@plannotator/ui 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
- import { AnnotationType, type Annotation, type EditorMode, type ImageAttachment } from "../../types";
2
+ import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
3
3
  import type { QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
@@ -21,48 +21,296 @@ function nextHtmlAnnId(): string {
21
21
  interface BridgeSelectionMessage {
22
22
  type: `${typeof PREFIX}selection`;
23
23
  text: string;
24
- rect: { top: number; left: number; width: number; height: number };
24
+ rect: BridgeRect;
25
+ modeOverride?: EditorMode;
26
+ /** Serialized element anchor (pinpoint clicks) — validated, size-capped. */
27
+ anchor?: HtmlElementAnchor;
28
+ /** True when the selection came from a pinpoint click on an element. */
29
+ pinpoint?: boolean;
30
+ /** Bridge-assigned key for the primary target (multi-select bookkeeping). */
31
+ targetKey?: string;
32
+ /** Semantic label from the pinpoint hover cascade (chips + export). */
33
+ targetLabel?: string;
25
34
  }
26
35
 
27
- interface BridgeMarkClickMessage {
28
- type: `${typeof PREFIX}mark-click`;
29
- id: string;
36
+ /** One draft target in an in-flight multi-select comment. Index 0 is primary. */
37
+ export interface HtmlDraftTarget {
38
+ key: string;
39
+ label?: string;
40
+ text: string;
41
+ anchor: HtmlElementAnchor | null;
30
42
  }
31
43
 
32
- interface BridgeResizeMessage {
33
- type: `${typeof PREFIX}resize`;
34
- height: number;
44
+ interface BridgeMultiTargetAddedMessage {
45
+ type: `${typeof PREFIX}multi-target-added`;
46
+ key: string;
47
+ label?: string;
48
+ text: string;
49
+ anchor?: HtmlElementAnchor;
35
50
  }
36
51
 
37
- type BridgeMessage = BridgeSelectionMessage | BridgeMarkClickMessage | BridgeResizeMessage | { type: string };
52
+ interface BridgeRect {
53
+ top: number;
54
+ left: number;
55
+ width: number;
56
+ height: number;
57
+ }
38
58
 
59
+ type BridgeMessage =
60
+ | BridgeSelectionMessage
61
+ | BridgeMultiTargetAddedMessage
62
+ | { type: `${typeof PREFIX}multi-target-removed`; key: string }
63
+ | { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
64
+ | { type: `${typeof PREFIX}selection-clear` }
65
+ | { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
66
+ | { type: `${typeof PREFIX}keytype`; key: string }
67
+ | { type: `${typeof PREFIX}mark-click`; id: string }
68
+ | { type: `${typeof PREFIX}resize`; height: number };
69
+
70
+ /** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
39
71
  export interface UseHtmlAnnotationOptions {
40
72
  iframeRef: RefObject<HTMLIFrameElement | null>;
73
+ /** Whether selection bridge messages may open composers or create annotations. */
74
+ enabled?: boolean;
41
75
  annotations: Annotation[];
42
76
  onAddAnnotation?: (ann: Annotation) => void;
43
77
  onSelectAnnotation?: (id: string | null) => void;
44
78
  selectedAnnotationId: string | null;
45
79
  mode: EditorMode;
46
80
  onResize?: (height: number) => void;
81
+ /** Validated pointer positions relayed from inside the iframe while a
82
+ * pinpoint draft is open (iframe-local viewport coordinates), with the
83
+ * Shift state observed by the iframe (the parent cannot see modifiers
84
+ * held while the pointer lives in the sandbox). Drives the composer-yield
85
+ * fade in the host component. */
86
+ onBridgePointer?: (x: number, y: number, shift: boolean) => void;
47
87
  }
48
88
 
49
89
  function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unknown>) {
50
90
  iframe?.contentWindow?.postMessage(msg, "*");
51
91
  }
52
92
 
93
+ function parseEditorMode(value: unknown): EditorMode | undefined {
94
+ return value === "selection"
95
+ || value === "comment"
96
+ || value === "redline"
97
+ || value === "quickLabel"
98
+ ? value
99
+ : undefined;
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === "object" && value !== null;
104
+ }
105
+
106
+ // Size caps for the anchor DTO — the bridge script runs inside a sandboxed
107
+ // iframe rendering arbitrary HTML, so everything it posts is validated and
108
+ // bounded before it can reach React state or the annotation model.
109
+ const MAX_ANCHOR_SELECTOR_LENGTH = 1024;
110
+ const MAX_ANCHOR_TAG_LENGTH = 64;
111
+ const MAX_ANCHOR_TEXT_LENGTH = 400;
112
+ // Multi-select caps: the additional-target array is bounded at the trust
113
+ // boundary (a hostile page cannot grow a draft past this), and the bridge's
114
+ // short target keys / 40-char hover labels get generous-but-hard ceilings.
115
+ export const MAX_ADDITIONAL_TARGETS = 16;
116
+ const MAX_TARGET_KEY_LENGTH = 64;
117
+ const MAX_TARGET_LABEL_LENGTH = 64;
118
+ // Selection text is page-controlled too (a pinpoint click posts the element's
119
+ // entire textContent), so it gets the same treatment: truncated here — not
120
+ // rejected, a legitimate huge selection still annotates — before it can reach
121
+ // React state, drafts, exported feedback, or a share URL. Mirrors
122
+ // MAX_SELECTION_TEXT in bridge-script.ts; this side is the authoritative one.
123
+ export const MAX_SELECTION_TEXT_LENGTH = 10000;
124
+
125
+ /** Truncate to the cap without ever splitting a UTF-16 surrogate pair (a
126
+ * lone high surrogate becomes U+FFFD once UTF-8-encoded downstream). */
127
+ export function capSelectionText(text: string): string {
128
+ if (text.length <= MAX_SELECTION_TEXT_LENGTH) return text;
129
+ let cut = MAX_SELECTION_TEXT_LENGTH;
130
+ const last = text.charCodeAt(cut - 1);
131
+ if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
132
+ return text.slice(0, cut);
133
+ }
134
+
135
+ /**
136
+ * Validate a bridge-posted normalized marker point. Fail-closed but additive:
137
+ * a malformed point is DROPPED (the marker falls back to the target-rect
138
+ * default) without rejecting the anchor it rides on; finite values are
139
+ * clamped into the normalized 0..1 range.
140
+ */
141
+ function parseAnchorPoint(value: unknown): { x: number; y: number } | undefined {
142
+ if (!isRecord(value)) return undefined;
143
+ const { x, y } = value;
144
+ if (
145
+ typeof x !== "number" || !Number.isFinite(x)
146
+ || typeof y !== "number" || !Number.isFinite(y)
147
+ ) {
148
+ return undefined;
149
+ }
150
+ return {
151
+ x: Math.min(1, Math.max(0, x)),
152
+ y: Math.min(1, Math.max(0, y)),
153
+ };
154
+ }
155
+
156
+ /** Validate a bridge-posted element anchor. Exported for protocol tests. */
157
+ export function parseHtmlElementAnchor(value: unknown): HtmlElementAnchor | null {
158
+ if (!isRecord(value)) return null;
159
+ const { selector, tagName, text } = value;
160
+ if (
161
+ typeof selector !== "string"
162
+ || selector.length === 0
163
+ || selector.length > MAX_ANCHOR_SELECTOR_LENGTH
164
+ || typeof tagName !== "string"
165
+ || tagName.length === 0
166
+ || tagName.length > MAX_ANCHOR_TAG_LENGTH
167
+ ) {
168
+ return null;
169
+ }
170
+ const point = parseAnchorPoint(value.point);
171
+ if (text === undefined) return { selector, tagName, ...(point ? { point } : {}) };
172
+ if (typeof text !== "string" || text.length > MAX_ANCHOR_TEXT_LENGTH) return null;
173
+ return { selector, tagName, text, ...(point ? { point } : {}) };
174
+ }
175
+
176
+ function parseTargetKey(value: unknown): string | null {
177
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_TARGET_KEY_LENGTH
178
+ ? value
179
+ : null;
180
+ }
181
+
182
+ function parseTargetLabel(value: unknown): string | undefined {
183
+ if (typeof value !== "string") return undefined;
184
+ // Labels derive from page-controlled attributes (aria-label etc.), so a
185
+ // hostile page can embed newlines that would become real markdown structure
186
+ // in the exported feedback — collapse ALL whitespace at the trust boundary.
187
+ const collapsed = value.replace(/\s+/g, " ").trim();
188
+ if (!collapsed) return undefined;
189
+ return collapsed.length > MAX_TARGET_LABEL_LENGTH
190
+ ? collapsed.slice(0, MAX_TARGET_LABEL_LENGTH)
191
+ : collapsed;
192
+ }
193
+
194
+ function parseBridgeRect(value: unknown): BridgeRect | null {
195
+ if (!isRecord(value)) return null;
196
+ const { top, left, width, height } = value;
197
+ return typeof top === "number" && Number.isFinite(top)
198
+ && typeof left === "number" && Number.isFinite(left)
199
+ && typeof width === "number" && Number.isFinite(width)
200
+ && typeof height === "number" && Number.isFinite(height)
201
+ ? { top, left, width, height }
202
+ : null;
203
+ }
204
+
205
+ /** Validate any bridge message. Exported for protocol tests. */
206
+ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
207
+ if (!isRecord(value) || typeof value.type !== "string") return null;
208
+
209
+ switch (value.type) {
210
+ case `${PREFIX}selection`: {
211
+ const rect = parseBridgeRect(value.rect);
212
+ if (typeof value.text !== "string" || !rect) return null;
213
+ return {
214
+ type: value.type,
215
+ text: capSelectionText(value.text),
216
+ rect,
217
+ modeOverride: parseEditorMode(value.modeOverride),
218
+ anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
219
+ pinpoint: value.pinpoint === true,
220
+ targetKey: parseTargetKey(value.targetKey) ?? undefined,
221
+ targetLabel: parseTargetLabel(value.targetLabel),
222
+ };
223
+ }
224
+ case `${PREFIX}multi-target-added`: {
225
+ const key = parseTargetKey(value.key);
226
+ if (!key || typeof value.text !== "string") return null;
227
+ return {
228
+ type: value.type,
229
+ key,
230
+ label: parseTargetLabel(value.label),
231
+ text: capSelectionText(value.text),
232
+ anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
233
+ };
234
+ }
235
+ case `${PREFIX}multi-target-removed`: {
236
+ const key = parseTargetKey(value.key);
237
+ return key ? { type: value.type, key } : null;
238
+ }
239
+ case `${PREFIX}pointer`:
240
+ return typeof value.x === "number" && Number.isFinite(value.x)
241
+ && typeof value.y === "number" && Number.isFinite(value.y)
242
+ ? { type: value.type, x: value.x, y: value.y, shift: value.shift === true }
243
+ : null;
244
+ case `${PREFIX}selection-clear`:
245
+ return { type: value.type };
246
+ case `${PREFIX}selection-rect`: {
247
+ const rect = parseBridgeRect(value.rect);
248
+ return rect ? { type: value.type, rect } : null;
249
+ }
250
+ case `${PREFIX}keytype`:
251
+ return typeof value.key === "string"
252
+ ? { type: value.type, key: value.key }
253
+ : null;
254
+ case `${PREFIX}mark-click`:
255
+ // The id is page-controlled like every other bridge string: cap it like
256
+ // the bridge's own sync validation does (256) so a hostile page cannot
257
+ // ship an unbounded string into parent state via a forged mark-click.
258
+ return typeof value.id === "string" && value.id.length <= 256
259
+ ? { type: value.type, id: value.id }
260
+ : null;
261
+ case `${PREFIX}resize`:
262
+ return typeof value.height === "number" && Number.isFinite(value.height)
263
+ ? { type: value.type, height: value.height }
264
+ : null;
265
+ default:
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Adapt source-validated iframe messages to the existing annotation UI.
272
+ *
273
+ * Malformed bridge payloads are ignored; annotations are posted back through
274
+ * the iframe protocol and reported through the supplied callbacks.
275
+ */
53
276
  export function useHtmlAnnotation({
54
277
  iframeRef,
278
+ enabled = true,
55
279
  onAddAnnotation,
56
280
  onSelectAnnotation,
57
281
  selectedAnnotationId,
58
282
  mode,
59
283
  onResize,
60
- }: UseHtmlAnnotationOptions): Omit<UseAnnotationHighlighterReturn, "highlighterRef"> {
284
+ onBridgePointer,
285
+ }: UseHtmlAnnotationOptions): Omit<
286
+ UseAnnotationHighlighterReturn,
287
+ "highlighterRef" | "highlightRange" | "highlightMathElement"
288
+ > & {
289
+ /** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
290
+ draftTargets: HtmlDraftTarget[];
291
+ /** Remove one draft target (chip X). Removing the last cancels the draft. */
292
+ removeDraftTarget: (key: string) => void;
293
+ /** Flash a draft target's pinned outline in the page (chip hover). */
294
+ flashDraftTarget: (key: string) => void;
295
+ /** Bumped after every target add/remove so the composer can refocus its textarea. */
296
+ composerFocusToken: number;
297
+ } {
61
298
  const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
62
299
  const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
63
300
  const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
301
+ const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
302
+ const [composerFocusToken, setComposerFocusToken] = useState(0);
64
303
 
65
304
  const pendingTextRef = useRef<string>("");
305
+ // Element anchor for the pending pinpoint selection — committed onto the
306
+ // annotation so restoration can resolve the exact element again.
307
+ const pendingAnchorRef = useRef<HtmlElementAnchor | null>(null);
308
+ const draftTargetsRef = useRef<HtmlDraftTarget[]>(draftTargets);
309
+ draftTargetsRef.current = draftTargets;
310
+ const onBridgePointerRef = useRef(onBridgePointer);
311
+ onBridgePointerRef.current = onBridgePointer;
312
+ const enabledRef = useRef(enabled);
313
+ enabledRef.current = enabled;
66
314
  const modeRef = useRef(mode);
67
315
  modeRef.current = mode;
68
316
  // Mirror toolbar visibility into a ref so the (stable) message handler can gate
@@ -83,6 +331,47 @@ export function useHtmlAnnotation({
83
331
 
84
332
  const anchorRef = useRef<HTMLDivElement | null>(null);
85
333
 
334
+ /**
335
+ * Remove one draft target. Removing the primary promotes the next remaining
336
+ * target (the composer's context text and pending anchor follow it);
337
+ * removing the final target cancels the draft. The bridge performs the same
338
+ * deterministic update on its side, so `remove-target` is ALWAYS posted:
339
+ * for chip removals it drives the bridge, and for bridge-echoed removals it
340
+ * is an idempotent no-op — which also resyncs the two sides if a hostile
341
+ * page forged the removal message the bridge never actually performed.
342
+ */
343
+ const applyTargetRemoval = useCallback(
344
+ (key: string) => {
345
+ const targets = draftTargetsRef.current;
346
+ const index = targets.findIndex((t) => t.key === key);
347
+ if (index < 0) return;
348
+ postToIframe(iframeRef.current, { type: `${PREFIX}remove-target`, key });
349
+ const remaining = targets.filter((t) => t.key !== key);
350
+ if (remaining.length === 0) {
351
+ // Final target removed — the draft is cancelled (bridge side already
352
+ // tore down its pinned state via toggle-off or the remove-target post).
353
+ setDraftTargets([]);
354
+ setCommentPopover(null);
355
+ pendingTextRef.current = "";
356
+ pendingAnchorRef.current = null;
357
+ return;
358
+ }
359
+ if (index === 0) {
360
+ // Primary removed — promote the next target: the comment's quoted
361
+ // text and restoration anchor now belong to it.
362
+ const next = remaining[0]!;
363
+ pendingTextRef.current = next.text;
364
+ pendingAnchorRef.current = next.anchor;
365
+ setCommentPopover((prev) =>
366
+ prev ? { ...prev, contextText: next.text, selectedText: next.text } : prev,
367
+ );
368
+ }
369
+ setDraftTargets(remaining);
370
+ setComposerFocusToken((t) => t + 1);
371
+ },
372
+ [iframeRef],
373
+ );
374
+
86
375
  const getOrCreateAnchor = useCallback(() => {
87
376
  if (!anchorRef.current) {
88
377
  const div = document.createElement("div");
@@ -115,18 +404,25 @@ export function useHtmlAnnotation({
115
404
  );
116
405
 
117
406
  useEffect(() => {
118
- function handler(e: MessageEvent<BridgeMessage>) {
119
- if (!e.data || typeof e.data.type !== "string" || !e.data.type.startsWith(PREFIX)) return;
407
+ function handler(e: MessageEvent<unknown>) {
408
+ if (e.source !== iframeRef.current?.contentWindow) return;
409
+ const message = parseBridgeMessage(e.data);
410
+ if (!message) return;
411
+
412
+ const type = message.type;
120
413
 
121
- const type = e.data.type;
414
+ if (!enabledRef.current && type !== `${PREFIX}mark-click` && type !== `${PREFIX}resize`) {
415
+ return;
416
+ }
122
417
 
123
418
  if (type === `${PREFIX}selection`) {
124
- const msg = e.data as BridgeSelectionMessage;
125
- pendingTextRef.current = msg.text;
126
- const anchor = positionAnchor(msg.rect);
419
+ pendingTextRef.current = message.text;
420
+ pendingAnchorRef.current = message.anchor ?? null;
421
+ setDraftTargets([]); // a new selection always starts a fresh draft
422
+ const anchor = positionAnchor(message.rect);
127
423
  if (!anchor) return;
128
424
 
129
- const currentMode = modeRef.current;
425
+ const currentMode = message.modeOverride ?? modeRef.current;
130
426
 
131
427
  if (currentMode === "redline") {
132
428
  const id = nextHtmlAnnId();
@@ -137,20 +433,46 @@ export function useHtmlAnnotation({
137
433
  startOffset: 0,
138
434
  endOffset: 0,
139
435
  type: AnnotationType.DELETION,
140
- originalText: msg.text,
436
+ originalText: message.text,
141
437
  author: getIdentity(),
142
438
  createdA: Date.now(),
439
+ htmlAnchor: message.anchor,
143
440
  });
144
441
  pendingTextRef.current = "";
145
- } else if (currentMode === "comment") {
442
+ pendingAnchorRef.current = null;
443
+ } else if (
444
+ currentMode === "comment"
445
+ // Pinpoint click-to-pin: the click already chose the target, so skip
446
+ // the intermediate toolbar and go straight to the comment composer.
447
+ || (message.pinpoint && currentMode === "selection")
448
+ ) {
146
449
  // Release iframe focus so the popover's textarea autofocus lands in the
147
450
  // parent (otherwise the iframe keeps focus and swallows further keys).
148
451
  iframeRef.current?.blur();
149
452
  setCommentPopover({
150
453
  anchorEl: anchor,
151
- contextText: msg.text,
152
- selectedText: msg.text,
454
+ contextText: message.text,
455
+ selectedText: message.text,
153
456
  });
457
+ // Pinpoint drafts arm shift-click multi-select: the clicked element
458
+ // becomes the primary target of the (single) draft comment. The
459
+ // bridge only accepts shift-toggles once THIS explicit arm arrives,
460
+ // so drafts the composer does not mirror (quickLabel, redline) can
461
+ // never accumulate pins the saved annotation would not carry.
462
+ if (message.pinpoint && message.targetKey) {
463
+ setDraftTargets([
464
+ {
465
+ key: message.targetKey,
466
+ label: message.targetLabel,
467
+ text: message.text,
468
+ anchor: message.anchor ?? null,
469
+ },
470
+ ]);
471
+ postToIframe(iframeRef.current, {
472
+ type: `${PREFIX}arm-multi-select`,
473
+ key: message.targetKey,
474
+ });
475
+ }
154
476
  } else if (currentMode === "quickLabel") {
155
477
  setQuickLabelPicker({
156
478
  anchorEl: anchor,
@@ -160,11 +482,43 @@ export function useHtmlAnnotation({
160
482
  setToolbarState({
161
483
  element: anchor,
162
484
  source: null,
163
- selectionText: msg.text,
485
+ selectionText: message.text,
164
486
  });
165
487
  }
166
488
  }
167
489
 
490
+ if (type === `${PREFIX}multi-target-added`) {
491
+ // Only meaningful while a pinpoint draft composer is open. The array
492
+ // cap is enforced HERE, at the trust boundary — a hostile page cannot
493
+ // grow the draft past MAX_ADDITIONAL_TARGETS extra targets.
494
+ const targets = draftTargetsRef.current;
495
+ if (
496
+ commentPopoverRef.current
497
+ && targets.length > 0
498
+ && targets.length < 1 + MAX_ADDITIONAL_TARGETS
499
+ && !targets.some((t) => t.key === message.key)
500
+ ) {
501
+ setDraftTargets([
502
+ ...targets,
503
+ {
504
+ key: message.key,
505
+ label: message.label,
506
+ text: message.text,
507
+ anchor: message.anchor ?? null,
508
+ },
509
+ ]);
510
+ setComposerFocusToken((t) => t + 1);
511
+ }
512
+ }
513
+
514
+ if (type === `${PREFIX}multi-target-removed`) {
515
+ applyTargetRemoval(message.key);
516
+ }
517
+
518
+ if (type === `${PREFIX}pointer`) {
519
+ onBridgePointerRef.current?.(message.x, message.y, message.shift);
520
+ }
521
+
168
522
  if (type === `${PREFIX}selection-clear`) {
169
523
  setToolbarState(null);
170
524
  // Keep the captured text alive while a comment/quick-label is open: the user
@@ -172,6 +526,7 @@ export function useHtmlAnnotation({
172
526
  // not drop the annotation on submit. It's overwritten on the next selection.
173
527
  if (!commentPopoverRef.current && !quickLabelPickerRef.current) {
174
528
  pendingTextRef.current = "";
529
+ pendingAnchorRef.current = null;
175
530
  }
176
531
  }
177
532
 
@@ -182,7 +537,7 @@ export function useHtmlAnnotation({
182
537
  const iframe = iframeRef.current;
183
538
  const anchor = anchorRef.current;
184
539
  if (!iframe || !anchor) return;
185
- const r = (e.data as unknown as { rect: { top: number; left: number; width: number; height: number } }).rect;
540
+ const r = message.rect;
186
541
  const iframeRect = iframe.getBoundingClientRect();
187
542
  anchor.style.top = `${iframeRect.top + r.top}px`;
188
543
  anchor.style.left = `${iframeRect.left + r.left + r.width / 2}px`;
@@ -194,7 +549,7 @@ export function useHtmlAnnotation({
194
549
  // markdown path, where AnnotationToolbar owns this keydown). Open a comment
195
550
  // pre-filled with the typed char.
196
551
  if (!toolbarStateRef.current) return;
197
- const key = (e.data as { key?: string }).key;
552
+ const key = message.key;
198
553
  const text = pendingTextRef.current;
199
554
  if (!key || !text) return;
200
555
  const anchor = anchorRef.current ?? getOrCreateAnchor();
@@ -206,13 +561,11 @@ export function useHtmlAnnotation({
206
561
  }
207
562
 
208
563
  if (type === `${PREFIX}mark-click`) {
209
- const msg = e.data as BridgeMarkClickMessage;
210
- onSelectRef.current?.(msg.id);
564
+ onSelectRef.current?.(message.id);
211
565
  }
212
566
 
213
567
  if (type === `${PREFIX}resize`) {
214
- const msg = e.data as BridgeResizeMessage;
215
- onResize?.(msg.height);
568
+ onResize?.(message.height);
216
569
  }
217
570
  }
218
571
 
@@ -224,7 +577,20 @@ export function useHtmlAnnotation({
224
577
  anchorRef.current = null;
225
578
  }
226
579
  };
227
- }, [iframeRef, positionAnchor, onResize, getOrCreateAnchor]);
580
+ }, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
581
+
582
+ useEffect(() => {
583
+ if (enabled) return;
584
+ setToolbarState(null);
585
+ setCommentPopover(null);
586
+ setQuickLabelPicker(null);
587
+ setDraftTargets([]);
588
+ pendingTextRef.current = "";
589
+ pendingAnchorRef.current = null;
590
+ anchorRef.current?.remove();
591
+ anchorRef.current = null;
592
+ postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
593
+ }, [enabled, iframeRef]);
228
594
 
229
595
  useEffect(() => {
230
596
  if (selectedAnnotationId) {
@@ -242,6 +608,7 @@ export function useHtmlAnnotation({
242
608
 
243
609
  const handleAnnotate = useCallback(
244
610
  (type: AnnotationType) => {
611
+ if (!enabledRef.current) return;
245
612
  const text = pendingTextRef.current;
246
613
  if (!text || type !== AnnotationType.DELETION) return;
247
614
 
@@ -256,16 +623,19 @@ export function useHtmlAnnotation({
256
623
  originalText: text,
257
624
  author: getIdentity(),
258
625
  createdA: Date.now(),
626
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
259
627
  });
260
628
 
261
629
  setToolbarState(null);
262
630
  pendingTextRef.current = "";
631
+ pendingAnchorRef.current = null;
263
632
  },
264
633
  [iframeRef],
265
634
  );
266
635
 
267
636
  const handleRequestComment = useCallback(
268
637
  (initialChar?: string) => {
638
+ if (!enabledRef.current) return;
269
639
  const text = pendingTextRef.current;
270
640
  if (!text) return;
271
641
  const anchor = anchorRef.current ?? getOrCreateAnchor();
@@ -277,11 +647,23 @@ export function useHtmlAnnotation({
277
647
 
278
648
  const handleCommentSubmit = useCallback(
279
649
  (comment: string, images?: ImageAttachment[]) => {
650
+ if (!enabledRef.current) return;
280
651
  // Prefer the text captured when the popover opened — it can't be clobbered by
281
652
  // a later selection change or clear while the user is composing the comment.
282
653
  const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
283
654
  if (!text) return;
284
655
 
656
+ // Multi-select: everything past the primary rides on the SAME comment.
657
+ const targets = draftTargetsRef.current;
658
+ const additionalTargets: HtmlAnnotationTarget[] | undefined =
659
+ targets.length > 1
660
+ ? targets.slice(1, 1 + MAX_ADDITIONAL_TARGETS).map((t) => ({
661
+ label: t.label,
662
+ text: t.text,
663
+ anchor: t.anchor ?? undefined,
664
+ }))
665
+ : undefined;
666
+
285
667
  const id = nextHtmlAnnId();
286
668
  postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
287
669
  onAddRef.current?.({
@@ -295,26 +677,51 @@ export function useHtmlAnnotation({
295
677
  author: getIdentity(),
296
678
  createdA: Date.now(),
297
679
  images,
680
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
681
+ htmlAdditionalTargets: additionalTargets,
298
682
  });
299
683
 
300
684
  setCommentPopover(null);
685
+ setDraftTargets([]);
301
686
  pendingTextRef.current = "";
687
+ pendingAnchorRef.current = null;
302
688
  },
303
689
  [iframeRef],
304
690
  );
305
691
 
306
692
  const handleCommentClose = useCallback(() => {
693
+ postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
307
694
  setCommentPopover(null);
695
+ setDraftTargets([]);
308
696
  pendingTextRef.current = "";
309
- }, []);
697
+ pendingAnchorRef.current = null;
698
+ }, [iframeRef]);
699
+
700
+ const removeDraftTarget = useCallback(
701
+ (key: string) => {
702
+ if (!enabledRef.current) return;
703
+ applyTargetRemoval(key);
704
+ },
705
+ [applyTargetRemoval],
706
+ );
707
+
708
+ const flashDraftTarget = useCallback(
709
+ (key: string) => {
710
+ postToIframe(iframeRef.current, { type: `${PREFIX}flash-target`, key });
711
+ },
712
+ [iframeRef],
713
+ );
310
714
 
311
715
  const handleToolbarClose = useCallback(() => {
716
+ postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
312
717
  setToolbarState(null);
313
718
  pendingTextRef.current = "";
314
- }, []);
719
+ pendingAnchorRef.current = null;
720
+ }, [iframeRef]);
315
721
 
316
722
  const applyQuickLabel = useCallback(
317
723
  (label: QuickLabel, clearState: () => void) => {
724
+ if (!enabledRef.current) return;
318
725
  const text = pendingTextRef.current;
319
726
  if (!text) return;
320
727
  const id = nextHtmlAnnId();
@@ -331,9 +738,11 @@ export function useHtmlAnnotation({
331
738
  quickLabelTip: label.tip,
332
739
  author: getIdentity(),
333
740
  createdA: Date.now(),
741
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
334
742
  });
335
743
  clearState();
336
744
  pendingTextRef.current = "";
745
+ pendingAnchorRef.current = null;
337
746
  },
338
747
  [iframeRef],
339
748
  );
@@ -349,9 +758,11 @@ export function useHtmlAnnotation({
349
758
  );
350
759
 
351
760
  const handleQuickLabelPickerDismiss = useCallback(() => {
761
+ postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
352
762
  setQuickLabelPicker(null);
353
763
  pendingTextRef.current = "";
354
- }, []);
764
+ pendingAnchorRef.current = null;
765
+ }, [iframeRef]);
355
766
 
356
767
  const removeHighlight = useCallback(
357
768
  (id: string) => {
@@ -369,11 +780,21 @@ export function useHtmlAnnotation({
369
780
  for (const ann of anns) {
370
781
  if (ann.type === AnnotationType.GLOBAL_COMMENT) continue;
371
782
  const annType = ann.type === AnnotationType.DELETION ? "deletion" : "comment";
783
+ // Multi-target annotations restore every additional target as a pin
784
+ // under the same id (same badge number). Anchor-only and capped.
785
+ const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
786
+ .map((t) => t.anchor)
787
+ .filter((a): a is HtmlElementAnchor => !!a)
788
+ .slice(0, MAX_ADDITIONAL_TARGETS);
372
789
  postToIframe(iframeRef.current, {
373
790
  type: `${PREFIX}find-and-mark`,
374
791
  id: ann.id,
375
792
  originalText: ann.originalText,
376
793
  annotationType: annType,
794
+ // Anchor-first restore: the bridge resolves the serialized element
795
+ // and scopes the text search to it, falling back to document-wide.
796
+ anchor: ann.htmlAnchor,
797
+ additionalAnchors: additionalAnchors.length ? additionalAnchors : undefined,
377
798
  });
378
799
  }
379
800
  },
@@ -395,5 +816,9 @@ export function useHtmlAnnotation({
395
816
  removeHighlight,
396
817
  clearAllHighlights,
397
818
  applyAnnotations,
819
+ draftTargets,
820
+ removeDraftTarget,
821
+ flashDraftTarget,
822
+ composerFocusToken,
398
823
  };
399
824
  }