import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus"; import { isVimSelectionActionId, type VimSelectionHudContext, } from "../../shortcuts"; import type { Annotation, EditorMode, HostDraft, ImageAttachment, InputMethod } from "../../types"; import { AnnotationType } from "../../types"; import { copyTextPreservingFocus } from "../../utils/clipboard"; import { elementIdentityForAskAI } from "../../utils/parser"; import { getIdentity } from "../../utils/identity"; import { THUMBS_UP_LABEL } from "../../utils/quickLabels"; import { createVimHudCommand, getVimHudPhase, type VimHudCommand, } from "../../utils/vimHud"; import { AnnotationToolbar } from "../AnnotationToolbar"; import type { SelectionAction } from "../../utils/selectionActions"; import type { MentionSource } from "../../utils/mentions"; import { AttachmentsButton } from "../AttachmentsButton"; import { CommentPopover, type CommentAskAIHandler, type CommentTargetChip, } from "../CommentPopover"; import { VimKeyHud } from "../VimKeyHud"; import type { ViewerHandle } from "../Viewer"; import { computeComposerYield, distanceToRect, type ComposerYieldState, } from "./composerYield"; import { buildSyncNumbering } from "./annotationNumbering"; import { mergeUnanchoredIds } from "./unanchored"; import { MAX_PAGE_URL_LENGTH, checkBridgeProtocolVersion, formatBridgeProtocolWarning, rejectsLiveMessage, useHtmlAnnotation, type HtmlLiveSession, } from "./useHtmlAnnotation"; import { THEME_TOKENS, buildSrcdocInjection, buildThemeTokenPayload, hasHostThemeOptIn, injectIntoHead, resolveBridgeScriptUrl, } from "./srcdoc"; const PREFIX = "plannotator-bridge-"; function readThemeTokens(): Record { const style = getComputedStyle(document.documentElement); const tokens: Record = {}; for (const key of THEME_TOKENS) { const val = style.getPropertyValue(key).trim(); if (val) tokens[key] = val; } return tokens; } function isLightTheme(): boolean { return document.documentElement.classList.contains("light"); } function isBridgeReadyMessage(value: unknown): boolean { return typeof value === "object" && value !== null && "type" in value && value.type === `${PREFIX}ready`; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function parseVimSelectionHudContext( value: unknown, ): VimSelectionHudContext | null { return value === "inactive" || value === "block" || value === "inline" || value === "text" || value === "visual" || value === "visual-block" || value === "action" ? value : null; } interface VimBridgeCommand { readonly actionId: Parameters[1]; readonly key: string; readonly context: VimSelectionHudContext; } function parseVimBridgeCommand(value: unknown): VimBridgeCommand | null { if ( !isRecord(value) || value.type !== `${PREFIX}vim-command` || !isVimSelectionActionId(value.actionId) || typeof value.key !== "string" ) { return null; } const context = parseVimSelectionHudContext(value.context); return context ? { actionId: value.actionId, key: value.key, context } : null; } function parseVimBridgeState(value: unknown): VimSelectionHudContext | null { return isRecord(value) && value.type === `${PREFIX}vim-state` ? parseVimSelectionHudContext(value.phase) : null; } function parseVimBridgeHelp(value: unknown): boolean | null { return isRecord(value) && value.type === `${PREFIX}vim-help` && typeof value.open === "boolean" ? value.open : null; } const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024; /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */ export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000; /** * Why the bridge could not be established on the `bridgeScriptUrl` path. * Never produced on the inline path (the bridge and the parent are one * bundle there, and no ready timer runs). */ export type BridgeUnavailableInfo = | { kind: "timeout"; url: string; /** The wait that elapsed without a `ready`. */ timeoutMs: number; } | { kind: "version-mismatch"; url: string; expectedVersion: number; /** Absent when the ready carried no stamp (a pre-stamp asset). */ reportedVersion?: number; }; /** User-facing message for the in-surface error banner. */ export function formatBridgeUnavailableMessage(info: BridgeUnavailableInfo): string { if (info.kind === "timeout") { return `Annotation tools did not load: the bridge script at ${info.url} sent no ready signal within ${info.timeoutMs} ms. The page is shown without annotation. Check that the URL is reachable and that your Content Security Policy allows script-src for that origin.`; } const reported = info.reportedVersion === undefined ? "no version" : `version ${info.reportedVersion}`; return `Annotation tools may not work: this viewer expects bridge protocol version ${info.expectedVersion}, but the script at ${info.url} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`; } function parseVimBridgeCopy(value: unknown): string | null { return isRecord(value) && value.type === `${PREFIX}vim-copy` && typeof value.text === "string" && value.text.length > 0 && value.text.length <= MAX_VIM_COPY_TEXT_LENGTH ? value.text : null; } /** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */ /** The fewest milliseconds between two honored `step` messages from the page. */ export const STEP_MESSAGE_MIN_INTERVAL_MS = 150; export interface HtmlViewerProps { rawHtml: string; /** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of * rawHtml. The caller must also set `fullViewport` and `liveSession`. */ src?: string; /** Live session credentials paired with `src`: proxy origin + per-session * token, validated on every inbound message and stamped on every post. */ liveSession?: HtmlLiveSession; /** Current page (pathname + search) in a live multi-page session. Restore * filters annotations to this page; changing it re-applies the filter. */ currentPageUrl?: string; /** Live-mode page navigation reports (ready pageUrl + page-change). */ onPageChange?: (pageUrl: string) => void; /** A link the framed srcdoc document swallowed rather than navigating to. * A srcdoc document's base URL is the PARENT page's, so an unhandled * `` would load the host app inside the frame; the * bridge suppresses every such navigation and relays the RAW href here * (bounded and screened at the trust boundary) for the host to resolve — * see `resolveHtmlLinkIntent`. In-page `#fragment` links are scrolled by * the bridge and never reported. Never fires in live (`src`) sessions, * which navigate the proxied app for real. */ onOpenLink?: (href: string) => void; /** Fragment to scroll to once this document's bridge is ready, for a * linked document opened from a `#`-carrying link. Bare id, no `#`. */ initialFragment?: string; annotations: Annotation[]; onAddAnnotation: (ann: Annotation) => void; onSelectAnnotation: (id: string | null) => void; selectedAnnotationId: string | null; mode: EditorMode; /** Input method: 'drag' = text selection, 'pinpoint' = click an element. */ inputMethod: InputMethod; /** Interact/Annotate toggle for HTML and live-app surfaces. While false * the bridge keeps clicks native (no pinpoint capture, no hover outline) * and clicks/forms/navigation reach the page untouched. Text * drag-selection commenting stays live in BOTH modes, and committed * markers stay visible and clickable in BOTH modes. Default true (armed) * on both surface kinds. */ annotateModeActive?: boolean; /** Esc final rung (bridge-side or the parent-side listener here): the user * asked to leave Annotate for Interact. The host owns the mode state. */ onAnnotateModeExit?: () => void; /** Mod+Shift+A pressed while focus lived inside the iframe. */ onAnnotateModeToggle?: () => void; /** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the * host's floating tools over the page. The host owns that state. */ onToolsToggle?: () => void; /** A plain `]` (1) or `[` (-1) pressed inside the page while the surface is * armed (Annotate) and the page's focus is not in a text field: the host's * next / previous file (the Inbox steps through a thread's attachments). * The bridge forwards nothing in Interact, and the page still gets the * key. Honored only while the frame holds focus, and at most once per * STEP_MESSAGE_MIN_INTERVAL_MS. Absent: the message is ignored, as before. */ onStep?: (dir: 1 | -1) => void; /** Escape pressed inside the page in Interact that the page did not use * itself (no handler of the page cancelled it or stopped it, and focus was * not in one of its text fields): the host may close something of its own * (the Inbox leaves full screen). Never sent in Annotate, whose Escape is * `onAnnotateModeExit`. Honored only while the frame holds focus, and at * most once per STEP_MESSAGE_MIN_INTERVAL_MS. Absent: ignored, as before. */ onPageEscape?: () => void; /** Opt-in Vim-style keyboard selection. Default false for compatibility. */ vimModeEnabled?: boolean; /** Replace the iframe-local compact badge with the shared live key HUD. */ vimHudEnabled?: boolean; /** Show the parent key panel without affecting the iframe HUD reticle. */ vimHudKeyPanelEnabled?: boolean; /** Persist a user request to hide the parent key panel. */ onVimHudKeyPanelChange?: (enabled: boolean) => void; globalAttachments?: ImageAttachment[]; onAddGlobalAttachment?: (image: ImageAttachment) => void; onRemoveGlobalAttachment?: (path: string) => void; maxWidth?: number | null; /** Render edge-to-edge: fill the viewport, drop the card chrome + action bar, * and let the iframe own the full height instead of auto-resizing to content. */ fullViewport?: boolean; /** Hide the floating doc-level controls (attachments + global comment) in * full-viewport mode, so the user can read the page unobstructed. */ hideControls?: boolean; /** A version diff (vs the previous version) is available to toggle. */ diffAvailable?: boolean; /** Whether the diff-highlighted HTML is currently shown. */ diffActive?: boolean; /** Toggle the diff-highlighted view on/off. */ onToggleDiff?: () => void; onAskAI?: CommentAskAIHandler; /** Disable every annotation mutation entry point while preserving reading and navigation. */ readOnly?: boolean; /** Reports the full set of annotation ids with no live representation on * the page (fail-closed anchors hide markers rather than guess). Called * with the complete current set whenever it changes, including back to * empty on recovery. Fires in readOnly mode too. Complete over the * `annotations` prop: page rows with nothing to restore by (no quoted * text, no element anchor) are reported even though the bridge never * sees them, and an id this viewer minted for a local comment that the * host swapped out of `annotations` for its own id is not reported. */ onUnanchoredChange?: (ids: string[]) => void; /** Product cap on additional (shift-click) targets per comment, 0..16. * Enforced at the trust boundary, on submit and on restore, and carried * to the bridge so the in-page toggle stops at the same number. Default * 16 (the package cap); absent leaves every message unchanged. */ maxAdditionalTargets?: number; /** Opt-in host capability, passed straight through to the selection * toolbar: the host's own commands for the current selection, rendered as * one wand button that opens the package's dropdown. Absent → unchanged. */ selectionActions?: SelectionAction[]; /** Opt-in host capability: the glyph on the `selectionActions` button. * Absent → the package's own wand. */ selectionActionsIcon?: ReactNode; /** Opt-in host capability, forwarded to BOTH comment composers this viewer * mounts (the pinpoint/selection composer and the global one): the `@` * mention source for the composer's picker. The picked ids ride onto the * created annotation as `Annotation.mentions`. Absent → unchanged. */ mentionSource?: MentionSource; /** scrollIntoView behavior when a selected annotation is scrolled into * view inside the page. Default 'smooth'; pass 'auto' to carry the * parent's reduced-motion preference across the iframe boundary. */ scrollBehavior?: 'smooth' | 'auto'; /** Accessible iframe title. */ title?: string; /** * Opt-in: load the annotation bridge into the srcdoc document through a * classic `