@plannotator/ui 0.52.1 → 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.
- package/HANDOFF.md +231 -27
- package/README.md +29 -4
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +189 -48
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- 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
|
+
}
|
package/utils/agentClosed.ts
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
}
|
package/utils/aiProvider.ts
CHANGED
|
@@ -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
|
+
}
|
package/utils/blockTargeting.ts
CHANGED
|
@@ -247,11 +247,22 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget
|
|
|
247
247
|
continue;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
|
|
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>(
|
|
254
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
453
|
-
//
|
|
454
|
-
//
|
|
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
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|