mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e20fd97be7
* feat(ui): AnnotationPanel renderCardFooter + readOnly host props Per-card footer slot for host reply/resolve UI (clicks inside don't select the card); readOnly hides delete/edit on all card kinds. Both optional, both no-op by default — Plannotator unchanged. * feat(ui): bless 6 exports into the strict-consumer surface TableOfContents, ResizeHandle, useResizablePanel, useActiveSection, useScrollViewport, utils/annotationHelpers — verified strict-clean and backend-free (useResizablePanel persists via the storageBackend seam); added to the gate and the HANDOFF supported-imports table. * feat(ui): Viewer allowImages + readOnly host props allowImages threads to both CommentPopover sites (popover already gated its attach affordance; Viewer never exposed the knob). readOnly suppresses every composer entry point — selection toolbar (highlighter 'enabled'), pinpoint, global comment, attachments, checkbox toggles — while existing annotations still render and select. Both default to today's behavior. * fix(ui): lazy-import Viewer/CommentPopover in the consumer test (DOM-less bun test crashed on web-highlighter) * feat(ui): strict-consumer gate gains verbatimModuleSyntax + noUnusedLocals/Parameters Fixed the 24 violations the flags surfaced across the supported-import graph: type-only imports (verbatimModuleSyntax) and dead imports/locals. Consumers with stricter tsconfigs no longer have to relax them. * feat(ui): opt-in content-verifying annotation restore verifyRestoredContent on useAnnotationHighlighter: after a meta-based fromStore restore, the painted text is checked against originalText (whitespace-normalized). Mismatch -> highlight removed, text-search fallback re-anchors; if that fails too, onRestoreMismatch(annotation, restoredText) fires and nothing is painted. Default off — today's trust-the-positions behavior. Workspaces hit this live after document drift; correctness upgrade for every consumer. * chore(ui): 0.24.0 — HANDOFF consumer-enablement notes + version bump
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import React, { useRef, useEffect } from "react";
|
|
import { isCodeFilePath } from "@plannotator/core/code-file";
|
|
import type { Block } from "../../types";
|
|
import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
|
|
import { getImageSrc } from "../ImageThumbnail";
|
|
|
|
interface HtmlBlockProps {
|
|
block: Block;
|
|
imageBaseDir?: string;
|
|
onOpenLinkedDoc?: (path: string) => void;
|
|
onOpenCodeFile?: (path: string) => void;
|
|
onNavigateAnchor?: (hash: string) => void;
|
|
}
|
|
|
|
// Walks the sanitized DOM and rewrites relative <img src> / <a href> so they
|
|
// behave the same as their markdown counterparts:
|
|
// - Relative image paths route through /api/image?path=... so they load from
|
|
// the plan's directory, not the plannotator server root.
|
|
// - Relative .md / .mdx / .txt / .html links open in the linked-doc overlay when
|
|
// onOpenLinkedDoc is provided (same as [[wiki-links]] and [label](./x.md)).
|
|
// Absolute http(s) URLs and mailto: are left untouched.
|
|
function rewriteRelativeRefs(
|
|
root: HTMLElement,
|
|
imageBaseDir?: string,
|
|
onOpenLinkedDoc?: (path: string) => void,
|
|
onOpenCodeFile?: (path: string) => void,
|
|
onNavigateAnchor?: (hash: string) => void,
|
|
): (() => void) {
|
|
const cleanups: (() => void)[] = [];
|
|
|
|
root.querySelectorAll('img').forEach((img) => {
|
|
const src = img.getAttribute('src');
|
|
if (!src) return;
|
|
if (/^(https?:|data:|blob:)/i.test(src)) return;
|
|
img.setAttribute('src', getImageSrc(src, imageBaseDir));
|
|
});
|
|
|
|
root.querySelectorAll('a').forEach((a) => {
|
|
const href = a.getAttribute('href');
|
|
if (!href) return;
|
|
// External http(s) links: open in a new tab and close the tab-nabbing
|
|
// vector (opener reference back to the plannotator tab). Matches the
|
|
// markdown renderer's behavior for [label](https://...).
|
|
if (/^(https?:|\/\/)/i.test(href)) {
|
|
a.setAttribute('target', '_blank');
|
|
a.setAttribute('rel', 'noopener noreferrer');
|
|
return;
|
|
}
|
|
if (/^(mailto:|tel:)/i.test(href)) return;
|
|
// In-page anchor: native browser jump doesn't target the scroll viewport,
|
|
// so route through onNavigateAnchor to match InlineMarkdown's behavior.
|
|
if (href.startsWith('#')) {
|
|
if (!onNavigateAnchor) return;
|
|
const handler = (e: Event) => {
|
|
e.preventDefault();
|
|
onNavigateAnchor(href);
|
|
};
|
|
a.addEventListener('click', handler);
|
|
cleanups.push(() => a.removeEventListener('click', handler));
|
|
return;
|
|
}
|
|
if (onOpenCodeFile && isCodeFilePath(href)) {
|
|
const handler = (e: Event) => {
|
|
e.preventDefault();
|
|
onOpenCodeFile(href.replace(/#.*$/, ''));
|
|
};
|
|
a.addEventListener('click', handler);
|
|
cleanups.push(() => a.removeEventListener('click', handler));
|
|
return;
|
|
}
|
|
if (onOpenLinkedDoc && /\.(mdx?|txt|html?)(#.*)?$/i.test(href)) {
|
|
const handler = (e: Event) => {
|
|
e.preventDefault();
|
|
onOpenLinkedDoc(href.replace(/#.*$/, ''));
|
|
};
|
|
a.addEventListener('click', handler);
|
|
cleanups.push(() => a.removeEventListener('click', handler));
|
|
}
|
|
});
|
|
|
|
return () => cleanups.forEach((fn) => fn());
|
|
}
|
|
|
|
// The inner HTML is set imperatively (not via dangerouslySetInnerHTML) so that
|
|
// React's reconciliation never replaces the rendered subtree on re-render.
|
|
// That matters because <details open> is DOM-owned state — a stray innerHTML
|
|
// re-set on every parent re-render would collapse any open <details> the
|
|
// user just opened. Paired with React.memo below so the component itself
|
|
// stops re-rendering unless the block content actually changes.
|
|
const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor }) => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const sanitized = React.useMemo(
|
|
() => sanitizeBlockHtml(block.content),
|
|
[block.content],
|
|
);
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
if (ref.current.innerHTML !== sanitized) {
|
|
ref.current.innerHTML = sanitized;
|
|
}
|
|
const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor);
|
|
return cleanup;
|
|
}, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor]);
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-block-id={block.id}
|
|
data-block-type="html"
|
|
className="html-block my-4 text-[15px] leading-relaxed text-foreground/90"
|
|
/>
|
|
);
|
|
};
|
|
export const HtmlBlock = React.memo(
|
|
HtmlBlockImpl,
|
|
(prev, next) =>
|
|
prev.block.id === next.block.id &&
|
|
prev.block.content === next.block.content &&
|
|
prev.imageBaseDir === next.imageBaseDir &&
|
|
prev.onOpenLinkedDoc === next.onOpenLinkedDoc &&
|
|
prev.onOpenCodeFile === next.onOpenCodeFile &&
|
|
prev.onNavigateAnchor === next.onNavigateAnchor,
|
|
);
|