@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
package/types.ts CHANGED
@@ -386,6 +386,28 @@ export interface CodeAnnotation {
386
386
  * inline on the current diff and never posted as an inline PR comment.
387
387
  */
388
388
  outdated?: boolean;
389
+ /**
390
+ * Code review only: a comment made by selecting text in a Markdown file's
391
+ * rendered preview. Always file-scoped (`scope: 'file'`); `lineStart` /
392
+ * `lineEnd` are the file-comment sentinels, not source lines. Exported with
393
+ * the quoted text; in PR mode it goes into the review body, never inline.
394
+ * Additive: annotations without it behave exactly as before.
395
+ */
396
+ markdownPreview?: MarkdownPreviewAnchor;
397
+ }
398
+
399
+ /** Where a comment made in a rendered Markdown preview points (see `CodeAnnotation.markdownPreview`). */
400
+ export interface MarkdownPreviewAnchor {
401
+ /** The rendered text the reviewer selected. */
402
+ originalText: string;
403
+ /** The preview view it was made in: the rendered diff or the whole new version. */
404
+ view: 'diff' | 'whole';
405
+ /** In the rendered diff: the kind of section the selection started in. */
406
+ diffContext?: 'added' | 'removed' | 'modified' | 'unchanged';
407
+ /** web-highlighter positions, valid only in the view it was made in;
408
+ * other views re-anchor by text. */
409
+ startMeta?: AnnotationTextMeta;
410
+ endMeta?: AnnotationTextMeta;
389
411
  }
390
412
 
391
413
  /** Token-level metadata passed from selection to annotation creation. */
@@ -520,3 +542,29 @@ export type {
520
542
  MentionSource,
521
543
  MentionTrigger,
522
544
  } from './utils/mentions';
545
+
546
+ /**
547
+ * The composer handed to the host (opt-in `onHostDraft` on `Viewer` and
548
+ * `HtmlViewer`; Plannotator supplies neither). With it a viewer draws no
549
+ * toolbar or composer of its own: it reports what a comment would be on, and
550
+ * the host writes the comment, saves it, and draws it as saved through
551
+ * `annotations` or `applySharedAnnotations`.
552
+ */
553
+ export interface HostDraft {
554
+ /** The annotation a comment here would carry, with an empty `text`. */
555
+ annotation: Annotation;
556
+ /**
557
+ * `'selection'`: a text selection settled; the host offers its own Comment
558
+ * (a phone's edit menu). `'compose'`: open the composer now (a pinpoint
559
+ * tap, Comment mode, an HTML pin).
560
+ */
561
+ intent: 'selection' | 'compose';
562
+ /** What it is on, as a composer names it (Plannotator's pinpoint label: "Button", a heading's words). Absent for text. */
563
+ label?: string;
564
+ /**
565
+ * Drop the draft: its pending highlight or pin goes. The host calls it when
566
+ * the person cancels, and after the save, before it draws the saved
567
+ * annotation. A draft already replaced by a newer one ignores it.
568
+ */
569
+ cancel: () => void;
570
+ }
@@ -31,3 +31,29 @@ export function agentClosedSubtitle(unsentAnnotations: number, surface: AgentClo
31
31
  const comments = unsentAnnotations === 1 ? 'Your comment is' : `Your ${unsentAnnotations} comments are`;
32
32
  return `The agent closed this review. ${comments} saved as a draft. ${RESTORE[surface]}`;
33
33
  }
