@plannotator/ui 0.30.0 → 0.31.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 (74) hide show
  1. package/README.md +1 -0
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +62 -32
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +208 -39
  34. package/components/html-viewer/bridge-script.ts +319 -65
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +146 -57
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
@@ -213,19 +213,83 @@ body[data-plannotator-vim-focus-owner]:focus {
213
213
  export const BRIDGE_SCRIPT = `(function() {
214
214
  var PREFIX = 'plannotator-bridge-';
215
215
 
216
+ // --- Live mode (proxied local app) ---
217
+ // Srcdoc sessions carry no config: LIVE stays null and every branch below is
218
+ // inert, keeping srcdoc behavior byte-for-byte identical. The proxy injects
219
+ // this script into EVERY HTML response, so a frame gate deactivates the
220
+ // bridge when the proxied page is opened directly (not framed) and inside
221
+ // nested same-origin subframes: only the frame whose parent IS the editor
222
+ // may run.
223
+ var LIVE = window.__plannotatorLiveConfig || null;
224
+ if (LIVE && (window === window.parent || window.parent !== window.top)) return;
225
+ // The server cannot know which origin form (localhost or 127.0.0.1) the
226
+ // editor tab was opened on, so live outbound messages are posted once per
227
+ // listed editor origin: the browser delivers only the post whose
228
+ // targetOrigin matches the parent document and silently drops the rest,
229
+ // so exactly one copy arrives. Inbound accepts any listed origin. Srcdoc
230
+ // keeps targetOrigin '*' and no token.
231
+ function isEditorOrigin(origin) {
232
+ if (!LIVE) return true;
233
+ var list = LIVE.editorOrigins || [];
234
+ for (var i = 0; i < list.length; i++) { if (list[i] === origin) return true; }
235
+ return false;
236
+ }
237
+ function postToParent(msg) {
238
+ if (LIVE) {
239
+ msg.token = LIVE.token;
240
+ var origins = LIVE.editorOrigins || [];
241
+ for (var o = 0; o < origins.length; o++) parent.postMessage(msg, origins[o]);
242
+ return;
243
+ }
244
+ parent.postMessage(msg, '*');
245
+ }
246
+ // Page identity for multi-page live sessions: annotations are stamped with
247
+ // the page they were made on, and restore filters to the current page.
248
+ function currentPageUrl() {
249
+ return (location.pathname + location.search).slice(0, 2048);
250
+ }
251
+ if (LIVE) {
252
+ // SPA navigation: report history changes so the parent can re-filter the
253
+ // restored set. Coalesced with a microtask flag so a pushState burst posts
254
+ // once. Full reloads need nothing: the proxy re-injects and the fresh
255
+ // document posts ready again.
256
+ var pageChangeQueued = false;
257
+ var postPageChange = function() {
258
+ if (pageChangeQueued) return;
259
+ pageChangeQueued = true;
260
+ Promise.resolve().then(function() {
261
+ pageChangeQueued = false;
262
+ postToParent({ type: PREFIX + 'page-change', pageUrl: currentPageUrl() });
263
+ });
264
+ };
265
+ var wrapHistory = function(name) {
266
+ var original = history[name];
267
+ if (typeof original !== 'function') return;
268
+ history[name] = function() {
269
+ var result = original.apply(this, arguments);
270
+ postPageChange();
271
+ return result;
272
+ };
273
+ };
274
+ wrapHistory('pushState');
275
+ wrapHistory('replaceState');
276
+ window.addEventListener('popstate', postPageChange);
277
+ }
278
+
216
279
  // --- Theme ---
217
280
  // The author owns this document. Unless it opted in to host theming
218
281
  // (hostTheme), only viewer-namespaced --pn-* properties may be written to its
219
282
  // root, and its class list is never touched.
220
283
  window.addEventListener('message', function(e) {
221
284
  if (e.source !== parent) return;
285
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
222
286
  if (!e.data) return;
223
287
  if (e.data.type === PREFIX + 'set-vim-help') {
224
288
  vimHelpOpen = !!e.data.open;
225
- parent.postMessage({
289
+ postToParent({
226
290
  type: PREFIX + 'vim-help',
227
291
  open: vimHelpOpen
228
- }, '*');
292
+ });
229
293
  return;
230
294
  }
231
295
  if (e.data.type !== PREFIX + 'theme') return;
@@ -246,11 +310,12 @@ export const BRIDGE_SCRIPT = `(function() {
246
310
  // --- Resize ---
247
311
  var lastHeight = 0;
248
312
  function postResize() {
313
+ if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
249
314
  if (!document.body) return;
250
315
  var h = document.body.scrollHeight;
251
316
  if (h !== lastHeight) {
252
317
  lastHeight = h;
253
- parent.postMessage({ type: PREFIX + 'resize', height: h }, '*');
318
+ postToParent({ type: PREFIX + 'resize', height: h });
254
319
  }
255
320
  }
256
321
  window.addEventListener('load', postResize);
@@ -274,7 +339,28 @@ export const BRIDGE_SCRIPT = `(function() {
274
339
  var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
275
340
  var multiTargetSeq = 0;
276
341
  var MAX_MULTI_TARGETS = 16;
277
- var currentInputMethod = 'drag'; // 'drag' = text selection, 'pinpoint' = click an element
342
+ // Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
343
+ // drag-selection is a separate, always-on channel — see the mouseup handler
344
+ // — so the clamp only decides what a plain click does, never whether text
345
+ // can be selected and commented.
346
+ var currentInputMethod = LIVE ? 'pinpoint' : 'drag'; // 'drag' = text selection, 'pinpoint' = click an element
347
+ // Interact/Annotate mode. While INACTIVE the bridge keeps clicks native: no
348
+ // pinpoint capture, no hover outline, no [data-annotate] click, no
349
+ // committed-highlight click interception — clicks, forms, and SPA
350
+ // navigation reach the page untouched. Text drag-selection commenting stays
351
+ // LIVE in both modes (a real drag opens the comment toolbar even in
352
+ // Interact), committed overlay artifacts stay visible in both modes, and
353
+ // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
354
+ // Esc (or the header pen) drops to Interact.
355
+ var annotateModeActive = true;
356
+ function updatePinpointCursor() {
357
+ if (!document.body) return;
358
+ if (annotateModeActive && currentInputMethod === 'pinpoint') {
359
+ document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
360
+ } else {
361
+ document.body.removeAttribute('data-plannotator-pinpoint-cursor');
362
+ }
363
+ }
278
364
  var pinpointHover = null;
279
365
  var vimEnabled = false;
280
366
  var vimHudEnabled = false;
@@ -295,10 +381,9 @@ export const BRIDGE_SCRIPT = `(function() {
295
381
  // and immediately clear it. This flag suppresses that one trailing clear.
296
382
  var skipNextClear = false;
297
383
 
298
- document.addEventListener('mouseup', function(e) {
299
- if (currentInputMethod === 'pinpoint') return; // pinpoint uses click, not drag-select
300
- setTimeout(handleSelection, 10);
301
- });
384
+ // Drag-selection commenting is handled by the merged capture-phase mouseup
385
+ // listener below (after the drag-yield state it reads) — it is ALWAYS live:
386
+ // both surfaces, armed or Interact, drag or pinpoint input method.
302
387
 
303
388
  // The page fully controls element text, so everything posted as a selection
304
389
  // is bounded here before it crosses the bridge (the parent enforces the same
@@ -322,7 +407,7 @@ export const BRIDGE_SCRIPT = `(function() {
322
407
  // Trailing clear from a plain-click element annotation — consume it once.
323
408
  if (skipNextClear) { skipNextClear = false; return; }
324
409
  if (pendingSelection) {
325
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
410
+ postToParent({ type: PREFIX + 'selection-clear' });
326
411
  pendingSelection = null;
327
412
  pendingRange = null;
328
413
  clearMultiTargets();
@@ -350,7 +435,7 @@ export const BRIDGE_SCRIPT = `(function() {
350
435
  endOffset: range.endOffset
351
436
  };
352
437
 
353
- parent.postMessage({
438
+ postToParent({
354
439
  type: PREFIX + 'selection',
355
440
  text: text,
356
441
  modeOverride: modeOverride || undefined,
@@ -359,7 +444,7 @@ export const BRIDGE_SCRIPT = `(function() {
359
444
  targetKey: (extras && extras.targetKey) || undefined,
360
445
  targetLabel: (extras && extras.targetLabel) || undefined,
361
446
  rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
362
- }, '*');
447
+ });
363
448
  renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
364
449
  return true;
365
450
  }
@@ -382,17 +467,17 @@ export const BRIDGE_SCRIPT = `(function() {
382
467
  // Drag selections keep the existing close-on-scroll-out behavior.
383
468
  if (pendingPinViaPinpoint || pendingMultiTargets.length > 0) return;
384
469
  // Selection scrolled out of view — close the toolbar (matches markdown).
385
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
470
+ postToParent({ type: PREFIX + 'selection-clear' });
386
471
  pendingSelection = null;
387
472
  pendingRange = null;
388
473
  clearPendingPin();
389
474
  renderAnnotationOverlay();
390
475
  return;
391
476
  }
392
- parent.postMessage({
477
+ postToParent({
393
478
  type: PREFIX + 'selection-rect',
394
479
  rect: { top: r.top, left: r.left, width: r.width, height: r.height }
395
- }, '*');
480
+ });
396
481
  }
397
482
  window.addEventListener('scroll', function() {
398
483
  if (!pendingSelection) return;
@@ -402,6 +487,7 @@ export const BRIDGE_SCRIPT = `(function() {
402
487
  // --- Mark Creation ---
403
488
  window.addEventListener('message', function(e) {
404
489
  if (e.source !== parent) return;
490
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
405
491
  if (!e.data || !e.data.type) return;
406
492
  var type = e.data.type;
407
493
 
@@ -483,11 +569,11 @@ export const BRIDGE_SCRIPT = `(function() {
483
569
  e.data.anchor,
484
570
  e.data.additionalAnchors
485
571
  );
486
- parent.postMessage({
572
+ postToParent({
487
573
  type: PREFIX + 'mark-applied',
488
574
  id: e.data.id,
489
575
  success: found
490
- }, '*');
576
+ });
491
577
  }
492
578
 
493
579
  else if (type === PREFIX + 'remove-mark') {
@@ -578,18 +664,44 @@ export const BRIDGE_SCRIPT = `(function() {
578
664
  }
579
665
 
580
666
  else if (type === PREFIX + 'set-input-method') {
581
- currentInputMethod = e.data.method === 'pinpoint' ? 'pinpoint' : 'drag';
667
+ // Live mode clamps the input method to pinpoint (what a plain click
668
+ // does); text drag-selection commenting stays live regardless.
669
+ currentInputMethod = (LIVE || e.data.method === 'pinpoint') ? 'pinpoint' : 'drag';
582
670
  if (currentInputMethod === 'pinpoint') {
583
671
  clearHoverHighlight(); // pinpoint owns clicks; drop the select affordance (and any pending hit test)
584
- if (document.body) document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
585
672
  } else {
586
- if (document.body) document.body.removeAttribute('data-plannotator-pinpoint-cursor');
587
673
  clearPinpointHover();
588
674
  }
675
+ updatePinpointCursor(); // cursor affordance is mode-gated: never in Interact
589
676
  if (vimEnabled) updateVimUi();
590
677
  }
591
678
 
679
+ else if (type === PREFIX + 'set-annotate-mode') {
680
+ var nextAnnotateActive = e.data.active === true;
681
+ if (nextAnnotateActive !== annotateModeActive) {
682
+ annotateModeActive = nextAnnotateActive;
683
+ if (!annotateModeActive) {
684
+ // Disarm tears down every PENDING affordance; committed markers and
685
+ // highlights stay visible, and marker buttons keep their clicks.
686
+ if (pendingSelection) postToParent({ type: PREFIX + 'selection-clear' });
687
+ pendingSelection = null;
688
+ pendingRange = null;
689
+ skipNextClear = false;
690
+ clearMultiTargets();
691
+ clearPendingPin();
692
+ try { window.getSelection().removeAllRanges(); } catch (ex) {}
693
+ clearPinpointHover();
694
+ clearHoverHighlight();
695
+ renderAnnotationOverlay();
696
+ }
697
+ updatePinpointCursor();
698
+ }
699
+ }
700
+
592
701
  else if (type === PREFIX + 'set-vim-mode') {
702
+ // Vim is off in live mode: it writes classes onto author elements and
703
+ // captures keys the app needs.
704
+ if (LIVE) return;
593
705
  var wasVimEnabled = vimEnabled;
594
706
  var wasVimHudEnabled = vimHudEnabled;
595
707
  vimEnabled = e.data.enabled === true;
@@ -1096,6 +1208,7 @@ export const BRIDGE_SCRIPT = `(function() {
1096
1208
  document.addEventListener('mousemove', function(e) {
1097
1209
  lastPointer = { x: e.clientX, y: e.clientY };
1098
1210
  updateDragYield(e);
1211
+ if (!annotateModeActive) return; // Interact mode: no hover affordances
1099
1212
  if (currentInputMethod !== 'pinpoint') {
1100
1213
  // Click-to-select hover affordance (drag mode owns highlight clicks):
1101
1214
  // cheap cached-rect hit test, rAF-throttled, cleared while a draft or
@@ -1108,6 +1221,9 @@ export const BRIDGE_SCRIPT = `(function() {
1108
1221
  return;
1109
1222
  }
1110
1223
  if (vimEnabled && vimPhase !== 'inactive') return;
1224
+ // Mid-drag (text selection in progress) the element hover box is noise:
1225
+ // the drag owns the surface until mouseup resolves it.
1226
+ if (dragYieldActive) { clearPinpointHover(); return; }
1111
1227
  // Hit-test at the pointer (e.target only backstops engines without
1112
1228
  // elementFromPoint) so the same code path serves the scroll re-hit-test.
1113
1229
  updatePinpointHover(e.clientX, e.clientY, e.target);
@@ -1137,7 +1253,7 @@ export const BRIDGE_SCRIPT = `(function() {
1137
1253
  renderAnnotationOverlay();
1138
1254
  // Scroll under a stationary pointer moves the committed rects: re-run
1139
1255
  // the cached-rect hover test so the affordance tracks reality.
1140
- if (currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1256
+ if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1141
1257
  setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
1142
1258
  }
1143
1259
  positionMultiTargetBoxes();
@@ -1145,7 +1261,7 @@ export const BRIDGE_SCRIPT = `(function() {
1145
1261
  positionPinpointBox(pendingPinEl);
1146
1262
  return;
1147
1263
  }
1148
- if (currentInputMethod !== 'pinpoint') return;
1264
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
1149
1265
  if (vimEnabled && vimPhase !== 'inactive') return;
1150
1266
  if (lastPointer) {
1151
1267
  updatePinpointHover(lastPointer.x, lastPointer.y, pinpointHover);
@@ -1259,15 +1375,21 @@ export const BRIDGE_SCRIPT = `(function() {
1259
1375
  }
1260
1376
  }
1261
1377
 
1262
- // Drag-selection marker yield: while a text drag is in progress in drag
1263
- // mode, placed markers drop pointer input (the same data-pn-hittest CSS the
1264
- // hit-test yield uses) so a 25px bubble sitting over the text cannot
1265
- // capture the selection mid-drag. Armed only by a >4px move with the
1266
- // primary button held from a non-overlay mousedown, so marker clicks
1267
- // (mousedown ON the marker) and plain click-to-select (no drag) are
1268
- // untouched; disarmed on mouseup or when the button is seen released.
1378
+ // Drag-selection marker yield: while a text drag is in progress, placed
1379
+ // markers drop pointer input (the same data-pn-hittest CSS the hit-test
1380
+ // yield uses) so a 25px bubble sitting over the text cannot capture the
1381
+ // selection mid-drag. Armed only by a >4px move with the primary button
1382
+ // held from a non-overlay mousedown, so marker clicks (mousedown ON the
1383
+ // marker) and plain click-to-select (no drag) are untouched; disarmed on
1384
+ // mouseup or when the button is seen released. The same >4px arming is
1385
+ // what tells the mouseup handler below that a REAL drag happened, which
1386
+ // is how drag-selection commenting stays live in pinpoint/armed mode
1387
+ // without a plain pinpoint click re-posting its own selection.
1269
1388
  var dragYieldStart = null;
1270
1389
  var dragYieldActive = false;
1390
+ // True between a drag's terminating mouseup and the next mousedown: the
1391
+ // click event that follows a completed drag must not pinpoint-annotate.
1392
+ var dragEndedClick = false;
1271
1393
  function endDragYield() {
1272
1394
  dragYieldStart = null;
1273
1395
  if (dragYieldActive) {
@@ -1292,13 +1414,37 @@ export const BRIDGE_SCRIPT = `(function() {
1292
1414
  }
1293
1415
  }
1294
1416
  document.addEventListener('mousedown', function(e) {
1295
- if (currentInputMethod === 'pinpoint') return;
1417
+ dragEndedClick = false;
1296
1418
  if (e.button !== 0) return;
1297
1419
  if (isViewerOverlayNode(e.target)) return;
1298
1420
  dragYieldStart = { x: e.clientX, y: e.clientY };
1299
1421
  }, true);
1300
1422
  document.addEventListener('mouseup', function() {
1423
+ // Text drag-selection commenting is ALWAYS live: both surfaces, armed or
1424
+ // Interact. In armed pinpoint a plain click belongs to the pinpoint
1425
+ // handler — only a drag that actually PRODUCED a text selection owns its
1426
+ // trailing click, so annotateElement's own selection is never re-posted
1427
+ // by its trailing mouseup. The >4px drift alone is NOT enough to arm the
1428
+ // click suppression: a drifted click (common on trackpads) that selected
1429
+ // nothing must stay a click, or armed pinpoint would silently swallow it
1430
+ // while the unprevented click leaks through to the page. Browsers clear
1431
+ // a prior selection on the mousedown that starts the drag, so the
1432
+ // selection observed here is the one THIS drag produced. Everywhere else
1433
+ // the classic always-schedule behavior stays: handleSelection only acts
1434
+ // on a real selection, posts the clear that dismisses a stale draft, and
1435
+ // never preventDefaults — a plain click is never swallowed.
1436
+ var dragged = dragYieldActive;
1301
1437
  endDragYield();
1438
+ var draggedSelection = false;
1439
+ if (dragged) {
1440
+ try {
1441
+ var s = window.getSelection();
1442
+ draggedSelection = !!(s && !s.isCollapsed && s.rangeCount && (s.toString() || '').trim());
1443
+ } catch (ex) {}
1444
+ }
1445
+ dragEndedClick = draggedSelection;
1446
+ if (annotateModeActive && currentInputMethod === 'pinpoint' && !draggedSelection) return;
1447
+ setTimeout(handleSelection, 10);
1302
1448
  }, true);
1303
1449
 
1304
1450
  // One record per committed annotation. Its targets are live projections;
@@ -1362,7 +1508,11 @@ export const BRIDGE_SCRIPT = `(function() {
1362
1508
  var key = JSON.stringify(combined);
1363
1509
  if (key === lastUnanchoredKey) return;
1364
1510
  lastUnanchoredKey = key;
1365
- parent.postMessage({ type: PREFIX + 'unanchored', ids: combined }, '*');
1511
+ // postToParent, not a raw '*' post: live sessions stamp the session
1512
+ // token and post only to the listed editor origins, and the parent
1513
+ // drops untokened live messages — a raw post would silently disable
1514
+ // unanchored reporting exactly where restores fail most (live pages).
1515
+ postToParent({ type: PREFIX + 'unanchored', ids: combined });
1366
1516
  }
1367
1517
 
1368
1518
  function validNormalizedPoint(p) {
@@ -1502,9 +1652,47 @@ export const BRIDGE_SCRIPT = `(function() {
1502
1652
  }
1503
1653
  }
1504
1654
  }
1655
+ if (!record.targets.length && LIVE) {
1656
+ // Live pages render late (lazy routes, data-dependent trees): a
1657
+ // restore that resolves nothing keeps its record, seeded with
1658
+ // unresolved placeholder targets built from the durable params, so
1659
+ // the mutation-driven reconcile re-acquires them when the elements
1660
+ // appear. Srcdoc documents are static (nothing would ever
1661
+ // re-resolve), so the record is dropped below exactly as before.
1662
+ if (anchor) {
1663
+ record.targets.push({
1664
+ kind: 'element',
1665
+ element: null,
1666
+ anchor: anchor,
1667
+ point: normalizedPointOf(anchor, null)
1668
+ });
1669
+ }
1670
+ if (originalText) {
1671
+ record.targets.push({
1672
+ kind: 'range',
1673
+ range: null,
1674
+ text: originalText,
1675
+ markerless: !!anchor
1676
+ });
1677
+ }
1678
+ if (additionalAnchors && additionalAnchors.length) {
1679
+ var lateCount = Math.min(additionalAnchors.length, MAX_MULTI_TARGETS);
1680
+ for (var lateIndex = 0; lateIndex < lateCount; lateIndex++) {
1681
+ if (!additionalAnchors[lateIndex]) continue;
1682
+ record.targets.push({
1683
+ kind: 'element',
1684
+ element: null,
1685
+ anchor: additionalAnchors[lateIndex],
1686
+ point: normalizedPointOf(additionalAnchors[lateIndex], null)
1687
+ });
1688
+ }
1689
+ }
1690
+ }
1505
1691
  if (!record.targets.length) {
1506
1692
  // The record is removed (nothing to retry), so the per-pass dead scan
1507
1693
  // cannot see this id: track it separately for the unanchored report.
1694
+ // Live sessions only reach here when the durable params seeded no
1695
+ // placeholder targets at all (nothing will ever re-resolve).
1508
1696
  removeAnnRecord(id);
1509
1697
  restoreFailedIds.add(id);
1510
1698
  }
@@ -2067,7 +2255,7 @@ export const BRIDGE_SCRIPT = `(function() {
2067
2255
  btn.addEventListener('click', function(clickEvent) {
2068
2256
  clickEvent.preventDefault();
2069
2257
  clickEvent.stopPropagation();
2070
- parent.postMessage({ type: PREFIX + 'mark-click', id: annId }, '*');
2258
+ postToParent({ type: PREFIX + 'mark-click', id: annId });
2071
2259
  });
2072
2260
  overlayNodes.add(btn);
2073
2261
  return btn;
@@ -2608,7 +2796,7 @@ export const BRIDGE_SCRIPT = `(function() {
2608
2796
  clearPendingPin();
2609
2797
  try { window.getSelection().removeAllRanges(); } catch (ex) {}
2610
2798
  renderAnnotationOverlay();
2611
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2799
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2612
2800
  return;
2613
2801
  }
2614
2802
  var next = pendingMultiTargets.shift();
@@ -2628,14 +2816,14 @@ export const BRIDGE_SCRIPT = `(function() {
2628
2816
  mainBox.classList.remove('pn-pin-enter');
2629
2817
  if (pendingPinEl && pendingPinEl.isConnected) positionPinpointBox(pendingPinEl);
2630
2818
  renderAnnotationOverlay();
2631
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2819
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2632
2820
  return;
2633
2821
  }
2634
2822
  for (var i = 0; i < pendingMultiTargets.length; i++) {
2635
2823
  if (pendingMultiTargets[i].key === key) {
2636
2824
  destroyMultiTargetBox(pendingMultiTargets[i].box);
2637
2825
  pendingMultiTargets.splice(i, 1);
2638
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2826
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2639
2827
  return;
2640
2828
  }
2641
2829
  }
@@ -2678,13 +2866,13 @@ export const BRIDGE_SCRIPT = `(function() {
2678
2866
  var key = makeTargetKey();
2679
2867
  var box = createMultiTargetBox(el);
2680
2868
  pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
2681
- parent.postMessage({
2869
+ postToParent({
2682
2870
  type: PREFIX + 'multi-target-added',
2683
2871
  key: key,
2684
2872
  label: label,
2685
2873
  text: text,
2686
2874
  anchor: anchor || undefined
2687
- }, '*');
2875
+ });
2688
2876
  }
2689
2877
 
2690
2878
  /** Chip hover in the composer: flash the corresponding pinned outline. */
@@ -2748,12 +2936,12 @@ export const BRIDGE_SCRIPT = `(function() {
2748
2936
  pointerRelayRaf = requestAnimationFrame(function() {
2749
2937
  pointerRelayRaf = 0;
2750
2938
  if (!pendingPinEl || !pointerRelayPos) return;
2751
- parent.postMessage({
2939
+ postToParent({
2752
2940
  type: PREFIX + 'pointer',
2753
2941
  x: pointerRelayPos.x,
2754
2942
  y: pointerRelayPos.y,
2755
2943
  shift: pointerRelayPos.shift
2756
- }, '*');
2944
+ });
2757
2945
  });
2758
2946
  }
2759
2947
 
@@ -2823,22 +3011,31 @@ export const BRIDGE_SCRIPT = `(function() {
2823
3011
  pendingSelection = { element: true };
2824
3012
  pendingRange = null;
2825
3013
  skipNextClear = true; // don't let this click's mouseup clear the toolbar we just opened
2826
- parent.postMessage({ type: PREFIX + 'selection', text: elText,
3014
+ postToParent({ type: PREFIX + 'selection', text: elText,
2827
3015
  modeOverride: modeOverride || undefined,
2828
3016
  anchor: pendingPinAnchor || undefined,
2829
3017
  pinpoint: !!viaPinpoint || undefined,
2830
3018
  targetKey: pendingPinKey || undefined,
2831
3019
  targetLabel: pendingPinLabel || undefined,
2832
- rect: { top: r.top, left: r.left, width: r.width, height: r.height } }, '*');
3020
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
2833
3021
  return true;
2834
3022
  }
2835
3023
 
2836
3024
  document.addEventListener('click', function(e) {
2837
- if (currentInputMethod !== 'pinpoint') return;
3025
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
2838
3026
  // Real placed markers (and any other viewer overlay) own their clicks —
2839
3027
  // checked by IDENTITY, not selector, so a page element spoofing
2840
3028
  // [data-plannotator-marker] stays an ordinary annotatable target.
2841
3029
  if (isViewerOverlayNode(e.target)) return;
3030
+ // A drag that ended in this click owns the surface: the drag-selection
3031
+ // pass is about to post the selected text, and pinpoint-annotating the
3032
+ // element under the pointer would clobber it (annotateElement rewrites
3033
+ // the selection). Armed at mouseup only when the drag actually produced
3034
+ // a selection — a drifted click arms nothing and pins normally below —
3035
+ // and mousedown clears a prior pin's leftover selection, so stale
3036
+ // selection state never blocks the next plain re-pin click. One-shot:
3037
+ // only the drag's own trailing click is suppressed.
3038
+ if (dragEndedClick) { dragEndedClick = false; return; }
2842
3039
  // Shift-click while an ARMED pinpoint draft is open: toggle the element
2843
3040
  // in/out of the SAME draft comment instead of replacing the selection.
2844
3041
  // Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
@@ -2865,24 +3062,51 @@ export const BRIDGE_SCRIPT = `(function() {
2865
3062
  annotateElement(el, undefined, true, { x: e.clientX, y: e.clientY });
2866
3063
  }, true);
2867
3064
 
2868
- // Escape while pinpointing (outside vim, which has its own ladder): cancel a
2869
- // pending pin, else just drop the hover outline.
3065
+ // Escape ladder (outside vim, which has its own): a pending draft closes
3066
+ // first, then the hover outline clears, then Esc EXITS Annotate back to
3067
+ // Interact — the parent owns the mode, so the final rung only posts
3068
+ // annotate-exit and waits for set-annotate-mode to come back down.
3069
+ function closePendingDraft() {
3070
+ postToParent({ type: PREFIX + 'selection-clear' });
3071
+ pendingSelection = null;
3072
+ pendingRange = null;
3073
+ skipNextClear = false;
3074
+ clearMultiTargets();
3075
+ clearPendingPin();
3076
+ window.getSelection().removeAllRanges();
3077
+ renderAnnotationOverlay();
3078
+ }
2870
3079
  document.addEventListener('keydown', function(e) {
2871
3080
  if (e.key !== 'Escape' || vimEnabled) return;
3081
+ if (!annotateModeActive) {
3082
+ // Interact mode: Esc belongs to the page — except an open drag-comment
3083
+ // draft (drag-selection stays live in Interact) still closes first.
3084
+ if (pendingSelection) closePendingDraft();
3085
+ return;
3086
+ }
2872
3087
  if (pendingSelection) {
2873
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
2874
- pendingSelection = null;
2875
- pendingRange = null;
2876
- skipNextClear = false;
2877
- clearMultiTargets();
2878
- clearPendingPin();
2879
- window.getSelection().removeAllRanges();
2880
- renderAnnotationOverlay();
2881
- } else if (currentInputMethod === 'pinpoint') {
2882
- clearPinpointHover();
3088
+ closePendingDraft();
3089
+ } else {
3090
+ // Hover-clear is NOT a rung: the hover outline is a pointer
3091
+ // affordance, not a state the user perceives as a step, so clearing
3092
+ // it and exiting to Interact happen on the SAME press. Only an open
3093
+ // draft earns its own press.
3094
+ if (currentInputMethod === 'pinpoint' && pinpointHover) clearPinpointHover();
3095
+ postToParent({ type: PREFIX + 'annotate-exit' });
2883
3096
  }
2884
3097
  });
2885
3098
 
3099
+ // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
3100
+ // registers the same chord, but focus usually lives in here on live apps).
3101
+ // Capture phase so the page cannot swallow the reserved chord; the parent
3102
+ // answers with set-annotate-mode.
3103
+ document.addEventListener('keydown', function(e) {
3104
+ if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3105
+ if (e.key !== 'a' && e.key !== 'A') return;
3106
+ e.preventDefault();
3107
+ postToParent({ type: PREFIX + 'annotate-toggle' });
3108
+ }, true);
3109
+
2886
3110
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
2887
3111
  // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
2888
3112
  // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
@@ -2890,6 +3114,7 @@ export const BRIDGE_SCRIPT = `(function() {
2890
3114
  // a committed highlight selects the annotation instead (the pre-overlay
2891
3115
  // handler deferred to '.annotation-highlight' the same way).
2892
3116
  document.addEventListener('click', function(e) {
3117
+ if (!annotateModeActive) return; // Interact mode: [data-annotate] stays a page element
2893
3118
  if (currentInputMethod === 'pinpoint') return; // pinpoint handler covers this
2894
3119
  if (isViewerOverlayNode(e.target)) return; // placed markers own their clicks
2895
3120
  var t = e.target && e.target.closest && e.target.closest('[data-annotate]');
@@ -2933,6 +3158,10 @@ export const BRIDGE_SCRIPT = `(function() {
2933
3158
  }
2934
3159
 
2935
3160
  document.addEventListener('click', function(e) {
3161
+ // Interact mode: highlight rects are pointer-transparent projections, so a
3162
+ // page click landing on one goes to the page. Markers (real buttons) stay
3163
+ // the affordance for opening a committed comment in Interact.
3164
+ if (!annotateModeActive) return;
2936
3165
  if (e.shiftKey) return; // shift belongs to multi-select
2937
3166
  if (isViewerOverlayNode(e.target)) return; // markers own their clicks
2938
3167
  if (pendingPinEl) return; // an open pinpoint draft owns the surface
@@ -2941,7 +3170,7 @@ export const BRIDGE_SCRIPT = `(function() {
2941
3170
  var hitId = committedHighlightAt(e.clientX, e.clientY);
2942
3171
  if (!hitId) return;
2943
3172
  e.stopPropagation();
2944
- parent.postMessage({ type: PREFIX + 'mark-click', id: hitId }, '*');
3173
+ postToParent({ type: PREFIX + 'mark-click', id: hitId });
2945
3174
  });
2946
3175
 
2947
3176
  // --- Optional Vim navigation ---
@@ -3655,10 +3884,10 @@ export const BRIDGE_SCRIPT = `(function() {
3655
3884
  if (!vimEnabled) return;
3656
3885
  if (vimHudEnabled && vimLastPostedPhase !== vimPhase) {
3657
3886
  vimLastPostedPhase = vimPhase;
3658
- parent.postMessage({
3887
+ postToParent({
3659
3888
  type: PREFIX + 'vim-state',
3660
3889
  phase: vimPhase
3661
- }, '*');
3890
+ });
3662
3891
  }
3663
3892
  var badge = document.querySelector('[data-plannotator-vim-badge]');
3664
3893
  if (!vimHudEnabled && !badge) badge = getVimBadgeEl();
@@ -3720,10 +3949,10 @@ export const BRIDGE_SCRIPT = `(function() {
3720
3949
 
3721
3950
  function toggleVimHelp() {
3722
3951
  vimHelpOpen = !vimHelpOpen;
3723
- parent.postMessage({
3952
+ postToParent({
3724
3953
  type: PREFIX + 'vim-help',
3725
3954
  open: vimHelpOpen
3726
- }, '*');
3955
+ });
3727
3956
  }
3728
3957
 
3729
3958
  function clearVimUi() {
@@ -3743,10 +3972,10 @@ export const BRIDGE_SCRIPT = `(function() {
3743
3972
 
3744
3973
  function copyVimText(text) {
3745
3974
  if (!text) return;
3746
- parent.postMessage({
3975
+ postToParent({
3747
3976
  type: PREFIX + 'vim-copy',
3748
3977
  text: text
3749
- }, '*');
3978
+ });
3750
3979
  }
3751
3980
 
3752
3981
  function vimActionMode(key) {
@@ -3808,7 +4037,7 @@ export const BRIDGE_SCRIPT = `(function() {
3808
4037
  handled = true;
3809
4038
  } else if (key === 'Escape') {
3810
4039
  if (pendingSelection) {
3811
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
4040
+ postToParent({ type: PREFIX + 'selection-clear' });
3812
4041
  pendingSelection = null;
3813
4042
  pendingRange = null;
3814
4043
  restoreVimSemanticTarget();
@@ -4025,12 +4254,12 @@ export const BRIDGE_SCRIPT = `(function() {
4025
4254
  vimLastActionId = vimActionId;
4026
4255
  vimLastActionContext = vimCommandContext;
4027
4256
  updateVimReticle();
4028
- parent.postMessage({
4257
+ postToParent({
4029
4258
  type: PREFIX + 'vim-command',
4030
4259
  actionId: vimActionId,
4031
4260
  key: hudKey,
4032
4261
  context: vimCommandContext
4033
- }, '*');
4262
+ });
4034
4263
  }
4035
4264
  e.preventDefault();
4036
4265
  e.stopImmediatePropagation();
@@ -4064,7 +4293,7 @@ export const BRIDGE_SCRIPT = `(function() {
4064
4293
  if (e.metaKey || e.ctrlKey || e.altKey) return;
4065
4294
  if (!e.key || e.key.length !== 1) return; // single printable char only
4066
4295
  e.preventDefault();
4067
- parent.postMessage({ type: PREFIX + 'keytype', key: e.key }, '*');
4296
+ postToParent({ type: PREFIX + 'keytype', key: e.key });
4068
4297
  // Hand keyboard focus back to the parent window so the comment textarea can
4069
4298
  // take it. Blurring the <iframe> from the parent isn't enough — the inner
4070
4299
  // document keeps focus — so the iframe must relinquish it. parent.focus() is
@@ -4305,7 +4534,13 @@ export const BRIDGE_SCRIPT = `(function() {
4305
4534
  }).observe(document.body);
4306
4535
  }
4307
4536
  watchPageMutations();
4308
- parent.postMessage({ type: PREFIX + 'ready' }, '*');
4537
+ // Armed is the default on both surfaces, and live sessions default to
4538
+ // pinpoint: show the cursor affordance immediately instead of waiting for
4539
+ // the parent's first set-input-method/set-annotate-mode round trip.
4540
+ updatePinpointCursor();
4541
+ var readyMsg = { type: PREFIX + 'ready' };
4542
+ if (LIVE) readyMsg.pageUrl = currentPageUrl();
4543
+ postToParent(readyMsg);
4309
4544
  }
4310
4545
  if (document.readyState === 'loading') {
4311
4546
  document.addEventListener('DOMContentLoaded', onReady);
@@ -4333,3 +4568,22 @@ export const BRIDGE_SCRIPT = `(function() {
4333
4568
  }
4334
4569
  };
4335
4570
  })();`;
4571
+
4572
+ /**
4573
+ * Live-mode bootstrap, prepended to BRIDGE_SCRIPT by the annotate server when
4574
+ * composing the proxy-served bridge body. Reads the JSON config prelude
4575
+ * (window.__plannotatorLiveConfig) and installs the annotation CSS that srcdoc
4576
+ * mode splices as a <style> tag. Runs before the bridge IIFE and before its
4577
+ * MutationObserver exists, so this write never feeds the reconcile loop.
4578
+ * Same escaping rules as BRIDGE_SCRIPT: a dependency-free string constant.
4579
+ */
4580
+ export const LIVE_BRIDGE_BOOTSTRAP = `(function() {
4581
+ var config = window.__plannotatorLiveConfig;
4582
+ if (!config || typeof config.css !== 'string') return;
4583
+ try {
4584
+ var style = document.createElement('style');
4585
+ style.setAttribute('data-plannotator-live-css', '');
4586
+ style.appendChild(document.createTextNode(config.css));
4587
+ (document.head || document.documentElement).appendChild(style);
4588
+ } catch (ex) {}
4589
+ })();`;