@plannotator/ui 0.52.0 → 0.53.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 (67) hide show
  1. package/HANDOFF.md +317 -29
  2. package/README.md +35 -3
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +26 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +214 -55
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +253 -104
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -3,6 +3,7 @@ import {
3
3
  useCallback,
4
4
  useEffect,
5
5
  useImperativeHandle,
6
+ useLayoutEffect,
6
7
  useMemo,
7
8
  useRef,
8
9
  useState,
@@ -14,7 +15,7 @@ import {
14
15
  isVimSelectionActionId,
15
16
  type VimSelectionHudContext,
16
17
  } from "../../shortcuts";
17
- import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
18
+ import type { Annotation, EditorMode, HostDraft, ImageAttachment, InputMethod } from "../../types";
18
19
  import { AnnotationType } from "../../types";
19
20
  import { copyTextPreservingFocus } from "../../utils/clipboard";
20
21
  import { elementIdentityForAskAI } from "../../utils/parser";
@@ -181,6 +182,9 @@ function parseVimBridgeCopy(value: unknown): string | null {
181
182
  }
182
183
 
183
184
  /** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
185
+ /** The fewest milliseconds between two honored `step` messages from the page. */
186
+ export const STEP_MESSAGE_MIN_INTERVAL_MS = 150;
187
+
184
188
  export interface HtmlViewerProps {
185
189
  rawHtml: string;
186
190
  /** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
@@ -228,6 +232,13 @@ export interface HtmlViewerProps {
228
232
  /** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the
229
233
  * host's floating tools over the page. The host owns that state. */
230
234
  onToolsToggle?: () => void;
235
+ /** A plain `]` (1) or `[` (-1) pressed inside the page while the surface is
236
+ * armed (Annotate) and the page's focus is not in a text field: the host's
237
+ * next / previous file (the Inbox steps through a thread's attachments).
238
+ * The bridge forwards nothing in Interact, and the page still gets the
239
+ * key. Honored only while the frame holds focus, and at most once per
240
+ * STEP_MESSAGE_MIN_INTERVAL_MS. Absent: the message is ignored, as before. */
241
+ onStep?: (dir: 1 | -1) => void;
231
242
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
232
243
  vimModeEnabled?: boolean;
233
244
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -325,6 +336,27 @@ export interface HtmlViewerProps {
325
336
  * warning). Meaningless on the inline path, which never shows a strip.
326
337
  */
327
338
  bridgeErrorDisplay?: "banner" | "none";
339
+ /**
340
+ * Opt-in host capability: the composer handed to the host (see
341
+ * `HostDraft`). A text selection reports `intent: 'selection'` and a pin
342
+ * `intent: 'compose'` with the element's label, instead of opening the
343
+ * toolbar or the composer; the selection clearing reports null. With it the
344
+ * handle's `stepPin` moves a pinned draft to the element around it or back
345
+ * in. The host saves the comment and draws it through `annotations` /
346
+ * `applySharedAnnotations`. Ignored when `readOnly`. Absent → unchanged.
347
+ */
348
+ onHostDraft?: (draft: HostDraft | null) => void;
349
+ /**
350
+ * Opt-in host capability for a native shell whose web view decides every
351
+ * navigation itself (the Inbox surface in the iPhone app). A link the
352
+ * person taps in Interact opens as a new window (`window.open`, the sandbox
353
+ * gains `allow-popups`) instead of being reported through the frame
354
+ * protocol (`onOpenLink` does not fire for it); the host's web view hands
355
+ * that window to its own delegate, which decides it. A scripted click opens
356
+ * nothing. In-page `#` links still scroll here, and an armed pinpoint tap
357
+ * still pins. Absent → unchanged.
358
+ */
359
+ hostNavigates?: boolean;
328
360
  }
329
361
 
330
362
  /**
@@ -351,6 +383,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
351
383
  onAnnotateModeExit,
352
384
  onAnnotateModeToggle,
353
385
  onToolsToggle,
386
+ onStep,
354
387
  vimModeEnabled = false,
355
388
  vimHudEnabled = false,
356
389
  vimHudKeyPanelEnabled = true,
@@ -377,6 +410,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
377
410
  bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
378
411
  onBridgeUnavailable,
379
412
  bridgeErrorDisplay = "banner",
413
+ onHostDraft,
414
+ hostNavigates = false,
380
415
  },
381
416
  ref,
382
417
  ) => {
@@ -411,6 +446,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
411
446
  onAnnotateModeToggleRef.current = onAnnotateModeToggle;
412
447
  const onToolsToggleRef = useRef(onToolsToggle);
413
448
  onToolsToggleRef.current = onToolsToggle;
449
+ const onStepRef = useRef(onStep);
450
+ onStepRef.current = onStep;
451
+ // The step message is honored only while the host has the surface armed.
452
+ const annotateModeActiveRef = useRef(annotateModeActive);
453
+ annotateModeActiveRef.current = annotateModeActive;
454
+ const lastStepAtRef = useRef(0);
414
455
 
415
456
  /** Single choke point for direct-to-bridge posts: live sessions get the
416
457
  * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
@@ -613,6 +654,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
613
654
  onUnanchoredChange: handleBridgeUnanchored,
614
655
  maxAdditionalTargets,
615
656
  scrollBehavior,
657
+ onHostDraft: readOnly ? undefined : onHostDraft,
616
658
  });
617
659
  createdIdsRef.current = hook.createdAnnotationIds;
618
660
 
@@ -682,7 +724,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
682
724
  [hook.draftTargets],
683
725
  );
684
726
 
685
- useEffect(() => {
727
+ // Registered in the commit that inserts the iframe (a layout effect), so
728
+ // the bridge's `ready` can never arrive before anyone listens: a passive
729
+ // effect can run after a fast srcdoc frame has already posted it, and a
730
+ // lost ready leaves the bridge unconfigured for good (seen on a Linux
731
+ // WebKit runner, the Inbox surface's proof).
732
+ useLayoutEffect(() => {
686
733
  function handler(e: MessageEvent<unknown>) {
687
734
  if (e.source !== iframeRef.current?.contentWindow) return;
688
735
  // Live sessions verify origin + token before reading anything.
@@ -748,6 +795,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
748
795
  onToolsToggleRef.current?.();
749
796
  return;
750
797
  }
798
+ if (isRecord(e.data) && e.data.type === `${PREFIX}step`) {
799
+ // The page itself can post this message, so it is honored only for
800
+ // a key the person pressed: the frame holds focus, the surface is
801
+ // armed, and at most one step per STEP_MESSAGE_MIN_INTERVAL_MS (a
802
+ // page cannot push itself out of view by posting in a loop).
803
+ const dir = e.data.dir;
804
+ if (!annotateModeActiveRef.current || (dir !== 1 && dir !== -1)) return;
805
+ if (document.activeElement !== iframeRef.current) return;
806
+ const now = Date.now();
807
+ if (now - lastStepAtRef.current < STEP_MESSAGE_MIN_INTERVAL_MS) return;
808
+ lastStepAtRef.current = now;
809
+ onStepRef.current?.(dir);
810
+ return;
811
+ }
751
812
  const vimCopy = parseVimBridgeCopy(e.data);
752
813
  if (vimCopy !== null) {
753
814
  const iframe = iframeRef.current;
@@ -916,6 +977,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
916
977
  );
917
978
  }, [iframeReadyVersion, annotateModeActive]);
918
979
 
980
+ // A native host decides link navigations itself (`hostNavigates`).
981
+ useEffect(() => {
982
+ if (iframeReadyVersion === 0 || !hostNavigates) return;
983
+ postToBridge({ type: `${PREFIX}set-host-navigates`, active: true });
984
+ }, [iframeReadyVersion, hostNavigates]);
985
+
919
986
  // Parent-side Esc rung: with focus outside the iframe the bridge never
920
987
  // sees the keydown. Any open composer/toolbar/picker still closes first —
921
988
  // their state is read from this render's closure, so an Esc that closed
@@ -1012,6 +1079,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1012
1079
  // Shared/draft restores respect the live page filter too.
1013
1080
  applySharedAnnotations: (anns: Annotation[]) =>
1014
1081
  hook.applyAnnotations(forCurrentPage(anns)),
1082
+ stepPin: (direction: "parent" | "child") =>
1083
+ postToBridge({ type: `${PREFIX}step-pin`, direction }),
1084
+ revealPin: () => postToBridge({ type: `${PREFIX}reveal-pin` }),
1015
1085
  }));
1016
1086
 
1017
1087
  const handleGlobalCommentSubmit = useCallback(
@@ -1166,7 +1236,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1166
1236
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
1167
1237
  <iframe
1168
1238
  ref={iframeRef}
1169
- {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
1239
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" + (hostNavigates ? " allow-popups" : "") })}
1170
1240
  style={{
1171
1241
  width: "100%",
1172
1242
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -225,6 +225,8 @@
225
225
  // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
226
226
  // Esc (or the header pen) drops to Interact.
227
227
  var annotateModeActive = true;
228
+ // A native host decides link navigations itself (HtmlViewer hostNavigates).
229
+ var hostNavigates = false;
228
230
  function updatePinpointCursor() {
229
231
  if (!document.body) return;
230
232
  if (annotateModeActive && currentInputMethod === 'pinpoint') {
@@ -530,6 +532,21 @@
530
532
  flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
531
533
  }
532
534
 
535
+ else if (type === PREFIX + 'step-pin') {
536
+ // A host's Parent or Child (a phone's comment sheet): move the pinned
537
+ // draft to the element around it, or back in. Re-pins through
538
+ // annotateElement, so the parent sees an ordinary pinpoint selection.
539
+ stepPin(e.data.direction === 'child' ? 'child' : 'parent');
540
+ }
541
+
542
+ else if (type === PREFIX + 'reveal-pin') {
543
+ // A host whose own panel covers the page's lower part (a phone's comment
544
+ // sheet) shrank the frame: bring the pinned draft back into view.
545
+ if (pendingPinEl && pendingPinEl.isConnected) {
546
+ try { pendingPinEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
547
+ }
548
+ }
549
+
533
550
  else if (type === PREFIX + 'scroll-to') {
534
551
  // Selecting an annotation scrolls its first resolved target into view
535
552
  // and flashes the overlay focus highlight over EVERY rect of EVERY
@@ -573,6 +590,10 @@
573
590
  if (vimEnabled) updateVimUi();
574
591
  }
575
592
 
593
+ else if (type === PREFIX + 'set-host-navigates') {
594
+ hostNavigates = e.data.active === true;
595
+ }
596
+
576
597
  else if (type === PREFIX + 'set-annotate-mode') {
577
598
  var nextAnnotateActive = e.data.active === true;
578
599
  if (nextAnnotateActive !== annotateModeActive) {
@@ -3524,6 +3545,41 @@
3524
3545
  });
3525
3546
  }
3526
3547
 
3548
+ // Parent and Child for a pinned draft. The trail holds the elements stepped
3549
+ // out of, so Child goes back the way Parent came; past it, Child takes the
3550
+ // first element child. A new pin by click starts a new trail.
3551
+ var pinStepTrail = [];
3552
+ var steppingPin = false;
3553
+ function stepPin(direction) {
3554
+ if (!pendingPinEl || !pendingPinEl.isConnected) return;
3555
+ var from = pendingPinEl;
3556
+ var next = null;
3557
+ if (direction === 'parent') {
3558
+ var up = from.parentElement;
3559
+ if (up && up !== document.body && up !== document.documentElement) next = up;
3560
+ } else {
3561
+ var back = pinStepTrail[pinStepTrail.length - 1];
3562
+ if (back && back.isConnected && from.contains(back)) next = back;
3563
+ else {
3564
+ for (var child = from.firstElementChild; child; child = child.nextElementSibling) {
3565
+ if (isViewerOverlayNode(child)) continue;
3566
+ if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(child.tagName)) continue;
3567
+ next = child;
3568
+ break;
3569
+ }
3570
+ }
3571
+ }
3572
+ if (!next) return;
3573
+ var trail = pinStepTrail.slice();
3574
+ if (direction === 'parent') trail.push(from);
3575
+ else if (trail[trail.length - 1] === next) trail.pop();
3576
+ else trail = [];
3577
+ steppingPin = true;
3578
+ try { annotateElement(next, undefined, pendingPinViaPinpoint, null); }
3579
+ finally { steppingPin = false; }
3580
+ pinStepTrail = trail;
3581
+ }
3582
+
3527
3583
  // Pin an element: select its text if possible (so a <mark> can wrap it), else
3528
3584
  // post its text + box directly so the toolbar still anchors (e.g. an SVG node,
3529
3585
  // whose <text> doesn't select like HTML text). Either way the element stays
@@ -3531,6 +3587,7 @@
3531
3587
  // rides along so the annotation can restore to this exact element later.
3532
3588
  function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
3533
3589
  if (!el) return false;
3590
+ if (!steppingPin) pinStepTrail = [];
3534
3591
  pinpointHover = null;
3535
3592
  hidePinpointLabel();
3536
3593
  clearMultiTargets(); // a new primary starts a fresh draft
@@ -3666,6 +3723,16 @@
3666
3723
  return;
3667
3724
  }
3668
3725
  e.preventDefault();
3726
+ // A native host decides links itself: a person's tap opens the link as a
3727
+ // new window, which the host's web view hands to its own delegate (the
3728
+ // frame never navigates; the host page's CSP would refuse it anyway).
3729
+ if (hostNavigates) {
3730
+ if (e.isTrusted && !(annotateModeActive && currentInputMethod === 'pinpoint')) {
3731
+ var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
3732
+ try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
3733
+ }
3734
+ return;
3735
+ }
3669
3736
  // Armed pinpoint: the click belongs to annotation, and the capture-phase
3670
3737
  // pinpoint handler below is about to pin this element. Navigation is
3671
3738
  // already suppressed above, which is all this surface owes the click.
@@ -3766,6 +3833,25 @@
3766
3833
  postToParent({ type: PREFIX + message });
3767
3834
  }, true);
3768
3835
 
3836
+ // A plain ] or [ while armed (Annotate): the host may step to its next or
3837
+ // previous file (the Inbox steps through a thread's attachments). Never in
3838
+ // Interact (the page owns its keys), never while the page's focus is in a
3839
+ // text field, with a modifier, on auto-repeat or mid-composition. The page
3840
+ // still receives the key; a host without a step handler ignores the
3841
+ // message. Capture phase, like the chords above.
3842
+ document.addEventListener('keydown', function(e) {
3843
+ if (e.key !== ']' && e.key !== '[') return;
3844
+ if (!annotateModeActive || vimEnabled) return;
3845
+ if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return;
3846
+ var t = e.target;
3847
+ if (t && t.nodeType === 1) {
3848
+ var tag = t.tagName;
3849
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
3850
+ if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
3851
+ }
3852
+ postToParent({ type: PREFIX + 'step', dir: e.key === ']' ? 1 : -1 });
3853
+ }, true);
3854
+
3769
3855
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
3770
3856
  // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
3771
3857
  // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
@@ -461,6 +461,8 @@ export const BRIDGE_SCRIPT = `(function() {
461
461
  // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
462
462
  // Esc (or the header pen) drops to Interact.
463
463
  var annotateModeActive = true;
464
+ // A native host decides link navigations itself (HtmlViewer hostNavigates).
465
+ var hostNavigates = false;
464
466
  function updatePinpointCursor() {
465
467
  if (!document.body) return;
466
468
  if (annotateModeActive && currentInputMethod === 'pinpoint') {
@@ -766,6 +768,21 @@ export const BRIDGE_SCRIPT = `(function() {
766
768
  flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
767
769
  }
768
770
 
771
+ else if (type === PREFIX + 'step-pin') {
772
+ // A host's Parent or Child (a phone's comment sheet): move the pinned
773
+ // draft to the element around it, or back in. Re-pins through
774
+ // annotateElement, so the parent sees an ordinary pinpoint selection.
775
+ stepPin(e.data.direction === 'child' ? 'child' : 'parent');
776
+ }
777
+
778
+ else if (type === PREFIX + 'reveal-pin') {
779
+ // A host whose own panel covers the page's lower part (a phone's comment
780
+ // sheet) shrank the frame: bring the pinned draft back into view.
781
+ if (pendingPinEl && pendingPinEl.isConnected) {
782
+ try { pendingPinEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
783
+ }
784
+ }
785
+
769
786
  else if (type === PREFIX + 'scroll-to') {
770
787
  // Selecting an annotation scrolls its first resolved target into view
771
788
  // and flashes the overlay focus highlight over EVERY rect of EVERY
@@ -809,6 +826,10 @@ export const BRIDGE_SCRIPT = `(function() {
809
826
  if (vimEnabled) updateVimUi();
810
827
  }
811
828
 
829
+ else if (type === PREFIX + 'set-host-navigates') {
830
+ hostNavigates = e.data.active === true;
831
+ }
832
+
812
833
  else if (type === PREFIX + 'set-annotate-mode') {
813
834
  var nextAnnotateActive = e.data.active === true;
814
835
  if (nextAnnotateActive !== annotateModeActive) {
@@ -3760,6 +3781,41 @@ export const BRIDGE_SCRIPT = `(function() {
3760
3781
  });
3761
3782
  }
3762
3783
 
3784
+ // Parent and Child for a pinned draft. The trail holds the elements stepped
3785
+ // out of, so Child goes back the way Parent came; past it, Child takes the
3786
+ // first element child. A new pin by click starts a new trail.
3787
+ var pinStepTrail = [];
3788
+ var steppingPin = false;
3789
+ function stepPin(direction) {
3790
+ if (!pendingPinEl || !pendingPinEl.isConnected) return;
3791
+ var from = pendingPinEl;
3792
+ var next = null;
3793
+ if (direction === 'parent') {
3794
+ var up = from.parentElement;
3795
+ if (up && up !== document.body && up !== document.documentElement) next = up;
3796
+ } else {
3797
+ var back = pinStepTrail[pinStepTrail.length - 1];
3798
+ if (back && back.isConnected && from.contains(back)) next = back;
3799
+ else {
3800
+ for (var child = from.firstElementChild; child; child = child.nextElementSibling) {
3801
+ if (isViewerOverlayNode(child)) continue;
3802
+ if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(child.tagName)) continue;
3803
+ next = child;
3804
+ break;
3805
+ }
3806
+ }
3807
+ }
3808
+ if (!next) return;
3809
+ var trail = pinStepTrail.slice();
3810
+ if (direction === 'parent') trail.push(from);
3811
+ else if (trail[trail.length - 1] === next) trail.pop();
3812
+ else trail = [];
3813
+ steppingPin = true;
3814
+ try { annotateElement(next, undefined, pendingPinViaPinpoint, null); }
3815
+ finally { steppingPin = false; }
3816
+ pinStepTrail = trail;
3817
+ }
3818
+
3763
3819
  // Pin an element: select its text if possible (so a <mark> can wrap it), else
3764
3820
  // post its text + box directly so the toolbar still anchors (e.g. an SVG node,
3765
3821
  // whose <text> doesn't select like HTML text). Either way the element stays
@@ -3767,6 +3823,7 @@ export const BRIDGE_SCRIPT = `(function() {
3767
3823
  // rides along so the annotation can restore to this exact element later.
3768
3824
  function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
3769
3825
  if (!el) return false;
3826
+ if (!steppingPin) pinStepTrail = [];
3770
3827
  pinpointHover = null;
3771
3828
  hidePinpointLabel();
3772
3829
  clearMultiTargets(); // a new primary starts a fresh draft
@@ -3902,6 +3959,16 @@ export const BRIDGE_SCRIPT = `(function() {
3902
3959
  return;
3903
3960
  }
3904
3961
  e.preventDefault();
3962
+ // A native host decides links itself: a person's tap opens the link as a
3963
+ // new window, which the host's web view hands to its own delegate (the
3964
+ // frame never navigates; the host page's CSP would refuse it anyway).
3965
+ if (hostNavigates) {
3966
+ if (e.isTrusted && !(annotateModeActive && currentInputMethod === 'pinpoint')) {
3967
+ var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
3968
+ try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
3969
+ }
3970
+ return;
3971
+ }
3905
3972
  // Armed pinpoint: the click belongs to annotation, and the capture-phase
3906
3973
  // pinpoint handler below is about to pin this element. Navigation is
3907
3974
  // already suppressed above, which is all this surface owes the click.
@@ -4002,6 +4069,25 @@ export const BRIDGE_SCRIPT = `(function() {
4002
4069
  postToParent({ type: PREFIX + message });
4003
4070
  }, true);
4004
4071
 
4072
+ // A plain ] or [ while armed (Annotate): the host may step to its next or
4073
+ // previous file (the Inbox steps through a thread's attachments). Never in
4074
+ // Interact (the page owns its keys), never while the page's focus is in a
4075
+ // text field, with a modifier, on auto-repeat or mid-composition. The page
4076
+ // still receives the key; a host without a step handler ignores the
4077
+ // message. Capture phase, like the chords above.
4078
+ document.addEventListener('keydown', function(e) {
4079
+ if (e.key !== ']' && e.key !== '[') return;
4080
+ if (!annotateModeActive || vimEnabled) return;
4081
+ if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return;
4082
+ var t = e.target;
4083
+ if (t && t.nodeType === 1) {
4084
+ var tag = t.tagName;
4085
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
4086
+ if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
4087
+ }
4088
+ postToParent({ type: PREFIX + 'step', dir: e.key === ']' ? 1 : -1 });
4089
+ }, true);
4090
+
4005
4091
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
4006
4092
  // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
4007
4093
  // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
- import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
1
+ import { useState, useEffect, useLayoutEffect, useCallback, useRef, type RefObject } from "react";
2
+ import { AnnotationType, type Annotation, type EditorMode, type HostDraft, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
3
3
  import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
@@ -199,6 +199,10 @@ export interface UseHtmlAnnotationOptions {
199
199
  /** scrollIntoView behavior for scroll-to (selecting an annotation).
200
200
  * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
201
201
  scrollBehavior?: 'smooth' | 'auto';
202
+ /** The composer handed to the host (see `HostDraft` and HtmlViewer's
203
+ * `onHostDraft`): a selection or a pin reports a draft instead of opening
204
+ * the toolbar or the composer. Absent: unchanged. */
205
+ onHostDraft?: (draft: HostDraft | null) => void;
202
206
  }
