Files
backnotprop__plannotator/packages/ui/components/blocks/HtmlBlock.tsx
Michael Ramos e20fd97be7 Consumer enablement for @plannotator/ui (0.24.0) (#1017)
* 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
2026-07-07 19:09:52 -07:00

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,
);