Files
Michael Ramos 782d9740c1 feat(annotate): carry agent-facing element context on HTML and live-app pinpoints (#1517)
A pinpointed element exported as a one-word placeholder ('[element:
Navigation]') or its flattened textContent. The bridge now captures a
bounded description at click time (tag, id, classes, ancestor path, role,
accessible name, allowlisted attributes, rendered text, an adaptive HTML
skeleton, box, landmark, heading, component hint, live route) as
elementContext; the parent re-validates and re-caps it; the export prints
a fenced skeleton plus selector/path/role/name lines under the comment;
the annotation panel gains a per-row Copy for element-bearing cards; the
feedback archive records element identity. Additive: annotations without
the field export byte-identically, share links drop it, no protocol bump.
2026-09-12 16:20:39 -07:00

1256 lines
49 KiB
TypeScript

import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
import { getIdentity } from "../../utils/identity";
import type {
ToolbarState,
CommentPopoverState,
QuickLabelPickerState,
UseAnnotationHighlighterReturn,
} from "../../hooks/useAnnotationHighlighter";
import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
const PREFIX = "plannotator-bridge-";
/** Outcome of comparing a bridge `ready` message's stamp with this parent. */
export interface BridgeProtocolVerdict {
ok: boolean;
/** The version this parent bundle speaks (`BRIDGE_PROTOCOL_VERSION`). */
expected: number;
/** The version the bridge reported; `undefined` when the ready carried none
* (a bridge asset built before the stamp existed, or a forged message). */
reported: number | undefined;
}
/**
* Compare a `ready` message against the parent's protocol version. A missing
* stamp counts as a mismatch: the only way it can be absent is a bridge asset
* older than the parent (or a page forging the message), which is exactly
* the drift this check exists to name.
*/
export function checkBridgeProtocolVersion(data: unknown): BridgeProtocolVerdict {
const raw = isRecord(data) ? data.protocolVersion : undefined;
const reported = typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
return {
ok: reported === BRIDGE_PROTOCOL_VERSION,
expected: BRIDGE_PROTOCOL_VERSION,
reported,
};
}
/** The one console warning a mismatch produces; names both versions. */
export function formatBridgeProtocolWarning(
verdict: BridgeProtocolVerdict,
bridgeScriptUrl?: string,
): string {
const reported = verdict.reported === undefined ? "none" : String(verdict.reported);
const source = bridgeScriptUrl ? `the bridge script at ${bridgeScriptUrl}` : "the bridge script";
return `[plannotator] HTML bridge protocol version mismatch: this viewer expects ${verdict.expected}, ${source} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
}
// Collision-proof annotation ids. `Date.now()` alone repeats within a millisecond,
// so two quick annotations could share a data-bind-id and clobber each other.
let htmlAnnSeq = 0;
function nextHtmlAnnId(): string {
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
}
// Ids minted for locally created annotations (create-mark) live per hook
// instance (mintedIdsRef below), not per module: the unanchored union only
// consults them against the bridge of the instance that minted them (a
// remounted viewer restores from the host's list and never reports an id it
// was not asked for), and a module-wide set leaked every id ever minted into
// unrelated later instances of a long-lived host page.
function htmlCommentDraftKey(
text: string,
anchor?: HtmlElementAnchor | null,
targetKey?: string,
): string {
return `html-selection:${targetKey ?? JSON.stringify(anchor ?? null)}:${text}`;
}
interface BridgeSelectionMessage {
type: `${typeof PREFIX}selection`;
text: string;
rect: BridgeRect;
modeOverride?: EditorMode;
/** Serialized element anchor (pinpoint clicks) — validated, size-capped. */
anchor?: HtmlElementAnchor;
/** True when the selection came from a pinpoint click on an element. */
pinpoint?: boolean;
/** Bridge-assigned key for the primary target (multi-select bookkeeping). */
targetKey?: string;
/** Semantic label from the pinpoint hover cascade (chips + export). */
targetLabel?: string;
/** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
context?: HtmlElementContext;
}
/** One draft target in an in-flight multi-select comment. Index 0 is primary. */
export interface HtmlDraftTarget {
key: string;
label?: string;
text: string;
anchor: HtmlElementAnchor | null;
context?: HtmlElementContext;
}
interface BridgeMultiTargetAddedMessage {
type: `${typeof PREFIX}multi-target-added`;
key: string;
label?: string;
text: string;
anchor?: HtmlElementAnchor;
context?: HtmlElementContext;
}
interface BridgeRect {
top: number;
left: number;
width: number;
height: number;
}
type BridgeMessage =
| BridgeSelectionMessage
| BridgeMultiTargetAddedMessage
| { type: `${typeof PREFIX}multi-target-removed`; key: string }
| { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
| { type: `${typeof PREFIX}selection-clear` }
| { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
| { type: `${typeof PREFIX}keytype`; key: string }
| { type: `${typeof PREFIX}mark-click`; id: string }
| { type: `${typeof PREFIX}unanchored`; ids: string[] }
| { type: `${typeof PREFIX}resize`; height: number }
| { type: `${typeof PREFIX}page-change`; pageUrl: string };
/** Live proxied-app session credentials: the proxy origin messages must come
* from, and the per-session token every message must echo. */
export interface HtmlLiveSession {
origin: string;
token: string;
}
/** Cap for live-mode page identity strings (mirrors the bridge's slice). */
export const MAX_PAGE_URL_LENGTH = 2048;
/** True when a live-session message event fails the origin or token check.
* Exported for protocol tests. */
export function rejectsLiveMessage(
live: HtmlLiveSession,
origin: string,
data: unknown,
): boolean {
if (origin !== live.origin) return true;
if (!isRecord(data) || data.token !== live.token) return true;
return false;
}
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
export interface UseHtmlAnnotationOptions {
iframeRef: RefObject<HTMLIFrameElement | null>;
/** Whether selection bridge messages may open composers or create annotations. */
enabled?: boolean;
annotations: Annotation[];
onAddAnnotation?: (ann: Annotation) => void;
onSelectAnnotation?: (id: string | null) => void;
selectedAnnotationId: string | null;
mode: EditorMode;
onResize?: (height: number) => void;
/** Live proxied-app session: reject messages that fail the origin or token
* check before parsing, and stamp token + concrete targetOrigin on every
* outbound post. Absent for srcdoc sessions (behavior unchanged). */
live?: HtmlLiveSession;
/** Live-mode page navigation reports (validated, capped at 2048 chars). */
onPageChange?: (pageUrl: string) => void;
/** Validated pointer positions relayed from inside the iframe while a
* pinpoint draft is open (iframe-local viewport coordinates), with the
* Shift state observed by the iframe (the parent cannot see modifiers
* held while the pointer lives in the sandbox). Drives the composer-yield
* fade in the host component. */
onBridgePointer?: (x: number, y: number, shift: boolean) => void;
/** Reports the full set of annotation ids that currently have NO live
* representation on the page — every target dead, or the restore never
* resolved (fail-closed anchors hide markers rather than guess). Called
* with the complete current set whenever it changes, including back to
* empty on recovery. Delivered in readOnly mode too: view-only surfaces
* are exactly where silently missing markers would go unnoticed. */
onUnanchoredChange?: (ids: string[]) => void;
/** Product cap on additional (shift-click) targets per comment, 0..16.
* Applied at the trust boundary, on submit, on restore, and carried to
* the bridge on arm-multi-select so the in-page toggle stops at the
* same number. Absent: the package's 16, and the arm message is unchanged. */
maxAdditionalTargets?: number;
/** scrollIntoView behavior for scroll-to (selecting an annotation).
* Absent: smooth, as before; pass 'auto' to honor reduced motion. */
scrollBehavior?: 'smooth' | 'auto';
}
/** Clamp a host cap into the package's bound; anything unusable is the default. */
export function resolveMaxAdditionalTargets(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
}
function postToIframe(
iframe: HTMLIFrameElement | null,
msg: Record<string, unknown>,
live?: HtmlLiveSession | null,
) {
if (live) {
// Live proxied-app sessions: token on every message, concrete targetOrigin.
// Browsers silently DROP a post whose targetOrigin does not match the
// receiving window (mid-navigation frames); some DOM environments throw
// instead, so align with the browser semantics explicitly.
try {
iframe?.contentWindow?.postMessage({ ...msg, token: live.token }, live.origin);
} catch {
// Dropped, matching browser behavior for unmatched target origins.
}
return;
}
iframe?.contentWindow?.postMessage(msg, "*");
}
function parseEditorMode(value: unknown): EditorMode | undefined {
return value === "selection"
|| value === "comment"
|| value === "redline"
|| value === "quickLabel"
? value
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
// Size caps for the anchor DTO — the bridge script runs inside a sandboxed
// iframe rendering arbitrary HTML, so everything it posts is validated and
// bounded before it can reach React state or the annotation model.
const MAX_ANCHOR_SELECTOR_LENGTH = 1024;
const MAX_ANCHOR_TAG_LENGTH = 64;
const MAX_ANCHOR_TEXT_LENGTH = 400;
// Multi-select caps: the additional-target array is bounded at the trust
// boundary (a hostile page cannot grow a draft past this), and the bridge's
// short target keys / 40-char hover labels get generous-but-hard ceilings.
export const MAX_ADDITIONAL_TARGETS = 16;
const MAX_TARGET_KEY_LENGTH = 64;
const MAX_TARGET_LABEL_LENGTH = 64;
// Selection text is page-controlled too (a pinpoint click posts the element's
// entire textContent), so it gets the same treatment: truncated here — not
// rejected, a legitimate huge selection still annotates — before it can reach
// React state, drafts, exported feedback, or a share URL. Mirrors
// MAX_SELECTION_TEXT in bridge-script.ts; this side is the authoritative one.
export const MAX_SELECTION_TEXT_LENGTH = 10000;
/** Truncate to the cap without ever splitting a UTF-16 surrogate pair (a
* lone high surrogate becomes U+FFFD once UTF-8-encoded downstream). */
export function capSelectionText(text: string): string {
if (text.length <= MAX_SELECTION_TEXT_LENGTH) return text;
let cut = MAX_SELECTION_TEXT_LENGTH;
const last = text.charCodeAt(cut - 1);
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
return text.slice(0, cut);
}
/**
* Validate a bridge-posted normalized marker point. Fail-closed but additive:
* a malformed point is DROPPED (the marker falls back to the target-rect
* default) without rejecting the anchor it rides on; finite values are
* clamped into the normalized 0..1 range.
*/
function parseAnchorPoint(value: unknown): { x: number; y: number } | undefined {
if (!isRecord(value)) return undefined;
const { x, y } = value;
if (
typeof x !== "number" || !Number.isFinite(x)
|| typeof y !== "number" || !Number.isFinite(y)
) {
return undefined;
}
return {
x: Math.min(1, Math.max(0, x)),
y: Math.min(1, Math.max(0, y)),
};
}
/** Validate a bridge-posted element anchor. Exported for protocol tests. */
export function parseHtmlElementAnchor(value: unknown): HtmlElementAnchor | null {
if (!isRecord(value)) return null;
const { selector, tagName, text } = value;
if (
typeof selector !== "string"
|| selector.length === 0
|| selector.length > MAX_ANCHOR_SELECTOR_LENGTH
|| typeof tagName !== "string"
|| tagName.length === 0
|| tagName.length > MAX_ANCHOR_TAG_LENGTH
) {
return null;
}
const point = parseAnchorPoint(value.point);
if (text === undefined) return { selector, tagName, ...(point ? { point } : {}) };
if (typeof text !== "string" || text.length > MAX_ANCHOR_TEXT_LENGTH) return null;
return { selector, tagName, text, ...(point ? { point } : {}) };
}
function parseTargetKey(value: unknown): string | null {
return typeof value === "string" && value.length > 0 && value.length <= MAX_TARGET_KEY_LENGTH
? value
: null;
}
function parseTargetLabel(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
// Labels derive from page-controlled attributes (aria-label etc.), so a
// hostile page can embed newlines that would become real markdown structure
// in the exported feedback — collapse ALL whitespace at the trust boundary.
const collapsed = value.replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
return collapsed.length > MAX_TARGET_LABEL_LENGTH
? collapsed.slice(0, MAX_TARGET_LABEL_LENGTH)
: collapsed;
}
// Element-context caps. The bridge builds the context under the same numbers,
// but this side is the authoritative one: every scalar is re-collapsed (a
// hostile page can embed newlines that would become markdown structure in the
// exported feedback), every list re-capped, unknown keys dropped, and the
// serialized whole bounded. A malformed context is DROPPED, never fatal to
// the annotation it rides on (the same additive rule as the anchor point).
export const MAX_ELEMENT_CONTEXT_BYTES = 2048;
const MAX_CONTEXT_TAG_LENGTH = 32;
const MAX_CONTEXT_ID_LENGTH = 100;
const MAX_CONTEXT_CLASSES = 9; // 8 + the "+N more" marker
const MAX_CONTEXT_CLASS_LENGTH = 48;
const MAX_CONTEXT_PATH_LENGTH = 512;
const MAX_CONTEXT_ROLE_LENGTH = 32;
const MAX_CONTEXT_NAME_LENGTH = 120;
const MAX_CONTEXT_ATTRS = 10;
const MAX_CONTEXT_ATTR_NAME_LENGTH = 40;
const MAX_CONTEXT_ATTR_VALUE_LENGTH = 120;
const MAX_CONTEXT_TEXT_LENGTH = 300;
const MAX_CONTEXT_OUTLINE_LENGTH = 600;
const MAX_CONTEXT_OUTLINE_LINES = 40;
const MAX_CONTEXT_LANDMARK_LENGTH = 80;
const MAX_CONTEXT_HEADING_LENGTH = 130;
const MAX_CONTEXT_COMPONENT_LENGTH = 100;
const MAX_CONTEXT_PAGE_TITLE_LENGTH = 200;
/** Attribute names the context may carry (mirrors CONTEXT_ATTRS in the bridge). */
const CONTEXT_ATTR_ALLOWLIST = new Set([
"href", "src", "alt", "title", "type", "name", "role", "placeholder", "for", "target", "rel",
"aria-label", "aria-labelledby", "aria-describedby", "aria-current", "aria-expanded", "aria-hidden", "aria-controls",
"data-annotate", "data-testid", "data-test", "data-test-id", "data-cy", "data-qa", "data-component", "data-id",
]);
function capAt(text: string, max: number): string {
let cut = max;
const last = text.charCodeAt(cut - 1);
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
return text.slice(0, cut);
}
/** Collapse control characters and whitespace runs, then cap. */
function collapseContextScalar(value: unknown, max: number): string | undefined {
if (typeof value !== "string") return undefined;
const collapsed = value.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
return collapsed.length > max ? capAt(collapsed, max) : collapsed;
}
/** The outline keeps its line breaks (it is fenced on export) but nothing
* else: control characters go, each line is whitespace-collapsed, and a
* backtick run that could close the export's fence is defused. */
function collapseContextOutline(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const lines = value
.replace(/[\x00-\x09\x0b-\x1f\x7f]+/g, " ")
.replace(/`{3,}/g, "'''")
.split("\n")
.map((line) => {
// Keep the skeleton's indentation (capped), collapse everything else.
const indent = (/^ */.exec(line)?.[0] ?? "").slice(0, 12);
return indent + line.slice(indent.length).replace(/\s+/g, " ").trim();
})
.filter((line) => line.trim().length > 0)
.slice(0, MAX_CONTEXT_OUTLINE_LINES);
const joined = lines.join("\n").trim();
if (!joined) return undefined;
return joined.length > MAX_CONTEXT_OUTLINE_LENGTH ? capAt(joined, MAX_CONTEXT_OUTLINE_LENGTH) : joined;
}
function contextBytes(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).length;
}
/** Validate a bridge-posted element context. Exported for protocol tests. */
export function parseHtmlElementContext(value: unknown): HtmlElementContext | undefined {
if (!isRecord(value)) return undefined;
const tag = collapseContextScalar(value.tag, MAX_CONTEXT_TAG_LENGTH);
if (!tag) return undefined;
const context: HtmlElementContext = { tag: tag.toLowerCase() };
const id = collapseContextScalar(value.id, MAX_CONTEXT_ID_LENGTH);
if (id) context.id = id;
if (Array.isArray(value.classes)) {
const classes: string[] = [];
for (const entry of value.classes) {
if (classes.length >= MAX_CONTEXT_CLASSES) break;
const cls = collapseContextScalar(entry, MAX_CONTEXT_CLASS_LENGTH);
if (cls) classes.push(cls);
}
if (classes.length) context.classes = classes;
}
const path = collapseContextScalar(value.path, MAX_CONTEXT_PATH_LENGTH);
if (path) context.path = path;
const role = collapseContextScalar(value.role, MAX_CONTEXT_ROLE_LENGTH);
if (role) context.role = role;
const name = collapseContextScalar(value.name, MAX_CONTEXT_NAME_LENGTH);
if (name) context.name = name;
if (Array.isArray(value.attrs)) {
const attrs: Array<[string, string]> = [];
for (const entry of value.attrs) {
if (attrs.length >= MAX_CONTEXT_ATTRS) break;
if (!Array.isArray(entry) || entry.length !== 2) continue;
const attrName = collapseContextScalar(entry[0], MAX_CONTEXT_ATTR_NAME_LENGTH);
if (!attrName || !CONTEXT_ATTR_ALLOWLIST.has(attrName.toLowerCase())) continue;
if (typeof entry[1] !== "string") continue;
attrs.push([attrName.toLowerCase(), collapseContextScalar(entry[1], MAX_CONTEXT_ATTR_VALUE_LENGTH) ?? ""]);
}
if (attrs.length) context.attrs = attrs;
}
const text = collapseContextScalar(value.text, MAX_CONTEXT_TEXT_LENGTH);
if (text) context.text = text;
const outline = collapseContextOutline(value.outline);
if (outline) context.outline = outline;
if (typeof value.children === "number" && Number.isFinite(value.children) && value.children >= 0) {
context.children = Math.min(100000, Math.floor(value.children));
}
if (isRecord(value.rect)) {
const rect = value.rect;
const nums = ["x", "y", "w", "h", "vw", "vh"].map((key) => {
const n = rect[key];
return typeof n === "number" && Number.isFinite(n) ? Math.round(Math.max(-1e6, Math.min(1e6, n))) : null;
});
if (nums.every((n) => n !== null)) {
const [x, y, w, h, vw, vh] = nums as number[];
context.rect = { x: x!, y: y!, w: w!, h: h!, vw: vw!, vh: vh! };
}
}
const landmark = collapseContextScalar(value.landmark, MAX_CONTEXT_LANDMARK_LENGTH);
if (landmark) context.landmark = landmark;
const heading = collapseContextScalar(value.heading, MAX_CONTEXT_HEADING_LENGTH);
if (heading) context.heading = heading;
const component = collapseContextScalar(value.component, MAX_CONTEXT_COMPONENT_LENGTH);
if (component) context.component = component;
if (isRecord(value.page)) {
const url = collapseContextScalar(value.page.url, MAX_PAGE_URL_LENGTH);
if (url) {
context.page = { url };
const title = collapseContextScalar(value.page.title, MAX_CONTEXT_PAGE_TITLE_LENGTH);
if (title) context.page.title = title;
}
}
// Serialized bound, re-enforced here: shed the expendable fields in the
// bridge's order until the whole fits (validated per-field caps make this
// unreachable for an honest bridge; a forged message cannot exceed it).
const shedOrder: Array<keyof HtmlElementContext> = ["outline", "text", "attrs", "classes", "path", "heading", "landmark", "component"];
for (const field of shedOrder) {
if (contextBytes(context) <= MAX_ELEMENT_CONTEXT_BYTES) break;
delete context[field];
}
return contextBytes(context) <= MAX_ELEMENT_CONTEXT_BYTES ? context : undefined;
}
function parseBridgeRect(value: unknown): BridgeRect | null {
if (!isRecord(value)) return null;
const { top, left, width, height } = value;
return typeof top === "number" && Number.isFinite(top)
&& typeof left === "number" && Number.isFinite(left)
&& typeof width === "number" && Number.isFinite(width)
&& typeof height === "number" && Number.isFinite(height)
? { top, left, width, height }
: null;
}
/** Validate any bridge message. Exported for protocol tests. */
export function parseBridgeMessage(value: unknown): BridgeMessage | null {
if (!isRecord(value) || typeof value.type !== "string") return null;
switch (value.type) {
case `${PREFIX}selection`: {
const rect = parseBridgeRect(value.rect);
if (typeof value.text !== "string" || !rect) return null;
return {
type: value.type,
text: capSelectionText(value.text),
rect,
modeOverride: parseEditorMode(value.modeOverride),
anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
pinpoint: value.pinpoint === true,
targetKey: parseTargetKey(value.targetKey) ?? undefined,
targetLabel: parseTargetLabel(value.targetLabel),
context: parseHtmlElementContext(value.context),
};
}
case `${PREFIX}multi-target-added`: {
const key = parseTargetKey(value.key);
if (!key || typeof value.text !== "string") return null;
return {
type: value.type,
key,
label: parseTargetLabel(value.label),
text: capSelectionText(value.text),
anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
context: parseHtmlElementContext(value.context),
};
}
case `${PREFIX}multi-target-removed`: {
const key = parseTargetKey(value.key);
return key ? { type: value.type, key } : null;
}
case `${PREFIX}pointer`:
return typeof value.x === "number" && Number.isFinite(value.x)
&& typeof value.y === "number" && Number.isFinite(value.y)
? { type: value.type, x: value.x, y: value.y, shift: value.shift === true }
: null;
case `${PREFIX}selection-clear`:
return { type: value.type };
case `${PREFIX}selection-rect`: {
const rect = parseBridgeRect(value.rect);
return rect ? { type: value.type, rect } : null;
}
case `${PREFIX}keytype`:
return typeof value.key === "string"
? { type: value.type, key: value.key }
: null;
case `${PREFIX}mark-click`:
// The id is page-controlled like every other bridge string: cap it like
// the bridge's own sync validation does (256) so a hostile page cannot
// ship an unbounded string into parent state via a forged mark-click.
return typeof value.id === "string" && value.id.length <= 256
? { type: value.type, id: value.id }
: null;
case `${PREFIX}unanchored`: {
// Bounded like the bridge's own emission (512 ids, 256 chars each); any
// out-of-contract entry rejects the whole report — the real bridge
// never sends one, so a violation means a forged message.
if (!Array.isArray(value.ids) || value.ids.length > 512) return null;
const unanchoredIds: string[] = [];
for (const entry of value.ids) {
if (typeof entry !== "string" || entry.length > 256) return null;
unanchoredIds.push(entry);
}
return { type: value.type, ids: unanchoredIds };
}
case `${PREFIX}resize`:
return typeof value.height === "number" && Number.isFinite(value.height)
? { type: value.type, height: value.height }
: null;
case `${PREFIX}page-change`:
// Live-mode SPA navigation report. Bounded like every bridge string.
return typeof value.pageUrl === "string"
&& value.pageUrl.length > 0
&& value.pageUrl.length <= MAX_PAGE_URL_LENGTH
? { type: value.type, pageUrl: value.pageUrl }
: null;
default:
return null;
}
}
/**
* Adapt source-validated iframe messages to the existing annotation UI.
*
* Malformed bridge payloads are ignored; annotations are posted back through
* the iframe protocol and reported through the supplied callbacks.
*/
export function useHtmlAnnotation({
iframeRef,
enabled = true,
onAddAnnotation,
onSelectAnnotation,
selectedAnnotationId,
mode,
onResize,
live,
onPageChange,
onBridgePointer,
onUnanchoredChange,
maxAdditionalTargets,
scrollBehavior,
}: UseHtmlAnnotationOptions): Omit<
UseAnnotationHighlighterReturn,
"highlighterRef" | "highlightRange" | "highlightMathElement"
> & {
/** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
draftTargets: HtmlDraftTarget[];
/** Remove one draft target (chip X). Removing the last cancels the draft. */
removeDraftTarget: (key: string) => void;
/** Flash a draft target's pinned outline in the page (chip hover). */
flashDraftTarget: (key: string) => void;
/** Bumped after every target add/remove so the composer can refocus its textarea. */
composerFocusToken: number;
/** Composer one-click "Looks good": submits the hardcoded positive label
* with the same anchor and multi-select targets a typed comment would carry. */
handleCommentLooksGood: () => void;
/** Ids this module minted for locally created annotations (create-mark),
* for the unanchored union: a minted id the host never listed is a
* swapped-out local mark, not a host row. Read-only, stable identity. */
createdAnnotationIds: ReadonlySet<string>;
} {
const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
const [composerFocusToken, setComposerFocusToken] = useState(0);
const pendingTextRef = useRef<string>("");
// Element anchor for the pending pinpoint selection — committed onto the
// annotation so restoration can resolve the exact element again.
const pendingAnchorRef = useRef<HtmlElementAnchor | null>(null);
const pendingContextRef = useRef<HtmlElementContext | null>(null);
const draftTargetsRef = useRef<HtmlDraftTarget[]>(draftTargets);
draftTargetsRef.current = draftTargets;
const onBridgePointerRef = useRef(onBridgePointer);
onBridgePointerRef.current = onBridgePointer;
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const modeRef = useRef(mode);
modeRef.current = mode;
// Ids this instance minted (see the module comment above nextHtmlAnnId);
// released on unmount so nothing outlives the viewer that created it.
const mintedIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const minted = mintedIdsRef.current;
return () => {
minted.clear();
};
}, []);
// Mirror toolbar visibility into a ref so the (stable) message handler can gate
// type-to-comment on "the markup toolbar is showing", like AnnotationToolbar does.
const toolbarStateRef = useRef(toolbarState);
toolbarStateRef.current = toolbarState;
// Mirror the open comment/quick-label state so the selection-clear handler can
// tell whether the user is mid-compose and must keep the captured text alive.
const commentPopoverRef = useRef(commentPopover);
commentPopoverRef.current = commentPopover;
const quickLabelPickerRef = useRef(quickLabelPicker);
quickLabelPickerRef.current = quickLabelPicker;
const onAddRef = useRef(onAddAnnotation);
onAddRef.current = onAddAnnotation;
const onSelectRef = useRef(onSelectAnnotation);
onSelectRef.current = onSelectAnnotation;
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
onUnanchoredChangeRef.current = onUnanchoredChange;
const liveRef = useRef<HtmlLiveSession | null>(live ?? null);
liveRef.current = live ?? null;
const onPageChangeRef = useRef(onPageChange);
onPageChangeRef.current = onPageChange;
// The effective cap and whether the host set one: only an explicit cap
// rides on arm-multi-select, so an unconfigured viewer posts today's message.
const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
const hostCapRef = useRef(maxAdditionalTargets !== undefined);
hostCapRef.current = maxAdditionalTargets !== undefined;
const anchorRef = useRef<HTMLDivElement | null>(null);
/** Post into the iframe with the live token + targetOrigin when a live
* session is active; srcdoc posts keep targetOrigin "*" and no token. */
const post = useCallback(
(msg: Record<string, unknown>) => {
postToIframe(iframeRef.current, msg, liveRef.current);
},
[iframeRef],
);
/**
* Remove one draft target. Removing the primary promotes the next remaining
* target (the composer's context text and pending anchor follow it);
* removing the final target cancels the draft. The bridge performs the same
* deterministic update on its side, so `remove-target` is ALWAYS posted:
* for chip removals it drives the bridge, and for bridge-echoed removals it
* is an idempotent no-op — which also resyncs the two sides if a hostile
* page forged the removal message the bridge never actually performed.
*/
const applyTargetRemoval = useCallback(
(key: string) => {
const targets = draftTargetsRef.current;
const index = targets.findIndex((t) => t.key === key);
if (index < 0) return;
post({ type: `${PREFIX}remove-target`, key });
const remaining = targets.filter((t) => t.key !== key);
if (remaining.length === 0) {
// Final target removed — the draft is cancelled (bridge side already
// tore down its pinned state via toggle-off or the remove-target post).
setDraftTargets([]);
setCommentPopover(null);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
return;
}
if (index === 0) {
// Primary removed — promote the next target: the comment's quoted
// text and restoration anchor now belong to it.
const next = remaining[0]!;
pendingTextRef.current = next.text;
pendingAnchorRef.current = next.anchor;
pendingContextRef.current = next.context ?? null;
setCommentPopover((prev) =>
prev ? { ...prev, contextText: next.text, selectedText: next.text } : prev,
);
}
setDraftTargets(remaining);
setComposerFocusToken((t) => t + 1);
},
[post],
);
const getOrCreateAnchor = useCallback(() => {
if (!anchorRef.current) {
const div = document.createElement("div");
div.style.position = "fixed";
div.style.pointerEvents = "none";
div.style.width = "1px";
div.style.height = "1px";
document.body.appendChild(div);
anchorRef.current = div;
}
return anchorRef.current;
}, []);
const positionAnchor = useCallback(
(bridgeRect: { top: number; left: number; width: number; height: number }) => {
const iframe = iframeRef.current;
if (!iframe) return null;
const iframeRect = iframe.getBoundingClientRect();
// Fresh anchor per selection. The toolbar/popover recompute position only
// when their `element` node identity changes, so reusing one anchor div
// leaves them pinned to the previous selection. Drop the old one first.
if (anchorRef.current) anchorRef.current.remove();
anchorRef.current = null;
const anchor = getOrCreateAnchor();
anchor.style.top = `${iframeRect.top + bridgeRect.top}px`;
anchor.style.left = `${iframeRect.left + bridgeRect.left + bridgeRect.width / 2}px`;
return anchor;
},
[iframeRef, getOrCreateAnchor],
);
useEffect(() => {
function handler(e: MessageEvent<unknown>) {
if (e.source !== iframeRef.current?.contentWindow) return;
// Live sessions verify origin + session token BEFORE parsing. The
// existing caps stay exactly as they are: live content is friendlier
// but the trust boundary does not relax.
const liveSession = liveRef.current;
if (liveSession && rejectsLiveMessage(liveSession, e.origin, e.data)) return;
const message = parseBridgeMessage(e.data);
if (!message) return;
const type = message.type;
if (
!enabledRef.current
&& type !== `${PREFIX}mark-click`
&& type !== `${PREFIX}unanchored`
&& type !== `${PREFIX}resize`
// Page identity is navigation state, not an annotation mutation.
&& type !== `${PREFIX}page-change`
) {
return;
}
if (type === `${PREFIX}selection`) {
pendingTextRef.current = message.text;
pendingAnchorRef.current = message.anchor ?? null;
pendingContextRef.current = message.context ?? null;
setDraftTargets([]); // a new selection always starts a fresh draft
const anchor = positionAnchor(message.rect);
if (!anchor) return;
// HTML and live-app surfaces are COMMENT-ONLY: redline (auto-DELETION)
// and quickLabel are markdown-surface features, so both the host's
// mode and any bridge-posted modeOverride clamp to the plain selection
// flow here (a hostile page can post modeOverride, so the clamp sits
// at the trust boundary, not in the host). Persisted DELETION
// annotations still restore through applyAnnotations — only CREATION
// is comment-only.
const requestedMode = message.modeOverride ?? modeRef.current;
const currentMode =
requestedMode === "redline" || requestedMode === "quickLabel"
? "selection"
: requestedMode;
if (
currentMode === "comment"
// Pinpoint click-to-pin: the click already chose the target, so skip
// the intermediate toolbar and go straight to the comment composer.
|| (message.pinpoint && currentMode === "selection")
) {
// Release iframe focus so the popover's textarea autofocus lands in the
// parent (otherwise the iframe keeps focus and swallows further keys).
iframeRef.current?.blur();
setCommentPopover({
anchorEl: anchor,
contextText: message.text,
selectedText: message.text,
draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
});
// Pinpoint drafts arm shift-click multi-select: the clicked element
// becomes the primary target of the (single) draft comment. The
// bridge only accepts shift-toggles once THIS explicit arm arrives,
// so drafts the composer does not mirror (quickLabel, redline) can
// never accumulate pins the saved annotation would not carry.
if (message.pinpoint && message.targetKey) {
setDraftTargets([
{
key: message.targetKey,
label: message.targetLabel,
text: message.text,
anchor: message.anchor ?? null,
context: message.context,
},
]);
post({
type: `${PREFIX}arm-multi-select`,
key: message.targetKey,
...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
});
}
} else {
setToolbarState({
element: anchor,
source: null,
selectionText: message.text,
});
}
}
if (type === `${PREFIX}multi-target-added`) {
// Only meaningful while a pinpoint draft composer is open. The array
// cap is enforced HERE, at the trust boundary — a hostile page cannot
// grow the draft past MAX_ADDITIONAL_TARGETS extra targets.
const targets = draftTargetsRef.current;
if (
commentPopoverRef.current
&& targets.length > 0
&& targets.length < 1 + maxTargetsRef.current
&& !targets.some((t) => t.key === message.key)
) {
setDraftTargets([
...targets,
{
key: message.key,
label: message.label,
text: message.text,
anchor: message.anchor ?? null,
context: message.context,
},
]);
setComposerFocusToken((t) => t + 1);
}
}
if (type === `${PREFIX}multi-target-removed`) {
applyTargetRemoval(message.key);
}
if (type === `${PREFIX}pointer`) {
onBridgePointerRef.current?.(message.x, message.y, message.shift);
}
if (type === `${PREFIX}selection-clear`) {
setToolbarState(null);
// Keep the captured text alive while a comment/quick-label is open: the user
// is composing, and the selection collapsing or scrolling out of view must
// not drop the annotation on submit. It's overwritten on the next selection.
if (!commentPopoverRef.current && !quickLabelPickerRef.current) {
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
}
}
if (type === `${PREFIX}selection-rect`) {
// The iframe content scrolled — move the anchor to the selection's new
// position and nudge the toolbar/popover (which listen to window scroll) to
// recompute, so they stay attached to the selection.
const iframe = iframeRef.current;
const anchor = anchorRef.current;
if (!iframe || !anchor) return;
const r = message.rect;
const iframeRect = iframe.getBoundingClientRect();
anchor.style.top = `${iframeRect.top + r.top}px`;
anchor.style.left = `${iframeRect.left + r.left + r.width / 2}px`;
window.dispatchEvent(new Event("scroll"));
}
if (type === `${PREFIX}keytype`) {
// Type-to-comment: only when the markup toolbar is showing (matches the
// markdown path, where AnnotationToolbar owns this keydown). Open a comment
// pre-filled with the typed char.
if (!toolbarStateRef.current) return;
const key = message.key;
const text = pendingTextRef.current;
if (!key || !text) return;
const anchor = anchorRef.current ?? getOrCreateAnchor();
// Release iframe focus so the popover textarea can take it (and the rest of
// the typing) — otherwise the iframe keeps focus and the bridge eats keys.
iframeRef.current?.blur();
setToolbarState(null);
setCommentPopover({
anchorEl: anchor,
contextText: text,
selectedText: text,
initialText: key,
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
});
}
if (type === `${PREFIX}mark-click`) {
onSelectRef.current?.(message.id);
}
if (type === `${PREFIX}unanchored`) {
onUnanchoredChangeRef.current?.(message.ids);
}
if (type === `${PREFIX}resize`) {
onResize?.(message.height);
}
if (type === `${PREFIX}page-change`) {
onPageChangeRef.current?.(message.pageUrl);
}
}
window.addEventListener("message", handler);
return () => {
window.removeEventListener("message", handler);
if (anchorRef.current) {
anchorRef.current.remove();
anchorRef.current = null;
}
};
}, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
useEffect(() => {
if (enabled) return;
setToolbarState(null);
setCommentPopover(null);
setQuickLabelPicker(null);
setDraftTargets([]);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
anchorRef.current?.remove();
anchorRef.current = null;
post({ type: `${PREFIX}cancel-selection` });
}, [enabled, post]);
useEffect(() => {
if (selectedAnnotationId) {
post({
type: `${PREFIX}scroll-to`,
id: selectedAnnotationId,
// Only an explicit host preference rides along; the default message
// is unchanged and the bridge scrolls smoothly as before.
...(scrollBehavior ? { behavior: scrollBehavior } : {}),
});
} else {
post({
type: `${PREFIX}focus-mark`,
id: null,
});
}
}, [selectedAnnotationId, post, scrollBehavior]);
const handleAnnotate = useCallback(
(type: AnnotationType) => {
if (!enabledRef.current) return;
const text = pendingTextRef.current;
if (!text || type !== AnnotationType.DELETION) return;
const id = nextHtmlAnnId();
mintedIdsRef.current.add(id);
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
onAddRef.current?.({
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.DELETION,
originalText: text,
author: getIdentity(),
createdA: Date.now(),
htmlAnchor: pendingAnchorRef.current ?? undefined,
elementContext: pendingContextRef.current ?? undefined,
});
setToolbarState(null);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
},
[post],
);
const handleRequestComment = useCallback(
(initialChar?: string) => {
if (!enabledRef.current) return;
const text = pendingTextRef.current;
if (!text) return;
const anchor = anchorRef.current ?? getOrCreateAnchor();
setToolbarState(null);
setCommentPopover({
anchorEl: anchor,
contextText: text,
selectedText: text,
initialText: initialChar,
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
});
},
[getOrCreateAnchor],
);
const handleCommentSubmit = useCallback(
(comment: string, images?: ImageAttachment[]) => {
if (!enabledRef.current) return;
// Prefer the text captured when the popover opened — it can't be clobbered by
// a later selection change or clear while the user is composing the comment.
const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
if (!text) return;
// Multi-select: everything past the primary rides on the SAME comment.
const targets = draftTargetsRef.current;
const additionalTargets: HtmlAnnotationTarget[] | undefined =
targets.length > 1
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
label: t.label,
text: t.text,
anchor: t.anchor ?? undefined,
...(t.context ? { context: t.context } : {}),
}))
: undefined;
const id = nextHtmlAnnId();
mintedIdsRef.current.add(id);
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
onAddRef.current?.({
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.COMMENT,
text: comment,
originalText: text,
author: getIdentity(),
createdA: Date.now(),
images,
htmlAnchor: pendingAnchorRef.current ?? undefined,
elementContext: pendingContextRef.current ?? undefined,
htmlAdditionalTargets: additionalTargets,
});
setCommentPopover(null);
setDraftTargets([]);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
},
[post],
);
// The composer's one-click "Looks good" (the restored thumbs-up for
// comment-only surfaces, where pinpoint clicks land straight in the
// composer and never see the selection toolbar). Mirrors
// handleCommentSubmit — same anchor, same multi-select targets — but
// emits the hardcoded positive label instead of typed prose.
const handleCommentLooksGood = useCallback(() => {
if (!enabledRef.current) return;
const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
if (!text) return;
const targets = draftTargetsRef.current;
const additionalTargets: HtmlAnnotationTarget[] | undefined =
targets.length > 1
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
label: t.label,
text: t.text,
anchor: t.anchor ?? undefined,
...(t.context ? { context: t.context } : {}),
}))
: undefined;
const id = nextHtmlAnnId();
mintedIdsRef.current.add(id);
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
onAddRef.current?.({
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.COMMENT,
text: THUMBS_UP_LABEL.text,
originalText: text,
isQuickLabel: true,
quickLabelTip: THUMBS_UP_LABEL.tip,
author: getIdentity(),
createdA: Date.now(),
htmlAnchor: pendingAnchorRef.current ?? undefined,
elementContext: pendingContextRef.current ?? undefined,
htmlAdditionalTargets: additionalTargets,
});
setCommentPopover(null);
setDraftTargets([]);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
}, [post]);
const handleCommentClose = useCallback(() => {
post({ type: `${PREFIX}cancel-selection` });
setCommentPopover(null);
setDraftTargets([]);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
}, [post]);
const removeDraftTarget = useCallback(
(key: string) => {
if (!enabledRef.current) return;
applyTargetRemoval(key);
},
[applyTargetRemoval],
);
const flashDraftTarget = useCallback(
(key: string) => {
post({ type: `${PREFIX}flash-target`, key });
},
[post],
);
const handleToolbarClose = useCallback(() => {
post({ type: `${PREFIX}cancel-selection` });
setToolbarState(null);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
}, [post]);
const applyQuickLabel = useCallback(
(label: QuickLabel, clearState: () => void) => {
if (!enabledRef.current) return;
const text = pendingTextRef.current;
if (!text) return;
const id = nextHtmlAnnId();
mintedIdsRef.current.add(id);
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
onAddRef.current?.({
id,
blockId: "",
startOffset: 0,
endOffset: 0,
type: AnnotationType.COMMENT,
text: label.text,
originalText: text,
isQuickLabel: true,
quickLabelTip: label.tip,
author: getIdentity(),
createdA: Date.now(),
htmlAnchor: pendingAnchorRef.current ?? undefined,
elementContext: pendingContextRef.current ?? undefined,
});
clearState();
pendingTextRef.current = "";
pendingAnchorRef.current = null;
pendingContextRef.current = null;
},
[post],
);
const handleQuickLabel = useCallback(
(label: QuickLabel) => applyQuickLabel(label, () => setToolbarState(null)),
[applyQuickLabel],
);
const handleFloatingQuickLabel = useCallback(
(label: QuickLabel) => applyQuickLabel(label, () => setQuickLabelPicker(null)),
[applyQuickLabel],
);
const handleQuickLabelPickerDismiss = useCallback(() => {
post({ type: `${PREFIX}cancel-selection` });
setQuickLabelPicker(null);
pendingTextRef.current = "";
pendingAnchorRef.current = null;
}, [post]);
const removeHighlight = useCallback(
(id: string) => {
post({ type: `${PREFIX}remove-mark`, id });
},
[post],
);
const clearAllHighlights = useCallback(() => {
post({ type: `${PREFIX}clear-marks` });
}, [post]);
const applyAnnotations = useCallback(
(anns: Annotation[]) => {
for (const ann of anns) {
if (ann.type === AnnotationType.GLOBAL_COMMENT) continue;
const annType = ann.type === AnnotationType.DELETION ? "deletion" : "comment";
// Multi-target annotations restore every additional target as a pin
// under the same id (same badge number). Anchor-only and capped.
const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
.map((t) => t.anchor)
.filter((a): a is HtmlElementAnchor => !!a)
.slice(0, maxTargetsRef.current);
post({
type: `${PREFIX}find-and-mark`,
id: ann.id,
originalText: ann.originalText,
annotationType: annType,
// Anchor-first restore: the bridge resolves the serialized element
// and scopes the text search to it, falling back to document-wide.
anchor: ann.htmlAnchor,
additionalAnchors: additionalAnchors.length ? additionalAnchors : undefined,
});
}
},
[post],
);
return {
toolbarState,
commentPopover,
quickLabelPicker,
handleAnnotate,
handleQuickLabel,
handleToolbarClose,
handleRequestComment,
handleCommentSubmit,
handleCommentLooksGood,
handleCommentClose,
handleFloatingQuickLabel,
handleQuickLabelPickerDismiss,
removeHighlight,
clearAllHighlights,
applyAnnotations,
draftTargets,
removeDraftTarget,
flashDraftTarget,
composerFocusToken,
createdAnnotationIds: mintedIdsRef.current,
};
}