mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
782d9740c1
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.
1256 lines
49 KiB
TypeScript
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,
|
|
};
|
|
}
|