mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
8e0c51f5ff
Follow-ups from the Workspaces adoption of 0.33.0: a math-slot module hosts can redirect Mermaid's own katex import to (importer-scoped resolveId recipe in HANDOFF) so a math document fetches one KaTeX chunk owned by the host; HtmlViewer bridgeErrorDisplay ('banner' default, 'none' lets a host own the failure banner while onBridgeUnavailable still fires); the documented bridge alias narrowed to relative sibling imports; resetMathRenderer keeps a registered loader and discards stale in-flight loads via an epoch, with setMathRendererLoader(null) and getMathRendererLoader added. Plannotator's own bundles unchanged (markers and sizes within noise of main). Bumps @plannotator/ui to 0.34.0; core stays 0.25.0.
AI-assisted (Claude) under maintainer direction.
1236 lines
51 KiB
TypeScript
1236 lines
51 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus";
|
|
import {
|
|
isVimSelectionActionId,
|
|
type VimSelectionHudContext,
|
|
} from "../../shortcuts";
|
|
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
import { AnnotationType } from "../../types";
|
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
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 { 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<string, string> {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const tokens: Record<string, string> = {};
|
|
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<string, unknown> {
|
|
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<typeof createVimHudCommand>[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. */
|
|
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;
|
|
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;
|
|
/** 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;
|
|
/** 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 `<script src>` from this URL (the package's generated
|
|
* `components/html-viewer/bridge-script.asset.js`, served by the host)
|
|
* instead of inlining the 185 KB script into every document. Absent (the
|
|
* default, and Plannotator's only path): inline, unchanged. The tag lands
|
|
* where the inline script does, at the end of `<head>`, before the body.
|
|
* The URL is resolved against THIS document's base (`document.baseURI`)
|
|
* before it is written, never against the framed page, so a page's own
|
|
* `<base href>` cannot redirect it. The srcdoc frame is an opaque origin,
|
|
* so the script needs no CORS and no `crossorigin` attribute is set; a CSP
|
|
* header on the host page is inherited by the frame and must allow
|
|
* `script-src` for the asset origin, and the asset must not be served with
|
|
* `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
|
|
* mode, where the proxy injects the bridge.
|
|
*/
|
|
bridgeScriptUrl?: string;
|
|
/**
|
|
* How long to wait for the bridge's `ready` after each document load on
|
|
* the `bridgeScriptUrl` path before the surface shows an error state.
|
|
* Default 5000 ms. No timer runs on the inline path.
|
|
*/
|
|
bridgeReadyTimeoutMs?: number;
|
|
/** The bridge could not be established on the `bridgeScriptUrl` path (no
|
|
* ready within the timeout, or a protocol version mismatch). The surface
|
|
* shows its own banner as well unless `bridgeErrorDisplay` is `'none'`;
|
|
* this lets the host react (telemetry, a retry affordance). Never called
|
|
* on the inline path. */
|
|
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
|
|
/**
|
|
* Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
|
|
* `'banner'` (default): the package renders its `[data-bridge-error]`
|
|
* strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
|
|
* the host owns the display through `onBridgeUnavailable`, which fires
|
|
* exactly as before (and a version mismatch still logs its one console
|
|
* warning). Meaningless on the inline path, which never shows a strip.
|
|
*/
|
|
bridgeErrorDisplay?: "banner" | "none";
|
|
}
|
|
|
|
/**
|
|
* Render arbitrary HTML in a sandbox and adapt its validated bridge messages
|
|
* to the same annotation controls used by the Markdown viewer.
|
|
*/
|
|
export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
(
|
|
{
|
|
rawHtml,
|
|
src,
|
|
liveSession,
|
|
currentPageUrl,
|
|
onPageChange,
|
|
annotations,
|
|
onAddAnnotation,
|
|
onSelectAnnotation,
|
|
selectedAnnotationId,
|
|
mode,
|
|
inputMethod,
|
|
annotateModeActive = true,
|
|
onAnnotateModeExit,
|
|
onAnnotateModeToggle,
|
|
vimModeEnabled = false,
|
|
vimHudEnabled = false,
|
|
vimHudKeyPanelEnabled = true,
|
|
onVimHudKeyPanelChange,
|
|
globalAttachments = [],
|
|
onAddGlobalAttachment,
|
|
onRemoveGlobalAttachment,
|
|
maxWidth,
|
|
fullViewport,
|
|
hideControls,
|
|
diffAvailable,
|
|
diffActive,
|
|
onToggleDiff,
|
|
onAskAI,
|
|
readOnly = false,
|
|
onUnanchoredChange,
|
|
maxAdditionalTargets,
|
|
scrollBehavior,
|
|
title = "HTML Plan Viewer",
|
|
bridgeScriptUrl,
|
|
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
|
|
onBridgeUnavailable,
|
|
bridgeErrorDisplay = "banner",
|
|
},
|
|
ref,
|
|
) => {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
|
|
const [iframeHeight, setIframeHeight] = useState(600);
|
|
// Increment on every bridge-ready event so srcdoc navigations re-send
|
|
// state even though the iframe element and its WindowProxy are reused.
|
|
const [iframeReadyVersion, setIframeReadyVersion] = useState(0);
|
|
const [iframeFocused, setIframeFocused] = useState(false);
|
|
const [vimBridgePhase, setVimBridgePhase] =
|
|
useState<VimSelectionHudContext>("inactive");
|
|
const [vimHudCommand, setVimHudCommand] = useState<VimHudCommand | null>(null);
|
|
const [vimHelpOpen, setVimHelpOpen] = useState(false);
|
|
const vimHudSequenceRef = useRef(0);
|
|
const vimHudActive = !readOnly && vimModeEnabled && vimHudEnabled;
|
|
const [globalCommentPopover, setGlobalCommentPopover] = useState<{
|
|
anchorEl: HTMLElement;
|
|
contextText: string;
|
|
} | null>(null);
|
|
|
|
// Live proxied-app mode: the iframe navigates a real origin, so the
|
|
// srcdoc pipeline is skipped entirely and its messages carry credentials.
|
|
const liveMode = !!src;
|
|
const liveSessionRef = useRef<HtmlLiveSession | null>(liveSession ?? null);
|
|
liveSessionRef.current = liveSession ?? null;
|
|
const onPageChangeRef = useRef(onPageChange);
|
|
onPageChangeRef.current = onPageChange;
|
|
const onAnnotateModeExitRef = useRef(onAnnotateModeExit);
|
|
onAnnotateModeExitRef.current = onAnnotateModeExit;
|
|
const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
|
|
onAnnotateModeToggleRef.current = onAnnotateModeToggle;
|
|
|
|
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
const postToBridge = useCallback((msg: Record<string, unknown>) => {
|
|
const win = iframeRef.current?.contentWindow;
|
|
if (!win) return;
|
|
const live = liveSessionRef.current;
|
|
if (live) {
|
|
// Browsers silently drop posts whose targetOrigin does not match the
|
|
// receiving window (mid-navigation frames); some DOM environments
|
|
// throw instead, so align with the browser semantics explicitly.
|
|
try {
|
|
win.postMessage({ ...msg, token: live.token }, live.origin);
|
|
} catch {
|
|
// Dropped, matching browser behavior for unmatched target origins.
|
|
}
|
|
} else {
|
|
win.postMessage(msg, "*");
|
|
}
|
|
}, []);
|
|
|
|
// Host theming is opt-in per document (Plannotator-generated artifacts tag
|
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
|
|
// The URL path is srcdoc-only: live mode has the proxy inject the bridge.
|
|
// Resolved against THIS document's base before it is written into the
|
|
// srcdoc, so a framed page's own <base href> can never re-anchor it.
|
|
const bridgeUrl = useMemo(
|
|
() => (!liveMode && bridgeScriptUrl
|
|
? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
|
|
: undefined),
|
|
[liveMode, bridgeScriptUrl],
|
|
);
|
|
const bridgeUrlRef = useRef(bridgeUrl);
|
|
bridgeUrlRef.current = bridgeUrl;
|
|
|
|
const srcdoc = useMemo(() => {
|
|
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
const injection = buildSrcdocInjection({
|
|
tokens: readThemeTokens(),
|
|
isLight: isLightTheme(),
|
|
hostTheme,
|
|
diffActive: !!diffActive,
|
|
bridgeScriptUrl: bridgeUrl,
|
|
});
|
|
return injectIntoHead(rawHtml, injection);
|
|
}, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
|
|
|
|
// Error state for the bridgeScriptUrl path only: the inline path never
|
|
// sets it (no timer, and a version mismatch there can only be a forged
|
|
// message, which is warned about and otherwise ignored).
|
|
const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
|
|
// A version-mismatch banner is dismissible (the older bridge keeps
|
|
// working); reset whenever the error itself changes.
|
|
const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
|
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
|
|
onBridgeUnavailableRef.current = onBridgeUnavailable;
|
|
// The timeout is read through a ref at arming time: the timer is armed
|
|
// once per document load (URL or srcdoc change), never re-armed by a
|
|
// later prop change, so a host adjusting bridgeReadyTimeoutMs after the
|
|
// bridge is ready can never produce a false timeout.
|
|
const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
|
|
bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
|
|
useEffect(() => {
|
|
if (!bridgeUrl || srcdoc === undefined) return;
|
|
setBridgeError(null);
|
|
setBridgeErrorDismissed(false);
|
|
const url = bridgeUrl;
|
|
const timeoutMs = bridgeReadyTimeoutMsRef.current;
|
|
readyTimerRef.current = setTimeout(() => {
|
|
readyTimerRef.current = null;
|
|
setBridgeError({ kind: "timeout", url, timeoutMs });
|
|
}, timeoutMs);
|
|
return () => {
|
|
if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
|
|
readyTimerRef.current = null;
|
|
};
|
|
}, [bridgeUrl, srcdoc]);
|
|
useEffect(() => {
|
|
setBridgeErrorDismissed(false);
|
|
if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
|
|
}, [bridgeError]);
|
|
|
|
const handleResize = useCallback((height: number) => {
|
|
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
setIframeHeight(height);
|
|
}, [liveMode]);
|
|
|
|
// Composer yield while shift-selecting (multi-target drafts): fade the
|
|
// composer as the pointer approaches, click-through when over it. Pointer
|
|
// positions arrive from parent mousemoves AND from the bridge (the iframe
|
|
// consumes moves over the page, so the bridge relays them).
|
|
const [composerYield, setComposerYield] = useState<ComposerYieldState>("none");
|
|
const composerYieldRef = useRef(composerYield);
|
|
composerYieldRef.current = composerYield;
|
|
const shiftHeldRef = useRef(false);
|
|
|
|
const handleYieldPointer = useCallback((clientX: number, clientY: number) => {
|
|
if (!shiftHeldRef.current) return;
|
|
const popover = document.querySelector("[data-comment-popover]");
|
|
if (!popover) return;
|
|
const rect = popover.getBoundingClientRect();
|
|
const next = computeComposerYield(
|
|
composerYieldRef.current,
|
|
distanceToRect(clientX, clientY, rect),
|
|
);
|
|
if (next !== composerYieldRef.current) setComposerYield(next);
|
|
}, []);
|
|
|
|
const handleBridgePointer = useCallback(
|
|
(x: number, y: number, shift: boolean) => {
|
|
// The bridge is the only observer of Shift while the pointer lives
|
|
// inside the sandbox (parent keydowns don't fire there, and window
|
|
// blur clears our local flag when focus enters the iframe) — so the
|
|
// relayed shift state arms/disarms the yield directly.
|
|
shiftHeldRef.current = shift;
|
|
if (!shift) {
|
|
setComposerYield("none");
|
|
return;
|
|
}
|
|
const iframeRect = iframeRef.current?.getBoundingClientRect();
|
|
if (!iframeRect) return;
|
|
handleYieldPointer(iframeRect.left + x, iframeRect.top + y);
|
|
},
|
|
[handleYieldPointer],
|
|
);
|
|
|
|
// Unanchored union: the bridge reports ids with no live representation,
|
|
// completed here with what the bridge cannot see (textless page rows it
|
|
// was never asked to restore) and minus locally minted ids the host
|
|
// swapped out of `annotations`. Bridge reports deliver as they arrive
|
|
// (pass-through timing); a prop-side change delivers only when the union
|
|
// actually changes, so a viewer with nothing to complete delivers exactly
|
|
// the bridge list, exactly when the bridge posts it.
|
|
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
const annotationsRef = useRef(annotations);
|
|
annotationsRef.current = annotations;
|
|
const bridgeUnanchoredRef = useRef<readonly string[]>([]);
|
|
const lastDeliveredUnanchoredRef = useRef("[]");
|
|
const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
// The bridge's first report for the current document is the one that
|
|
// follows the restore batch (the parent asks for it with
|
|
// report-unanchored, and the bridge answers after its next complete
|
|
// pass, empty set included). Nothing is delivered before it: a host
|
|
// that acknowledges "the first report after a reload" must get the
|
|
// post-restore set, never a prop-side set computed at mount.
|
|
const bridgeReportedRef = useRef(false);
|
|
// An in-place document swap (rawHtml or src changes on one instance):
|
|
// the old document may still emit through the same contentWindow before
|
|
// the new one is ready. From the swap until the next ready, bridge
|
|
// reports belong to the old document and are dropped, and the
|
|
// last-delivered key is reset so the new document's first answer is
|
|
// delivered even when it equals the old one. First mount is not a swap.
|
|
const awaitingReadyRef = useRef(false);
|
|
const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
|
|
const previousIdentity = documentIdentityRef.current;
|
|
if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
|
|
awaitingReadyRef.current = true;
|
|
bridgeReportedRef.current = false;
|
|
lastDeliveredUnanchoredRef.current = "[]";
|
|
bridgeUnanchoredRef.current = [];
|
|
}
|
|
documentIdentityRef.current = { rawHtml, src };
|
|
const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
|
|
const key = JSON.stringify(ids);
|
|
if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
|
|
lastDeliveredUnanchoredRef.current = key;
|
|
onUnanchoredChangeRef.current?.(ids);
|
|
}, []);
|
|
const handleBridgeUnanchored = useCallback((ids: string[]) => {
|
|
if (awaitingReadyRef.current) return;
|
|
bridgeUnanchoredRef.current = ids;
|
|
bridgeReportedRef.current = true;
|
|
deliverUnanchored(
|
|
mergeUnanchoredIds({
|
|
bridgeIds: ids,
|
|
annotations: annotationsRef.current,
|
|
createdIds: createdIdsRef.current,
|
|
}),
|
|
false,
|
|
);
|
|
}, [deliverUnanchored]);
|
|
|
|
const hook = useHtmlAnnotation({
|
|
iframeRef,
|
|
enabled: !readOnly,
|
|
annotations,
|
|
onAddAnnotation,
|
|
onSelectAnnotation,
|
|
selectedAnnotationId,
|
|
mode,
|
|
onResize: handleResize,
|
|
live: liveSession,
|
|
onPageChange,
|
|
onBridgePointer: handleBridgePointer,
|
|
onUnanchoredChange: handleBridgeUnanchored,
|
|
maxAdditionalTargets,
|
|
scrollBehavior,
|
|
});
|
|
createdIdsRef.current = hook.createdAnnotationIds;
|
|
|
|
useEffect(() => {
|
|
if (!bridgeReportedRef.current) return;
|
|
deliverUnanchored(
|
|
mergeUnanchoredIds({
|
|
bridgeIds: bridgeUnanchoredRef.current,
|
|
annotations,
|
|
createdIds: hook.createdAnnotationIds,
|
|
}),
|
|
true,
|
|
);
|
|
}, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
|
|
|
|
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
|
|
// Track Shift while a multi-select draft composer is open; releasing it
|
|
// (or losing window focus) always restores the composer.
|
|
useEffect(() => {
|
|
if (!multiSelectActive) {
|
|
shiftHeldRef.current = false;
|
|
setComposerYield("none");
|
|
return;
|
|
}
|
|
const down = (e: KeyboardEvent) => {
|
|
if (e.key === "Shift") shiftHeldRef.current = true;
|
|
};
|
|
const release = () => {
|
|
shiftHeldRef.current = false;
|
|
setComposerYield("none");
|
|
};
|
|
const up = (e: KeyboardEvent) => {
|
|
if (e.key === "Shift") release();
|
|
};
|
|
const move = (e: MouseEvent) => {
|
|
// Parent-side pointer (over app chrome or the composer itself).
|
|
if (e.shiftKey) shiftHeldRef.current = true;
|
|
handleYieldPointer(e.clientX, e.clientY);
|
|
};
|
|
window.addEventListener("keydown", down);
|
|
window.addEventListener("keyup", up);
|
|
window.addEventListener("blur", release);
|
|
window.addEventListener("mousemove", move);
|
|
return () => {
|
|
window.removeEventListener("keydown", down);
|
|
window.removeEventListener("keyup", up);
|
|
window.removeEventListener("blur", release);
|
|
window.removeEventListener("mousemove", move);
|
|
};
|
|
}, [multiSelectActive, handleYieldPointer]);
|
|
|
|
// Chip data for the composer: semantic label + short excerpt per target.
|
|
const targetChips = useMemo<CommentTargetChip[] | undefined>(() => {
|
|
if (!hook.draftTargets.length) return undefined;
|
|
return hook.draftTargets.map((t) => ({
|
|
key: t.key,
|
|
label: t.label,
|
|
excerpt: t.text.replace(/\s+/g, " ").trim().slice(0, 80),
|
|
}));
|
|
}, [hook.draftTargets]);
|
|
|
|
useEffect(() => {
|
|
function handler(e: MessageEvent<unknown>) {
|
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
// Live sessions verify origin + token before reading anything.
|
|
const live = liveSessionRef.current;
|
|
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
|
|
if (isBridgeReadyMessage(e.data)) {
|
|
// Protocol stamp: one console warning on drift, naming both
|
|
// versions. The ready is still honored (an older bridge answers
|
|
// every message shape it knows); on the bridgeScriptUrl path the
|
|
// surface additionally shows its error banner, because there the
|
|
// drift is a real deployment state (a cached asset from a previous
|
|
// package version) rather than a forged message.
|
|
const verdict = checkBridgeProtocolVersion(e.data);
|
|
const url = bridgeUrlRef.current;
|
|
if (!verdict.ok) {
|
|
console.warn(formatBridgeProtocolWarning(verdict, url));
|
|
}
|
|
if (url) {
|
|
if (readyTimerRef.current !== null) {
|
|
clearTimeout(readyTimerRef.current);
|
|
readyTimerRef.current = null;
|
|
}
|
|
setBridgeError(
|
|
verdict.ok
|
|
? null
|
|
: {
|
|
kind: "version-mismatch",
|
|
url,
|
|
expectedVersion: verdict.expected,
|
|
reportedVersion: verdict.reported,
|
|
},
|
|
);
|
|
}
|
|
setIframeReadyVersion((version) => version + 1);
|
|
setVimBridgePhase("inactive");
|
|
setVimHudCommand(null);
|
|
setVimHelpOpen(false);
|
|
// Live ready carries the page identity (validated like page-change)
|
|
// so reloads and cross-page navigations re-anchor the restore filter.
|
|
if (live && isRecord(e.data)) {
|
|
const pageUrl = e.data.pageUrl;
|
|
if (
|
|
typeof pageUrl === "string"
|
|
&& pageUrl.length > 0
|
|
&& pageUrl.length <= MAX_PAGE_URL_LENGTH
|
|
) {
|
|
onPageChangeRef.current?.(pageUrl);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// Interact/Annotate mode messages ride the same authenticated path:
|
|
// live sessions already rejected wrong-origin/tokenless data above.
|
|
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-exit`) {
|
|
onAnnotateModeExitRef.current?.();
|
|
return;
|
|
}
|
|
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-toggle`) {
|
|
onAnnotateModeToggleRef.current?.();
|
|
return;
|
|
}
|
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
if (vimCopy !== null) {
|
|
const iframe = iframeRef.current;
|
|
if (
|
|
!readOnly
|
|
&& vimModeEnabled
|
|
&& iframe
|
|
&& document.activeElement === iframe
|
|
) {
|
|
copyTextPreservingFocus(vimCopy, iframe);
|
|
}
|
|
return;
|
|
}
|
|
if (!vimHudActive) return;
|
|
const vimHelp = parseVimBridgeHelp(e.data);
|
|
if (vimHelp !== null) {
|
|
setVimHelpOpen(vimHelp);
|
|
return;
|
|
}
|
|
const vimState = parseVimBridgeState(e.data);
|
|
if (vimState) {
|
|
setVimBridgePhase(vimState);
|
|
return;
|
|
}
|
|
const vimCommand = parseVimBridgeCommand(e.data);
|
|
if (vimCommand) {
|
|
vimHudSequenceRef.current += 1;
|
|
setVimHudCommand(createVimHudCommand(
|
|
vimHudSequenceRef.current,
|
|
vimCommand.actionId,
|
|
vimCommand.key,
|
|
vimCommand.context,
|
|
));
|
|
}
|
|
}
|
|
window.addEventListener("message", handler);
|
|
return () => window.removeEventListener("message", handler);
|
|
}, [readOnly, vimHudActive, vimModeEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (vimHudActive) return;
|
|
setVimBridgePhase("inactive");
|
|
setVimHudCommand(null);
|
|
setVimHelpOpen(false);
|
|
}, [vimHudActive]);
|
|
|
|
const handleVimHelpOpenChange = useCallback((open: boolean) => {
|
|
setVimHelpOpen(open);
|
|
postToBridge(
|
|
{ type: `${PREFIX}set-vim-help`, open },
|
|
);
|
|
}, []);
|
|
|
|
const handleVimHudFocusLeave = useCallback(() => {
|
|
if (iframeRef.current === document.activeElement) return;
|
|
setIframeFocused(false);
|
|
}, []);
|
|
|
|
const focusVimDocument = useCallback((): boolean => {
|
|
const iframe = iframeRef.current;
|
|
if (readOnly || !vimModeEnabled || !iframe) return false;
|
|
if (document.activeElement === iframe) return false;
|
|
iframe.focus({ preventScroll: true });
|
|
if (document.activeElement !== iframe) return false;
|
|
postToBridge(
|
|
{ type: `${PREFIX}focus-vim` },
|
|
);
|
|
return true;
|
|
}, [readOnly, vimModeEnabled]);
|
|
|
|
useVimDocumentFocus({
|
|
enabled: !readOnly && vimModeEnabled,
|
|
blocked: !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker,
|
|
focusDocument: focusVimDocument,
|
|
});
|
|
|
|
// Restore filter for live multi-page sessions: only annotations made on
|
|
// the current page (or without page identity) are pushed for restoration.
|
|
// Numbering (sync-annotations) still ships the FULL list: numbers are
|
|
// parent-authoritative and global across pages, matching export.
|
|
const forCurrentPage = useCallback(
|
|
(anns: Annotation[]) =>
|
|
anns.filter((a) => !a.pageUrl || a.pageUrl === currentPageUrl),
|
|
[currentPageUrl],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
const restorable = forCurrentPage(annotations);
|
|
if (restorable.length > 0) {
|
|
hook.applyAnnotations(restorable);
|
|
}
|
|
// A fresh document: the bridge starts from an empty set and would stay
|
|
// silent when everything restores. Ask for one complete report after
|
|
// this restore batch (posted after it, so the answering pass sees it),
|
|
// which becomes this document's first delivery, empty set included.
|
|
awaitingReadyRef.current = false;
|
|
bridgeReportedRef.current = false;
|
|
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Live page navigation with a ready iframe: explicitly clear the previous
|
|
// page's marks, then re-apply the filtered set. Relying on dead anchors to
|
|
// hide pins would risk cross-page text-search false matches and waste
|
|
// reconcile budget.
|
|
const lastAppliedPageRef = useRef<string | undefined>(currentPageUrl);
|
|
useEffect(() => {
|
|
if (lastAppliedPageRef.current === currentPageUrl) return;
|
|
lastAppliedPageRef.current = currentPageUrl;
|
|
if (iframeReadyVersion === 0) return;
|
|
postToBridge({ type: `${PREFIX}clear-marks` });
|
|
const restorable = forCurrentPage(annotations);
|
|
if (restorable.length > 0) {
|
|
hook.applyAnnotations(restorable);
|
|
}
|
|
// clear-marks drops the bridge's synced numbering; re-establish it so
|
|
// restored markers keep their export-matching global numbers.
|
|
postToBridge({
|
|
type: `${PREFIX}sync-annotations`,
|
|
annotations: buildSyncNumbering(annotations),
|
|
});
|
|
// A new page is a new restore batch: report its complete set once, and
|
|
// deliver nothing computed against the previous page's report until
|
|
// that answer arrives.
|
|
bridgeReportedRef.current = false;
|
|
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
}, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Placed-marker numbering is parent-authoritative and matches the
|
|
// numbers exportAnnotations writes into the submitted feedback: the full
|
|
// list INCLUDING globals is numbered by ARRAY position (the export's
|
|
// effective order — its sort keys tie for raw-HTML annotations), and
|
|
// globals then ship no entry (no page location) — see buildSyncNumbering
|
|
// for the contract. Renumbers on delete; the bridge's own registration
|
|
// order is only a pre-sync fallback.
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
postToBridge(
|
|
{ type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
|
|
);
|
|
}, [iframeReadyVersion, annotations]);
|
|
|
|
// Tell the bridge the current input method (drag vs pinpoint). Re-posts on
|
|
// ready (fresh iframe) and whenever the user switches it in the toolstrip.
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
postToBridge(
|
|
{ type: `${PREFIX}set-input-method`, method: inputMethod },
|
|
);
|
|
}, [iframeReadyVersion, inputMethod]);
|
|
|
|
// Tell the bridge whether Annotate is armed. Same re-post pattern as
|
|
// set-input-method, so the mode survives live page changes / HMR reloads
|
|
// and bridge re-injection without ever reloading the iframe.
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
postToBridge(
|
|
{ type: `${PREFIX}set-annotate-mode`, active: annotateModeActive },
|
|
);
|
|
}, [iframeReadyVersion, annotateModeActive]);
|
|
|
|
// Parent-side Esc rung: with focus outside the iframe the bridge never
|
|
// sees the keydown. Any open composer/toolbar/picker still closes first —
|
|
// their state is read from this render's closure, so an Esc that closed
|
|
// one this same keydown is not double-consumed here.
|
|
useEffect(() => {
|
|
if (readOnly || !annotateModeActive || !onAnnotateModeExit) return;
|
|
const overlayOpen =
|
|
!!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
|
if (overlayOpen) return;
|
|
// A text field or dialog owns its own Escape.
|
|
const target = e.target as HTMLElement | null;
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
|
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
|
|
onAnnotateModeExit();
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, [
|
|
readOnly,
|
|
annotateModeActive,
|
|
onAnnotateModeExit,
|
|
hook.toolbarState,
|
|
hook.commentPopover,
|
|
hook.quickLabelPicker,
|
|
globalCommentPopover,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
const iframe = iframeRef.current;
|
|
postToBridge(
|
|
{
|
|
type: `${PREFIX}set-vim-mode`,
|
|
enabled: !readOnly && vimModeEnabled,
|
|
hudEnabled: vimHudEnabled,
|
|
mode,
|
|
},
|
|
);
|
|
if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
|
|
// The initial parent focus can land before the sandbox bridge is ready.
|
|
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
// matching the Markdown surface instead of waiting for the first key.
|
|
postToBridge(
|
|
{ type: `${PREFIX}focus-vim` },
|
|
);
|
|
}
|
|
}, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
|
|
|
|
const vimOverlayWasOpenRef = useRef(false);
|
|
useEffect(() => {
|
|
const overlayOpen = !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker;
|
|
const wasOpen = vimOverlayWasOpenRef.current;
|
|
vimOverlayWasOpenRef.current = overlayOpen;
|
|
if (
|
|
!readOnly
|
|
&& vimModeEnabled
|
|
&& wasOpen
|
|
&& !overlayOpen
|
|
&& (document.activeElement === document.body || document.activeElement === null)
|
|
) {
|
|
iframeRef.current?.focus({ preventScroll: true });
|
|
postToBridge(
|
|
{ type: `${PREFIX}focus-vim` },
|
|
);
|
|
}
|
|
}, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (iframeReadyVersion === 0) return;
|
|
function sendTheme() {
|
|
postToBridge(
|
|
{
|
|
type: `${PREFIX}theme`,
|
|
tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
|
|
isLight: isLightTheme(),
|
|
hostTheme,
|
|
},
|
|
);
|
|
}
|
|
sendTheme();
|
|
const observer = new MutationObserver(sendTheme);
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["class", "style"],
|
|
});
|
|
return () => observer.disconnect();
|
|
}, [iframeReadyVersion, hostTheme]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
removeHighlight: hook.removeHighlight,
|
|
clearAllHighlights: hook.clearAllHighlights,
|
|
// Shared/draft restores respect the live page filter too.
|
|
applySharedAnnotations: (anns: Annotation[]) =>
|
|
hook.applyAnnotations(forCurrentPage(anns)),
|
|
}));
|
|
|
|
const handleGlobalCommentSubmit = useCallback(
|
|
(text: string, images?: ImageAttachment[]) => {
|
|
if (readOnly) return;
|
|
onAddAnnotation({
|
|
id: `global-${Date.now()}`,
|
|
blockId: "",
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: AnnotationType.GLOBAL_COMMENT,
|
|
text: text.trim(),
|
|
originalText: "",
|
|
author: getIdentity(),
|
|
createdA: Date.now(),
|
|
images,
|
|
});
|
|
setGlobalCommentPopover(null);
|
|
},
|
|
[onAddAnnotation, readOnly],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (readOnly) setGlobalCommentPopover(null);
|
|
}, [readOnly]);
|
|
|
|
const hasActionButtons = !readOnly || Boolean(diffAvailable && onToggleDiff);
|
|
|
|
// Document-level controls (attachments + global comment). Shared between the
|
|
// normal layout (bar above the card) and full-viewport (floating overlay), so
|
|
// edge-to-edge HTML keeps these affordances rather than dropping them.
|
|
const actionButtons = (
|
|
<>
|
|
{diffAvailable && onToggleDiff && (
|
|
<button
|
|
onClick={onToggleDiff}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors cursor-pointer ${diffActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted"}`}
|
|
title={diffActive ? "Hide changes vs previous version" : "Show changes vs previous version"}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-9L21 3m0 0l-4.5 4.5M21 3H7.5" />
|
|
</svg>
|
|
<span>{diffActive ? "Hide changes" : "Show changes"}</span>
|
|
</button>
|
|
)}
|
|
{!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
<AttachmentsButton
|
|
images={globalAttachments}
|
|
onAdd={onAddGlobalAttachment}
|
|
onRemove={onRemoveGlobalAttachment}
|
|
variant="toolbar"
|
|
/>
|
|
)}
|
|
{!readOnly && (
|
|
<button
|
|
ref={globalCommentButtonRef}
|
|
onClick={() => {
|
|
const anchorEl = globalCommentButtonRef.current;
|
|
if (!anchorEl) return;
|
|
setGlobalCommentPopover({ anchorEl, contextText: "" });
|
|
}}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors cursor-pointer"
|
|
title="Add global comment"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
|
</svg>
|
|
<span>Comment</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`relative w-full${fullViewport ? " h-full flex flex-col" : ""}`}
|
|
style={fullViewport ? undefined : { maxWidth: maxWidth ?? undefined }}
|
|
>
|
|
{/* Action bar — above the iframe in normal mode (outside overflow:hidden). */}
|
|
{!fullViewport && hasActionButtons && (
|
|
<div data-print-hide className="flex justify-end gap-1 md:gap-2 mb-2">
|
|
{actionButtons}
|
|
</div>
|
|
)}
|
|
|
|
<article
|
|
data-print-region="article"
|
|
className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
|
|
>
|
|
{/* Armed affordance: a subtle accent ring floats over the iframe
|
|
while Annotate is armed (hosts that wire the toggle only).
|
|
Overlaid + pointer-transparent, so it never shifts layout and
|
|
never eats a click; an inset shadow on the article itself
|
|
would paint UNDER the covering iframe. */}
|
|
{!readOnly && annotateModeActive && (onAnnotateModeExit || onAnnotateModeToggle) && (
|
|
<div
|
|
aria-hidden
|
|
data-print-hide
|
|
data-annotate-armed-ring
|
|
className="pointer-events-none absolute inset-0 z-10"
|
|
style={{ boxShadow: "inset 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent)" }}
|
|
/>
|
|
)}
|
|
{/* Full-viewport mode has no card chrome, so float the same controls
|
|
over the top-right of the iframe (with a backdrop so they read over
|
|
any HTML). The selection toolbar is portaled separately. */}
|
|
{fullViewport && !hideControls && hasActionButtons && (
|
|
<div
|
|
data-print-hide
|
|
className="absolute top-3 right-3 z-10 flex items-center gap-1 md:gap-2 rounded-lg border border-border/50 bg-background/80 px-1.5 py-1 shadow-md backdrop-blur-sm"
|
|
>
|
|
{actionButtons}
|
|
</div>
|
|
)}
|
|
{/* bridgeScriptUrl path only: the bridge did not come up (no
|
|
ready within the timeout, or a stale asset's version). Floated
|
|
over the top of the iframe so it never changes the layout the
|
|
page renders in; the page itself stays visible. A host that
|
|
renders its own notice from onBridgeUnavailable passes
|
|
bridgeErrorDisplay="none" and no strip is rendered at all. */}
|
|
{bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
|
|
<div
|
|
role="alert"
|
|
data-print-hide
|
|
data-bridge-error={bridgeError.kind}
|
|
className="absolute inset-x-0 top-0 z-20 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive backdrop-blur-sm"
|
|
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
|
|
>
|
|
<span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
|
|
{/* Only the mismatch state is dismissible: the older bridge
|
|
still works there. A timeout leaves a dead surface, so
|
|
that banner stays. Inline styles on purpose: hosts that
|
|
build the guides.show viewer scan this file for utility
|
|
classes, and this banner must not grow that stylesheet. */}
|
|
{bridgeError.kind === "version-mismatch" && (
|
|
<button
|
|
type="button"
|
|
data-bridge-error-dismiss
|
|
aria-label="Dismiss"
|
|
style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
|
|
onClick={() => setBridgeErrorDismissed(true)}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Live proxied-app mode navigates a real loopback origin: no
|
|
sandbox (the user's own app needs cookies, storage, and
|
|
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
<iframe
|
|
ref={iframeRef}
|
|
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
|
|
style={{
|
|
width: "100%",
|
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
border: "none",
|
|
display: "block",
|
|
colorScheme: "auto",
|
|
outline: !readOnly && vimModeEnabled ? "none" : undefined,
|
|
}}
|
|
title={title}
|
|
onFocus={() => setIframeFocused(true)}
|
|
onBlur={(event) => {
|
|
if (
|
|
event.relatedTarget instanceof Element
|
|
&& event.relatedTarget.closest('[data-vim-key-hud]')
|
|
) {
|
|
return;
|
|
}
|
|
setIframeFocused(false);
|
|
}}
|
|
/>
|
|
</article>
|
|
</div>
|
|
|
|
{vimHudActive
|
|
&& (vimHelpOpen || (
|
|
vimHudKeyPanelEnabled
|
|
&& vimBridgePhase !== "inactive"
|
|
))
|
|
&& (iframeFocused || vimBridgePhase === "action" || vimHelpOpen)
|
|
&& createPortal(
|
|
<VimKeyHud
|
|
command={vimHudCommand}
|
|
phase={getVimHudPhase(vimBridgePhase, vimHudCommand?.actionId)}
|
|
inputMethod={inputMethod}
|
|
expanded={vimHelpOpen}
|
|
onExpandedChange={handleVimHelpOpenChange}
|
|
onHide={
|
|
onVimHudKeyPanelChange
|
|
? () => {
|
|
handleVimHelpOpenChange(false);
|
|
onVimHudKeyPanelChange(false);
|
|
}
|
|
: undefined
|
|
}
|
|
onFocusLeave={handleVimHudFocusLeave}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* Toolbar portal */}
|
|
{!readOnly && hook.toolbarState &&
|
|
createPortal(
|
|
<AnnotationToolbar
|
|
positionMode="center-above"
|
|
element={hook.toolbarState.element}
|
|
copyText={hook.toolbarState.selectionText}
|
|
// HTML/live surfaces are comment-only: no Delete, no label
|
|
// picker, no Alt+digit labels (commentOnly). Exactly ONE label
|
|
// affordance is restored: the hardcoded 👍 "Looks good". The
|
|
// wrapper filters by id as defense in depth, so no present or
|
|
// future toolbar path can emit an arbitrary label here.
|
|
commentOnly
|
|
onQuickLabel={(label) => {
|
|
if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
|
|
}}
|
|
onAnnotate={hook.handleAnnotate}
|
|
onRequestComment={hook.handleRequestComment}
|
|
onClose={hook.handleToolbarClose}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* Comment popover portal */}
|
|
{!readOnly && hook.commentPopover &&
|
|
createPortal(
|
|
<CommentPopover
|
|
anchorEl={hook.commentPopover.anchorEl}
|
|
contextText={hook.commentPopover.contextText}
|
|
initialText={hook.commentPopover.initialText}
|
|
isGlobal={false}
|
|
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
onSubmit={hook.handleCommentSubmit}
|
|
// Pinpoint clicks open this composer directly, so it carries
|
|
// the surface's one-click "Looks good" (the global composer
|
|
// does not: a document-wide thumbs-up is not a thing).
|
|
onQuickLookGood={hook.handleCommentLooksGood}
|
|
onClose={hook.handleCommentClose}
|
|
skillReferences
|
|
onAskAI={onAskAI}
|
|
askAIContext={{
|
|
kind: "selection",
|
|
label: "Selected HTML",
|
|
text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
|
|
}}
|
|
targetChips={targetChips}
|
|
onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
|
|
onHoverTargetChip={targetChips ? hook.flashDraftTarget : undefined}
|
|
refocusToken={targetChips ? hook.composerFocusToken : undefined}
|
|
captureStrayKeys={multiSelectActive}
|
|
yieldState={multiSelectActive ? composerYield : undefined}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* Global comment popover portal */}
|
|
{!readOnly && globalCommentPopover &&
|
|
createPortal(
|
|
<CommentPopover
|
|
anchorEl={globalCommentPopover.anchorEl}
|
|
contextText={globalCommentPopover.contextText}
|
|
isGlobal={true}
|
|
onSubmit={handleGlobalCommentSubmit}
|
|
onClose={() => setGlobalCommentPopover(null)}
|
|
skillReferences
|
|
onAskAI={onAskAI}
|
|
askAIContext={{ kind: "general", label: "Document" }}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
);
|
|
|
|
HtmlViewer.displayName = "HtmlViewer";
|