34
+
35
+ /**
36
+ * Copy for a plan review closed because plan mode or the agent session ended
37
+ * (`session-closed` with `by: "session"`, Pi's plan review), not because the
38
+ * agent asked to close it. Neutral on purpose: it also runs on /reload and
39
+ * session switches, after which plan mode may come back.
40
+ */
41
+ export const SESSION_CLOSED_TITLE = 'Review Closed';
42
+
43
+ export function sessionClosedSubtitle(unsentAnnotations: number): string {
44
+ const ended = 'The agent session that opened this plan review ended.';
45
+ if (unsentAnnotations <= 0) return ended;
46
+ const comments = unsentAnnotations === 1 ? 'Your comment is' : `Your ${unsentAnnotations} comments are`;
47
+ return `${ended} ${comments} saved.`;
48
+ }
49
+
50
+ /** Title and subtitle for a closed review, by who closed it. */
51
+ export function closedReviewCopy(
52
+ closed: { unsentAnnotations: number; by?: 'agent' | 'session' },
53
+ surface: AgentClosedSurface = 'document',
54
+ ): { title: string; subtitle: string } {
55
+ if (closed.by === 'session') {
56
+ return { title: SESSION_CLOSED_TITLE, subtitle: sessionClosedSubtitle(closed.unsentAnnotations) };
57
+ }
58
+ return { title: AGENT_CLOSED_TITLE, subtitle: agentClosedSubtitle(closed.unsentAnnotations, surface) };
59
+ }
@@ -4,13 +4,16 @@
4
4
  * the POST /api/config write that changes it.
5
5
  *
6
6
  * Only a Plannotator server that knows the session's tool host sends
7
- * `agentToolHost` (the Claude Code mod, Pi, OpenCode 2). Everything else, an
7
+ * `agentToolHost` (the Claude Code mod, Pi, Oh My Pi, OpenCode 2). Everything else, an
8
8
  * older server, OpenCode 1, Codex, a `@plannotator/ui` host such as
9
9
  * Workspaces, parses to undefined, and no toggle or offer is shown.
10
10
  */
11
11
 
12
- /** The hosts that register the `plannotator` agent tool. */
13
- export type AgentToolHost = 'claude-code' | 'pi' | 'opencode';
12
+ /**
13
+ * The hosts that register the `plannotator` agent tool. `oh-my-pi` is the Pi
14
+ * extension running inside Oh My Pi, where the tool is on by default.
15
+ */
16
+ export type AgentToolHost = 'claude-code' | 'pi' | 'oh-my-pi' | 'opencode';
14
17
 