203
207
 
204
208
  /** Clamp a host cap into the package's bound; anything unusable is the default. */
@@ -465,6 +469,7 @@ export function useHtmlAnnotation({
465
469
  onUnanchoredChange,
466
470
  maxAdditionalTargets,
467
471
  scrollBehavior,
472
+ onHostDraft,
468
473
  }: UseHtmlAnnotationOptions): Omit<
469
474
  UseAnnotationHighlighterReturn,
470
475
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -536,6 +541,10 @@ export function useHtmlAnnotation({
536
541
  onPageChangeRef.current = onPageChange;
537
542
  const onLinkClickRef = useRef(onLinkClick);
538
543
  onLinkClickRef.current = onLinkClick;
544
+ const onHostDraftRef = useRef(onHostDraft);
545
+ onHostDraftRef.current = onHostDraft;
546
+ // The id of the draft the host holds, so a stale cancel cannot drop a newer one.
547
+ const hostDraftIdRef = useRef<string | null>(null);
539
548
  // The effective cap and whether the host set one: only an explicit cap
540
549
  // rides on arm-multi-select, so an unconfigured viewer posts today's message.
541
550
  const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
@@ -628,7 +637,9 @@ export function useHtmlAnnotation({
628
637
  [iframeRef, getOrCreateAnchor],
629
638
  );
630
639
 
631
- useEffect(() => {
640
+ // A layout effect, like HtmlViewer's own listener: listening from the
641
+ // commit that inserts the iframe, before its document can post anything.
642
+ useLayoutEffect(() => {
632
643
  function handler(e: MessageEvent<unknown>) {
633
644
  if (e.source !== iframeRef.current?.contentWindow) return;
634
645
  // Live sessions verify origin + session token BEFORE parsing. The
@@ -659,6 +670,39 @@ export function useHtmlAnnotation({
659
670
  pendingAnchorRef.current = message.anchor ?? null;
660
671
  pendingContextRef.current = message.context ?? null;
661
672
  setDraftTargets([]); // a new selection always starts a fresh draft
673
+ const reportDraft = onHostDraftRef.current;
674
+ if (reportDraft) {
675
+ // The host composes: HTML is comment-only, so every selection or
676
+ // pin is a comment draft, and nothing of ours opens.
677
+ const id = nextHtmlAnnId();
678
+ hostDraftIdRef.current = id;
679
+ reportDraft({
680
+ annotation: {
681
+ id,
682
+ blockId: "",
683
+ startOffset: 0,
684
+ endOffset: 0,
685
+ type: AnnotationType.COMMENT,
686
+ text: "",
687
+ originalText: message.text,
688
+ author: getIdentity(),
689
+ createdA: Date.now(),
690
+ htmlAnchor: message.anchor,
691
+ elementContext: message.context,
692
+ },
693
+ intent: message.pinpoint ? "compose" : "selection",
694
+ label: message.targetLabel,
695
+ cancel: () => {
696
+ if (hostDraftIdRef.current !== id) return;
697
+ hostDraftIdRef.current = null;
698
+ post({ type: `${PREFIX}cancel-selection` });
699
+ pendingTextRef.current = "";
700
+ pendingAnchorRef.current = null;
701
+ pendingContextRef.current = null;
702
+ },
703
+ });
704
+ return;
705
+ }
662
706
  const anchor = positionAnchor(message.rect);
663
707
  if (!anchor) return;
664
708
 
@@ -754,6 +798,10 @@ export function useHtmlAnnotation({
754
798
  }
755
799
 
756
800
  if (type === `${PREFIX}selection-clear`) {
801
+ if (onHostDraftRef.current && hostDraftIdRef.current) {
802
+ hostDraftIdRef.current = null;
803
+ onHostDraftRef.current(null);
804
+ }
757
805
  setToolbarState(null);
758
806
  // Keep the captured text alive while a comment/quick-label is open: the user
759
807
  // is composing, and the selection collapsing or scrolling out of view must
@@ -19,14 +19,16 @@ export function CodexIcon({ className }: { className?: string }) {
19
19
  return <img src={CODEX_DATA_URI} className={className} alt="" aria-hidden="true" draggable={false} />;
20
20
  }
21
21
 
22
- // Pi brand mark — path data from packages/ui/components/ProviderIcons.tsx's
23
- // PiIcon (itself extracted from apps/marketing/public/assets/icon-pi.svg).
24
- // currentColor so it adapts to theme, like OpenCodeIcon below.
22
+ // Pi brand mark: the pi.dev logo (https://pi.dev/logo.svg), the same paths as
23
+ // ProviderIcons.tsx's PiIcon and apps/marketing/public/assets/icon-pi.svg. Its
24
+ // own three colours in every theme; the viewBox crops pi.dev's 800-unit canvas
25
+ // to the mark plus an even margin so it sits level with the other marks.
25
26
  export function PiIcon({ className }: { className?: string }) {
26
27
  return (
27
- <svg viewBox="0 0 800 800" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
28
- <path fill="currentColor" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" />
29
- <path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z" />
28
+ <svg viewBox="130 130 540 540" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
29
+ <path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z" />
30
+ <path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z" />
31
+ <path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z" />
30
32
  </svg>
31
33
  );
32
34
  }
@@ -34,7 +36,7 @@ export function PiIcon({ className }: { className?: string }) {
34
36
  // GitHub Copilot brand mark — path from the official agent icon set
35
37
  // (welcome.developers.workers.dev/icons/agents/copilot); the dark/light
36
38
  // variants there differ only by fill, so one currentColor component covers
37
- // both themes, like PiIcon/OpenCodeIcon.
39
+ // both themes, like OpenCodeIcon.
38
40
  export function CopilotIcon({ className }: { className?: string }) {
39
41
  return (
40
42
  <svg viewBox="0 0 256 208" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">