@plannotator/ui 0.52.1 → 0.53.1
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 +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- 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/MentionPicker.tsx +51 -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 +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- 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/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- 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/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- 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/mentions.ts +34 -5
- 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/mentions.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Nothing here touches the DOM, fetches, or persists. A composer with no
|
|
12
12
|
* `mentionSource` never calls any of it.
|
|
13
13
|
*/
|
|
14
|
+
import type { ReactNode } from 'react';
|
|
14
15
|
|
|
15
16
|
/** One pickable identity, supplied by the host. */
|
|
16
17
|
export interface MentionPerson {
|
|
@@ -32,6 +33,13 @@ export interface MentionPerson {
|
|
|
32
33
|
* supplied `onPickBlocked` (see `MentionSource`).
|
|
33
34
|
*/
|
|
34
35
|
readonly canOpen: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Optional group heading: consecutive people with the same
|
|
38
|
+
* `group` are drawn under one heading, as a `role="group"` labelled by it
|
|
39
|
+
* (the Inbox groups agents by kind). Absent on every person → the flat
|
|
40
|
+
* list, exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
readonly group?: string;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** The opt-in `@` mention source for `CommentPopover`'s composer. */
|
|
@@ -61,6 +69,24 @@ export interface MentionSource {
|
|
|
61
69
|
readonly tokenClassName?: string;
|
|
62
70
|
/** Fires on every text change with the ids whose token still survives in the body. */
|
|
63
71
|
readonly onMentionsChange?: (ids: readonly string[]) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Which kinds of people this composer offers. Absent → users only,
|
|
74
|
+
* as before (an agent is not taggable in a host's comments by default). A
|
|
75
|
+
* host whose agents can be tagged (the Inbox) passes `['agent']` or both.
|
|
76
|
+
*/
|
|
77
|
+
readonly kinds?: readonly MentionPerson['kind'][];
|
|
78
|
+
/**
|
|
79
|
+
* Whether a query also matches the `detail` line. Absent → true, as
|
|
80
|
+
* before. A host whose detail is incidental (the Inbox's "billing-api ·
|
|
81
|
+
* idle") passes false, so `@p` finds Pi, not every row with a "p" in it.
|
|
82
|
+
*/
|
|
83
|
+
readonly matchDetail?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Optional line drawn under the composer field, from the people
|
|
86
|
+
* the draft still tags: a host's "Goes to …" and heads-up. Absent → nothing
|
|
87
|
+
* is drawn, as before.
|
|
88
|
+
*/
|
|
89
|
+
readonly renderNotice?: (tagged: readonly MentionPerson[]) => ReactNode;
|
|
64
90
|
/**
|
|
65
91
|
* A `canOpen: false` person was picked. When supplied, NOTHING is inserted
|
|
66
92
|
* and the host shows its own no-access affordance. When absent, such a
|
|
@@ -115,24 +141,27 @@ export function mentionTrigger(text: string, caret: number): MentionTrigger | nu
|
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
/**
|
|
118
|
-
* The people this trigger offers: users only (an agent is not
|
|
119
|
-
*
|
|
120
|
-
*
|
|
144
|
+
* The people this trigger offers: users only by default (an agent is not
|
|
145
|
+
* taggable in a comment unless the host says so with `kinds`), never one
|
|
146
|
+
* already tagged in this draft, de-duplicated by id, and filtered on label or
|
|
147
|
+
* detail once a query character exists.
|
|
121
148
|
*/
|
|
122
149
|
export function mentionMatches(
|
|
123
150
|
people: readonly MentionPerson[],
|
|
124
151
|
trigger: MentionTrigger,
|
|
125
152
|
alreadyTagged: ReadonlySet<string>,
|
|
153
|
+
kinds: readonly MentionPerson['kind'][] = ['user'],
|
|
154
|
+
matchDetail = true,
|
|
126
155
|
): readonly MentionPerson[] {
|
|
127
156
|
const seen = new Set<string>();
|
|
128
157
|
return people.filter((person) => {
|
|
129
|
-
if (person.kind
|
|
158
|
+
if (!kinds.includes(person.kind)) return false;
|
|
130
159
|
if (alreadyTagged.has(person.id) || seen.has(person.id)) return false;
|
|
131
160
|
seen.add(person.id);
|
|
132
161
|
if (trigger.query === '') return true;
|
|
133
162
|
return (
|
|
134
163
|
person.label.toLowerCase().includes(trigger.query) ||
|
|
135
|
-
(person.detail ?? '').toLowerCase().includes(trigger.query)
|
|
164
|
+
(matchDetail && (person.detail ?? '').toLowerCase().includes(trigger.query))
|
|
136
165
|
);
|
|
137
166
|
});
|
|
138
167
|
}
|
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 => {
|