15
18
  export interface AgentToolSetting {
16
19
  readonly host: AgentToolHost;
@@ -23,7 +26,7 @@ export interface AgentToolSetting {
23
26
  }
24
27
 
25
28
  function isAgentToolHost(value: unknown): value is AgentToolHost {
26
- return value === 'claude-code' || value === 'pi' || value === 'opencode';
29
+ return value === 'claude-code' || value === 'pi' || value === 'oh-my-pi' || value === 'opencode';
27
30
  }
28
31
 
29
32
  /** Reads the agent tool fields out of a `serverConfig` payload, or undefined when the server reports no tool host. */
@@ -79,6 +82,7 @@ export async function saveAgentToolSetting(
79
82
  const HOST_NAMES: Record<AgentToolHost, string> = {
80
83
  'claude-code': 'Claude Code',
81
84
  pi: 'Pi',
85
+ 'oh-my-pi': 'Oh My Pi',
82
86
  opencode: 'OpenCode',
83
87
  };
84
88
 
@@ -90,7 +94,8 @@ export function agentToolHostName(host: AgentToolHost): string {
90
94
  * When a change takes effect on this host: the tool list is part of the
91
95
  * model's prompt, so each host reads the setting once. Pi reads it per
92
96
  * session; the Claude Code mod and the OpenCode plugin decide once per
93
- * process (when the module or plugin loads), so those take a restart.
97
+ * process (when the module or plugin loads), so those take a restart, and so
98
+ * does Oh My Pi, which may keep the extension loaded across its sessions.
94
99
  */
95
100
  export function agentToolAppliesWhen(host: AgentToolHost): string {
96
101
  return host === 'pi' ? 'the next Pi session' : `the next time ${HOST_NAMES[host]} starts`;
@@ -99,10 +104,12 @@ export function agentToolAppliesWhen(host: AgentToolHost): string {
99
104
  /**
100
105
  * What the tool costs in context. Pi and OpenCode send its full definition
101
106
  * with every request; Claude Code defers it behind tool search, so it costs
102
- * about its name until it is used.
107
+ * about its name until it is used; Oh My Pi lists it as one line of its
108
+ * system prompt (about 210 characters, measured on OMP 18.10) and loads the
109
+ * schema only when the model reads it.
103
110
  */
104
111
  export function agentToolCostNote(host: AgentToolHost): string {
105
- return host === 'claude-code'
106
- ? 'Claude Code loads it only when needed.'
107
- : 'Adds about 780 tokens to every request.';
112
+ if (host === 'claude-code') return 'Claude Code loads it only when needed.';
113
+ if (host === 'oh-my-pi') return 'Oh My Pi lists it in one short line (about 50 tokens) and loads the rest only when needed.';
114
+ return 'Adds about 780 tokens to every request.';
108
115
  }
@@ -38,6 +38,13 @@ export interface SessionBridgeInfo {
38
38
  host: string;
39
39
  status: 'ready' | 'busy' | 'blocked' | 'gone';
40
40
  modes: { turn: boolean; transient: boolean };
41
+ /** `false` when the host never interrupts the session (Stop only stops listening). Absent = it can. */
42
+ interruptible?: boolean;
43
+ }
44
+
45
+ /** Whether any listed "Ask this session" provider says it cannot interrupt its session. */
46
+ export function hasNonInterruptibleSession(providers: readonly { sessionBridge?: SessionBridgeInfo }[]): boolean {
47
+ return providers.some((provider) => provider.sessionBridge?.interruptible === false);
41
48
  }
42
49
 
43
50
  /** Error codes "Ask this session" answers with that the panel turns into actions. */
@@ -0,0 +1,27 @@
1
+ import type { Origin } from '@plannotator/core/agents';
2
+ import { isSessionBridgeProvider, type AIProviderOption } from './aiProvider';
3
+
4
+ /**
5
+ * The command that installs the Plannotator extension into Oh My Pi. OMP loads
6
+ * Pi extensions as OMP plugins; this is the one place the UI names it.
7
+ */
8
+ export const OMP_EXTENSION_INSTALL_COMMAND = 'omp plugin install @plannotator/pi-extension';
9
+
10
+ /**
11
+ * A one-line note for Ask AI when the agent that opened Plannotator COULD
12
+ * answer "Ask this session" but this session has no bridge to it (#1814).
13
+ *
14
+ * Only Oh My Pi qualifies today: origin `oh-my-pi` means the CLI ran from
15
+ * OMP's shell (OMP exports `OMPCODE` to its bash children), which never has a
16
+ * bridge, while the Plannotator extension loaded into OMP does. Every other
17
+ * origin without a bridge either has no way to get one or is already covered,
18
+ * so it gets no note. A session that has a bridge never shows it.
19
+ */
20
+ export function askSessionSetupHint(
21
+ origin: Origin | null | undefined,
22
+ providers: ReadonlyArray<Pick<AIProviderOption, 'name' | 'sessionBridge'>>,
23
+ ): string | null {
24
+ if (origin !== 'oh-my-pi') return null;
25
+ if (providers.some(isSessionBridgeProvider)) return null;
26
+ return `To ask your Oh My Pi session instead, install the Plannotator extension (${OMP_EXTENSION_INSTALL_COMMAND}) and open reviews with its /plannotator-* commands.`;
27
+ }
@@ -247,11 +247,22 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget
247
247
  continue;
248
248
  }
249
249
 
250
- const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
250
+ // A quote is one text block even when it holds a code fence or a table:
251
+ // its fence is not a code-block target (whose click would annotate only
252
+ // the fence), and its table rows are not cells of the quote.
253
+ const isQuote = block.tagName === 'BLOCKQUOTE';
254
+ const codeElement = isQuote ? null : block.querySelector<HTMLElement>('pre > code.pn-code');
255
+ // Inline math in a quote keeps the paragraph rule (the quote targets the
256
+ // formula, as a paragraph with inline math does); a display-math block
257
+ // inside a quote is not the quote.
251
258
  const mathElement = block.matches('.math-annotatable,[data-math-tex]')
252
259
  ? block
253
- : block.querySelector<HTMLElement>('.math-annotatable,[data-math-tex]');
254
- const table = block.querySelector<HTMLTableElement>('table');
260
+ : block.querySelector<HTMLElement>(
261
+ isQuote
262
+ ? '.math-annotatable:not([data-math-display="true"]),[data-math-tex]:not([data-math-display="true"])'
263
+ : '.math-annotatable,[data-math-tex]',
264
+ );
265
+ const table = isQuote ? null : block.querySelector<HTMLTableElement>('table');
255
266
 
256
267
  if (codeElement) {
257
268
  const target: SemanticTarget = {
package/utils/parser.ts CHANGED
@@ -382,10 +382,13 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
382
382
  const prevLineWasBlank = lastLineWasBlank;
383
383
  lastLineWasBlank = false;
384
384
 
385
- // Headings
386
- if (trimmed.startsWith('#')) {
385
+ // Headings: one to six `#` followed by a space or the end of the line
386
+ // (CommonMark ATX). `#123 fixed`, `#hashtag` and `#!/bin/sh` are text,
387
+ // so an issue ref that starts a line keeps its link.
388
+ const headingMatch = trimmed.match(/^(#{1,6})(?:\s|$)/);
389
+ if (headingMatch) {
387
390
  flush();
388
- const level = trimmed.match(/^#+/)?.[0].length || 1;
391
+ const level = headingMatch[1].length;
389
392
  blocks.push({
390
393
  id: `block-${currentId++}`,
391
394
  type: 'heading',
@@ -449,62 +452,63 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
449
452
  continue;
450
453
  }
451
454
 
452
- // Blockquotes — consecutive `>` lines merge into one block so wrapped
453
- // paragraph quotes render as a single continuous quote box. A blank line
454
- // breaks the blockquote so the next `>` starts a fresh one.
455
+ // Blockquotes: a run of consecutive `>` lines (blank `>` lines included)
456
+ // is ONE quote block, the way GitHub renders it: one quote, one bar. A
457
+ // truly blank line ends the run, so the next `>` starts a fresh quote; a
458
+ // line without `>` is not a lazy continuation (it starts a paragraph).
455
459
  //
456
- // Exception: if the stripped content starts with a block-level marker
457
- // (list item, heading, code fence, nested blockquote) we do NOT merge.
458
- // Our flat block model can't render a list-inside-a-quote as an actual
459
- // nested list, so merging would flatten the markers into run-on inline
460
- // text. Leaving them as separate blockquote blocks preserves each line's
461
- // visual identity (a stacked-box layout) — imperfect but legible. A
462
- // proper recursive blockquote parser is tracked as a follow-up.
460
+ // `content` is the quote's own markdown: each line with its `>` marker and
461
+ // one following space or tab removed (deeper indentation is kept, so a
462
+ // fenced code block or a nested list inside the quote keeps its shape) and
463
+ // trailing whitespace trimmed, one content line per source line. Blank
464
+ // lines at the quote's edges are dropped (`startLine` moves past leading
465
+ // ones), so the block's line span is the quote's text. The renderer
466
+ // (`BlockquoteBlock`) parses `content` again with this function and draws
467
+ // the inner blocks (paragraphs, code fences, lists, nested quotes) inside
468
+ // the one quote, while the quote stays a single annotatable block with a
469
+ // single `data-block-id`.
470
+ //
471
+ // GitHub alerts (`> [!KIND]` as the run's first line) are unchanged: the
472
+ // marker line tags the block, and the body is every later line fully
473
+ // `>`-stripped and trimmed, empty leading lines skipped, exactly as the
474
+ // line-by-line merge produced it. AlertBlock renders it.
463
475
  if (trimmed.startsWith('>')) {
464
476
  flush();
465
- const stripped = trimmed.replace(/^>\s*/, '');
466
- // List markers require trailing whitespace to avoid matching inline
467
- // text like "-hyphen" or "1.5 seconds"; headings, code fences, and
468
- // nested blockquote markers don't require it (``` can be followed
469
- // directly by a language tag, # can start a dense heading).
470
- const blockMarkerRe = /^(?:(?:\*|-|\d+\.)\s|#|```|>)/;
471
- const hasBlockMarker = blockMarkerRe.test(stripped);
472
- const prevBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
473
- // Don't merge into a previous blockquote whose content itself starts
474
- // with a block marker — otherwise a `> some text` line following a
475
- // `> 1. item` line would get glued onto the list-item block.
476
- const prevIsMarkerQuote =
477
- prevBlock?.type === 'blockquote' && blockMarkerRe.test(prevBlock.content);
478
- // Alerts own their body: once a blockquote is tagged as an alert,
479
- // subsequent `>` lines always merge into it (until a blank line).
480
- // Without this, `> [!NOTE]\n> - item` splits the list item off into
481
- // a separate plain quote, losing the callout.
482
- const prevIsAlert = prevBlock?.type === 'blockquote' && !!prevBlock.alertKind;
483
- const shouldMergeIntoAlert = prevIsAlert && !prevLineWasBlank;
484
- const shouldMergeNormal =
485
- !hasBlockMarker &&
486
- !prevIsMarkerQuote &&
487
- !prevLineWasBlank &&
488
- prevBlock?.type === 'blockquote';
489
- if (shouldMergeIntoAlert || shouldMergeNormal) {
490
- prevBlock!.content = prevBlock!.content
491
- ? prevBlock!.content + '\n' + stripped
492
- : stripped;
493
- } else {
494
- // GitHub alert marker: a blockquote whose first line is [!KIND].
495
- // We strip the marker from content and tag the block; rendering decides the style.
496
- const alertMatch = stripped.match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
477
+ let runEnd = i;
478
+ while (runEnd + 1 < lines.length && lines[runEnd + 1].trim().startsWith('>')) runEnd++;
479
+ const run = lines.slice(i, runEnd + 1);
480
+ const quoteStartLine = currentLineNum;
481
+ i = runEnd;
482
+ const alertMatch = trimmed
483
+ .replace(/^>\s*/, '')
484
+ .match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
485
+ if (alertMatch) {
486
+ const body = run.slice(1).reduce((acc, raw) => {
487
+ const stripped = raw.trim().replace(/^>\s*/, '');
488
+ return acc ? acc + '\n' + stripped : stripped;
489
+ }, '');
497
490
  blocks.push({
498
491
  id: `block-${currentId++}`,
499
492
  type: 'blockquote',
500
- content: alertMatch ? '' : stripped,
501
- alertKind: alertMatch
502
- ? (alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important')
503
- : undefined,
493
+ content: body,
494
+ alertKind: alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important',
504
495
  order: currentId,
505
- startLine: currentLineNum
496
+ startLine: quoteStartLine,
506
497
  });
498
+ continue;
507
499
  }
500
+ const inner = run.map((raw) => raw.replace(/^\s*>[ \t]?/, '').trimEnd());
501
+ let first = 0;
502
+ while (first < inner.length - 1 && inner[first] === '') first++;
503
+ let last = inner.length - 1;
504
+ while (last > first && inner[last] === '') last--;
505
+ blocks.push({
506
+ id: `block-${currentId++}`,
507
+ type: 'blockquote',
508
+ content: inner.slice(first, last + 1).join('\n'),
509
+ order: currentId,
510
+ startLine: quoteStartLine + first,
511
+ });
508
512
  continue;
509
513
  }
510
514
 
@@ -1024,7 +1028,17 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
1024
1028
  * a malformed anchor (an older or foreign writer) is skipped, never thrown. */
1025
1029
  const diagramLocationExportLine = (ann: any): string => {
1026
1030
  const anchor = ann?.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
1027
- return anchor === null ? '' : `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
1031
+ if (anchor !== null) return `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
1032
+ return hostLocationExportLine(ann);
1033
+ };
1034
+
1035
+ /** A host's own location line for an entry (the Inbox's "At 1:42 (102.4 s of
1036
+ * 7:41)" on a video comment): `exportLocation`, a string the HOST sets on the
1037
+ * copy it hands the exporter, never stored and never set by Plannotator, so
1038
+ * every other annotation's output is byte-identical. One line, capped. */
1039
+ const hostLocationExportLine = (ann: any): string => {
1040
+ const line = ann?.exportLocation;
1041
+ return typeof line === 'string' && line.trim() !== '' ? `${safeInline(line, 300)}\n` : '';
1028
1042
  };
1029
1043
 
1030
1044
  const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Plain-text paste into our own text fields, inserted by the page.
3
+ *
4
+ * Works around a Chrome 155 to 156 bug on macOS, and runs only there
5
+ * (`chromiumPasteBugAffected`). Remove after Chrome 157 is the stable channel
6
+ * (it ships the fix).
7
+ *
8
+ * Chrome 155 added a check after the `paste` event (Chromium CL 8217390,
9
+ * commit 4f9d868279, "Ensure default paste behavior doesn't leak changed
10
+ * clipboard content"): the browser's own insertion is skipped when the
11
+ * clipboard's sequence token changed while the event ran. On macOS an older
12
+ * race in ClipboardMac can give the same NSPasteboard changeCount a second
13
+ * token when a paste starts right after an app wrote the pasteboard and
14
+ * posted a synthetic ⌘V. Dictation apps (Hex) and text expanders do exactly
15
+ * that. The `paste` event still carries the right `text/plain`, but
16
+ * `beforeinput` arrives with empty data and nothing is inserted: the first
17
+ * dictation after a page load lands, later ones vanish. Fixed in Chromium
18
+ * main (CL 8503629, commit fc5529f522, crbug 568453825), not before 157.
19
+ *
20
+ * So, for a paste into a plain-text field we own (a textarea or a text-like
21
+ * input) that nothing else handled, that carries no files and has text, the
22
+ * page cancels the browser's insertion and inserts the text itself with
23
+ * `execCommand('insertText')` (keeps undo, fires `input`, so React's onChange
24
+ * runs), else `setRangeText` plus an `input` event. That is what the browser
25
+ * would have done. It is gated to the affected engine: Safari's execCommand
26
+ * merges the insertion with the typing before it in the undo stack, so
27
+ * everywhere else the browser's own paste is kept. Only trusted (real) paste
28
+ * events are acted on.
29
+ *
30
+ * Left native: a paste that a handler already cancelled (image paste to
31
+ * attach), one that carries files (paste-to-attach keeps working; the
32
+ * browser inserts the text beside them), an empty one, a field that is
33
+ * read-only or disabled, one that is not focused, one mid IME composition,
34
+ * contenteditable and code editors (CodeMirror, Pierre's editor, the agent
35
+ * terminal's helper textarea), and a field marked `data-native-paste`.
36
+ */
37
+
38
+ /** Input types the page can insert into with a caret (email and number have no selection API). */
39
+ const TEXT_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'password']);
40
+
41
+ /** Editors that read the paste themselves through a hidden textarea or own their own model. */
42
+ const NATIVE_PASTE = '[data-native-paste], .xterm, .cm-editor, .monaco-editor';
43
+
44
+ export type PlainTextField = HTMLTextAreaElement | HTMLInputElement;
45
+
46
+ export interface PasteInsertion {
47
+ field: PlainTextField;
48
+ text: string;
49
+ start: number;
50
+ end: number;
51
+ }
52
+
53
+ type PasteLike = Pick<ClipboardEvent, 'defaultPrevented' | 'clipboardData' | 'target'> & {
54
+ composedPath?: () => EventTarget[];
55
+ };
56
+
57
+ function plainTextField(target: EventTarget | null | undefined): PlainTextField | null {
58
+ if (!target || typeof (target as Node).nodeType !== 'number') return null;
59
+ const el = target as Element;
60
+ const view = el.ownerDocument?.defaultView;
61
+ if (!view) return null;
62
+ if (el instanceof view.HTMLTextAreaElement) return el;
63
+ if (el instanceof view.HTMLInputElement && TEXT_INPUT_TYPES.has(el.type)) return el;
64
+ return null;
65
+ }
66
+
67
+ function carriesFiles(data: DataTransfer): boolean {
68
+ if ((data.files?.length ?? 0) > 0) return true;
69
+ for (const item of Array.from(data.items ?? [])) {
70
+ if (item.kind === 'file') return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ function focused(field: PlainTextField): boolean {
76
+ const root = field.getRootNode() as Document | ShadowRoot;
77
+ return 'activeElement' in root && root.activeElement === field;
78
+ }
79
+
80
+ /** Cut to `room` UTF-16 units without splitting a surrogate pair. */
81
+ function truncate(text: string, room: number): string {
82
+ if (text.length <= room) return text;
83
+ let cut = room;
84
+ const last = text.charCodeAt(cut - 1);
85
+ if (cut > 0 && last >= 0xd800 && last <= 0xdbff) cut -= 1;
86
+ return text.slice(0, cut);
87
+ }
88
+
89
+ /**
90
+ * What the page should insert for this paste, or null to leave it to the
91
+ * browser. Pure apart from reading the event and the field.
92
+ */
93
+ export function plainTextPasteInsertion(event: PasteLike, composing = false): PasteInsertion | null {
94
+ if (event.defaultPrevented || composing) return null;
95
+ const data = event.clipboardData;
96
+ if (!data) return null;
97
+ const path = event.composedPath?.() ?? [];
98
+ const field = plainTextField(path[0] ?? event.target);
99
+ if (!field) return null;
100
+ if (field.readOnly || field.disabled) return null;
101
+ if (field.closest(NATIVE_PASTE)) return null;
102
+ if (!focused(field)) return null;
103
+ if (carriesFiles(data)) return null;
104
+ let text = data.getData('text/plain');
105
+ if (!text) return null;
106
+ // What Chrome does with line breaks: a textarea keeps each as \n, a one-line input turns each into a space.
107
+ text = text.replace(/\r\n?/g, '\n');
108
+ if (field.tagName !== 'TEXTAREA') text = text.replace(/\n/g, ' ');
109
+ if (!text) return null;
110
+ const start = field.selectionStart;
111
+ const end = field.selectionEnd;
112
+ if (start === null || end === null) return null;
113
+ if (field.maxLength >= 0) {
114
+ const room = field.maxLength - (field.value.length - (end - start));
115
+ if (room <= 0) return null; // the browser inserts nothing either
116
+ text = truncate(text, room);
117
+ if (!text) return null;
118
+ }
119
+ return { field, text, start, end };
120
+ }
121
+
122
+ /** Insert as typing would: an undo step and an `input` event. */
123
+ export function insertPastedText({ field, text, start, end }: PasteInsertion): void {
124
+ const doc = field.ownerDocument;
125
+ let inserted = false;
126
+ try {
127
+ inserted = typeof doc.execCommand === 'function' && doc.execCommand('insertText', false, text) === true;
128
+ } catch {
129
+ inserted = false;
130
+ }
131
+ if (inserted) return;
132
+ field.setRangeText(text, start, end, 'end');
133
+ const caret = start + text.length;
134
+ field.setSelectionRange(caret, caret); // some engines put the caret elsewhere after setRangeText
135
+ const view = doc.defaultView;
136
+ const event =
137
+ view && typeof view.InputEvent === 'function'
138
+ ? new view.InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })
139
+ : new Event('input', { bubbles: true });
140
+ field.dispatchEvent(event);
141
+ }
142
+
143
+ /** What the gate reads off `navigator` (User-Agent Client Hints are Chromium-only). */
144
+ export interface PasteEngineNavigator {
145
+ userAgent?: string;
146
+ platform?: string;
147
+ userAgentData?: { brands?: ReadonlyArray<{ brand: string; version: string }>; platform?: string } | null;
148
+ }
149
+
150
+ /** The Chromium majors with the bug: 155 added the check, 157 ships the fix. */
151
+ const AFFECTED_CHROMIUM = new Set([155, 156]);
152
+
153
+ /**
154
+ * Only Chromium 155 and 156 on macOS have the bug, so only they get the
155
+ * workaround: it turns itself off when 157 arrives, and Safari, Firefox and
156
+ * older Chromium (VS Code's webview) keep the browser's own paste. Safari
157
+ * matters most: there `execCommand('insertText')` merges with the typing
158
+ * before it, so one ⌘Z after a paste would also erase what was typed.
159
+ * Chrome, Edge, Brave, Arc and other Chromium browsers are told by the
160
+ * "Chromium" brand of User-Agent Client Hints, else by `Chrome/<major>` in
161
+ * the UA string (Safari and Firefox carry no `Chrome/`; Chrome on iOS is
162
+ * WebKit and says `CriOS/`).
163
+ */
164
+ export function chromiumPasteBugAffected(nav: PasteEngineNavigator | undefined | null): boolean {
165
+ if (!nav) return false;
166
+ const ua = nav.userAgent ?? '';
167
+ const data = nav.userAgentData ?? null;
168
+ let major = Number.NaN;
169
+ const brand = data?.brands?.find((b) => b.brand === 'Chromium');
170
+ if (brand) major = Number.parseInt(brand.version, 10);
171
+ if (Number.isNaN(major)) {
172
+ const match = /Chrome\/(\d+)/.exec(ua);
173
+ if (match) major = Number.parseInt(match[1]!, 10);
174
+ }
175
+ if (!AFFECTED_CHROMIUM.has(major)) return false;
176
+ // Client hints name the platform when present; else navigator.platform or the UA string.
177
+ if (data?.platform) return /^mac/i.test(data.platform);
178
+ return /^mac/i.test(nav.platform ?? '') || /Macintosh|Mac OS X/.test(ua);
179
+ }
180
+
181
+ /**
182
+ * Tests (the e2e simulation, in a Chromium that is not 155 or 156) set this
183
+ * to `true` on the window to turn the workaround on whatever the engine.
184
+ */
185
+ export const FORCE_PLAIN_TEXT_PASTE_FLAG = '__plannotatorForcePlainTextPaste';
186
+
187
+ export interface PlainTextPasteOptions {
188
+ /** Whether the workaround runs. Default: the engine gate, or the window's force flag. */
189
+ enabled?: boolean;
190
+ /** Act on script-dispatched (untrusted) paste events too; for tests. Default false. */
191
+ allowUntrusted?: boolean;
192
+ }
193
+
194
+ /**
195
+ * Install on a window; returns the uninstall. Listens in the bubble phase on
196
+ * the window, so every handler on the page (React's, the image-paste ones)
197
+ * runs first and can claim the paste. Does nothing outside the affected
198
+ * engine (`chromiumPasteBugAffected`).
199
+ */
200
+ export function installPlainTextPaste(win: Window = window, options: PlainTextPasteOptions = {}): () => void {
201
+ const doc = win.document;
202
+ const engine = chromiumPasteBugAffected(win.navigator as unknown as PasteEngineNavigator);
203
+ const forced = () => (win as unknown as Record<string, unknown>)[FORCE_PLAIN_TEXT_PASTE_FLAG] === true;
204
+ const enabled = () => options.enabled ?? (engine || forced());
205
+ let composing = false;
206
+ const onCompositionStart = () => {
207
+ composing = true;
208
+ };
209
+ // Also on focus moving and the window losing focus: a compositionend that never arrives must not
210
+ // leave the workaround off until reload.
211
+ const onCompositionEnd = () => {
212
+ composing = false;
213
+ };
214
+ const onPaste = (event: ClipboardEvent) => {
215
+ if (!enabled()) return;
216
+ if (!event.isTrusted && !options.allowUntrusted) return; // a script's paste event inserts nothing
217
+ const insertion = plainTextPasteInsertion(event, composing);
218
+ if (!insertion) return;
219
+ event.preventDefault();
220
+ insertPastedText(insertion);
221
+ };
222
+ doc.addEventListener('compositionstart', onCompositionStart, true);
223
+ doc.addEventListener('compositionend', onCompositionEnd, true);
224
+ doc.addEventListener('focusin', onCompositionEnd, true);
225
+ win.addEventListener('blur', onCompositionEnd);
226
+ win.addEventListener('paste', onPaste);
227
+ return () => {
228
+ doc.removeEventListener('compositionstart', onCompositionStart, true);
229
+ doc.removeEventListener('compositionend', onCompositionEnd, true);
230
+ doc.removeEventListener('focusin', onCompositionEnd, true);
231
+ win.removeEventListener('blur', onCompositionEnd);
232
+ win.removeEventListener('paste', onPaste);
233
+ };
234
+ }