@plannotator/ui 0.41.2 → 0.43.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/types.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
2
+ import type { DiagramRenderKind } from '@plannotator/core/annotatable';
2
3
 
3
4
  export type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
5
+ export type { DiagramRenderKind } from '@plannotator/core/annotatable';
6
+
7
+ /**
8
+ * How a document's body is rendered. `markdown` and `html` are the original
9
+ * pair; the two diagram kinds are whole-file diagram sources (.mmd/.mermaid,
10
+ * .dot/.gv) that render as ONE diagram through the same engine a ```mermaid
11
+ * fence uses — see diagramDocumentBlocks in utils/parser.
12
+ */
13
+ export type DocumentRenderAs = 'markdown' | 'html' | DiagramRenderKind;
4
14
 
5
15
  export enum AnnotationType {
6
16
  DELETION = 'DELETION',
@@ -192,6 +202,16 @@ export interface Block {
192
202
  order: number; // Sorting order
193
203
  startLine: number; // 1-based line number in source
194
204
  sourceLineCount?: number; // Number of source lines consumed when it differs from content lines
205
+ /**
206
+ * Line offset a diagram comment's `sourceLine` is measured from, when it
207
+ * differs from `startLine`. A ```mermaid fence in a document has its opening
208
+ * line ABOVE the diagram's first line, so `startLine` is the right offset
209
+ * there and this stays unset. A whole-file diagram source (.mmd/.dot) has no
210
+ * fence: its first line IS document line 1, so it sets 0 here while
211
+ * `startLine` keeps naming the block's own first line for the export's
212
+ * `(lines a–b)` label.
213
+ */
214
+ diagramSourceLineOffset?: number;
195
215
  }
196
216
 
197
217
  export interface DiffResult {
@@ -430,3 +450,15 @@ export type {
430
450
  AgentCapability,
431
451
  AgentCapabilities,
432
452
  } from '@plannotator/core/agent-jobs';
453
+
454
+ /** Host toolbar seams (opt-in; Plannotator supplies neither). */
455
+ export type {
456
+ SelectionAction,
457
+ SelectionActionContext,
458
+ } from './utils/selectionActions';
459
+
460
+ export type {
461
+ MentionPerson,
462
+ MentionSource,
463
+ MentionTrigger,
464
+ } from './utils/mentions';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The `@` grammar for a PLAIN TEXTAREA comment composer, and the one place it
3
+ * is spelled in this package.
4
+ *
5
+ * These are the host's own rules, ported as pure functions so the package can
6
+ * own the typing behavior while the host owns the people: the trigger regex,
7
+ * the word-boundary guard that keeps `a@b.com` from opening a menu, the
8
+ * readable `@Label` insertion, and the surviving-token rule that keeps the
9
+ * body and the reported mention ids from ever disagreeing about who was named.
10
+ *
11
+ * Nothing here touches the DOM, fetches, or persists. A composer with no
12
+ * `mentionSource` never calls any of it.
13
+ */
14
+
15
+ /** One pickable identity, supplied by the host. */
16
+ export interface MentionPerson {
17
+ /** Opaque host id (a user id, a credential id). Reported back verbatim. */
18
+ readonly id: string;
19
+ readonly kind: 'user' | 'agent';
20
+ readonly label: string;
21
+ /** Right-aligned hint shown after the label (an email, "Agent"), or null. */
22
+ readonly detail: string | null;
23
+ /**
24
+ * Whether this person can open the document. Host data: rows render
25
+ * identically either way, and a `false` row is only special when the host
26
+ * supplied `onPickBlocked` (see `MentionSource`).
27
+ */
28
+ readonly canOpen: boolean;
29
+ }
30
+
31
+ /** The opt-in `@` mention source for `CommentPopover`'s composer. */
32
+ export interface MentionSource {
33
+ /** The people this composer may offer. An empty list shows `emptyNotice`. */
34
+ readonly people: readonly MentionPerson[];
35
+ /**
36
+ * The honest-empty row's text when there is no one to offer, or null/absent
37
+ * to keep the menu closed instead.
38
+ */
39
+ readonly emptyNotice?: string | null;
40
+ /** Fires on every text change with the ids whose token still survives in the body. */
41
+ readonly onMentionsChange?: (ids: readonly string[]) => void;
42
+ /**
43
+ * A `canOpen: false` person was picked. When supplied, NOTHING is inserted
44
+ * and the host shows its own no-access affordance. When absent, such a
45
+ * person inserts like any other.
46
+ */
47
+ readonly onPickBlocked?: (person: MentionPerson) => void;
48
+ }
49
+
50
+ /**
51
+ * THE trigger: `@` plus a word-ish query, anchored at the caret. The
52
+ * word-boundary check that makes `a@b.com` safe is the caller's (below),
53
+ * because a textarea walks the string.
54
+ */
55
+ export const MENTION_QUERY_RE = /@[\w .-]*$/;
56
+
57
+ /**
58
+ * Sanitize a display label for the inserted token: byte shapes that would
59
+ * break a `[@…](…)` link grammar are replaced and whitespace collapsed, so a
60
+ * host that later rewrites the body into links reads back what it wrote.
61
+ */
62
+ export function sanitizeMentionLabel(label: string): string {
63
+ return label
64
+ .replace(/[[\]|\n\r]/gu, ' ')
65
+ .replace(/\s+/gu, ' ')
66
+ .trim();
67
+ }
68
+
69
+ export interface MentionTrigger {
70
+ /** What was typed after the `@`, trimmed and lowercased for matching. */
71
+ readonly query: string;
72
+ /** Offset of the `@` itself, so a pick can swallow it. */
73
+ readonly from: number;
74
+ /** Offset just past the typed query (the caret). */
75
+ readonly to: number;
76
+ }
77
+
78
+ /**
79
+ * The trigger under the caret, or null. The word-boundary check is the whole
80
+ * email guard: the character before the `@` must be a line start or
81
+ * whitespace, so typing `a@b.com` never opens the picker.
82
+ */
83
+ export function mentionTrigger(text: string, caret: number): MentionTrigger | null {
84
+ const before = text.slice(0, caret);
85
+ const match = MENTION_QUERY_RE.exec(before);
86
+ if (match === null) return null;
87
+ const from = match.index;
88
+ if (from > 0) {
89
+ const prior = before.charAt(from - 1);
90
+ if (!/\s/u.test(prior)) return null;
91
+ }
92
+ return { query: match[0].slice(1).trim().toLowerCase(), from, to: caret };
93
+ }
94
+
95
+ /**
96
+ * The people this trigger offers: users only (an agent is not taggable in a
97
+ * comment), never one already tagged in this draft, de-duplicated by id, and
98
+ * filtered on label or detail once a query character exists.
99
+ */
100
+ export function mentionMatches(
101
+ people: readonly MentionPerson[],
102
+ trigger: MentionTrigger,
103
+ alreadyTagged: ReadonlySet<string>,
104
+ ): readonly MentionPerson[] {
105
+ const seen = new Set<string>();
106
+ return people.filter((person) => {
107
+ if (person.kind !== 'user') return false;
108
+ if (alreadyTagged.has(person.id) || seen.has(person.id)) return false;
109
+ seen.add(person.id);
110
+ if (trigger.query === '') return true;
111
+ return (
112
+ person.label.toLowerCase().includes(trigger.query) ||
113
+ (person.detail ?? '').toLowerCase().includes(trigger.query)
114
+ );
115
+ });
116
+ }
117
+
118
+ /** The readable token this person's name becomes in the body. */
119
+ export function mentionToken(person: MentionPerson): string {
120
+ return `@${sanitizeMentionLabel(person.label)}`;
121
+ }
122
+
123
+ /**
124
+ * Picking: the typed `@query` is replaced by the readable token plus one
125
+ * space, and the caret lands after it.
126
+ */
127
+ export function applyMentionPick(
128
+ text: string,
129
+ trigger: MentionTrigger,
130
+ person: MentionPerson,
131
+ ): { readonly text: string; readonly caret: number } {
132
+ const insert = `${mentionToken(person)} `;
133
+ return {
134
+ text: text.slice(0, trigger.from) + insert + text.slice(trigger.to),
135
+ caret: trigger.from + insert.length,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The people a draft still tags. A token the author deleted from the text
141
+ * stops being a tag: the body and the reported ids never disagree about who
142
+ * was named, which is what makes the readable `@Name` honest.
143
+ */
144
+ export function survivingMentions(
145
+ text: string,
146
+ tagged: readonly MentionPerson[],
147
+ ): readonly MentionPerson[] {
148
+ return tagged.filter((person) => text.includes(mentionToken(person)));
149
+ }
package/utils/parser.ts CHANGED
@@ -541,6 +541,39 @@ export const resolveReferenceLinks = (markdown: string): string => {
541
541
  .join('\n');
542
542
  };
543
543
 
544
+ /**
545
+ * The block list for a whole-file diagram source (`plannotator annotate
546
+ * flow.mmd`): ONE code block carrying the file's raw text, which `Viewer`
547
+ * hands to the same `DiagramBlock` a ```mermaid fence in a plan produces.
548
+ * Everything downstream — diagram comments, the annotations rail, the export's
549
+ * `Diagram node <label> (<id>), line <n>` location line, drafts, restore — is
550
+ * the fence path unchanged.
551
+ *
552
+ * `diagramSourceLineOffset: 0` is the load-bearing part. `DiagramBlock` passes
553
+ * it as the viewer's `sourceLineOffset` and the codec adds it to the 1-based
554
+ * line WITHIN the diagram source; for a fence that offset is the fence's own
555
+ * opening line, which sits one line above the diagram's first line. A diagram
556
+ * FILE has no fence, so its first line is document line 1 and the offset is 0
557
+ * — the 1 a synthesized ```mermaid wrapper would produce puts every exported
558
+ * diagram line one too high. `startLine`/`sourceLineCount` still describe the
559
+ * block itself, so the export's `(lines a–b)` label names the file's real
560
+ * span.
561
+ */
562
+ export const diagramDocumentBlocks = (text: string, kind: 'mermaid' | 'graphviz'): Block[] => [
563
+ {
564
+ id: 'block-0',
565
+ type: 'code',
566
+ content: text,
567
+ // `dot` is what isGraphvizLanguage reads for the Graphviz engine.
568
+ language: kind === 'graphviz' ? 'dot' : 'mermaid',
569
+ order: 1,
570
+ startLine: 1,
571
+ // A trailing newline ends the last line, it does not start another.
572
+ sourceLineCount: text === '' ? 0 : text.replace(/\n$/, '').split('\n').length,
573
+ diagramSourceLineOffset: 0,
574
+ },
575
+ ];
576
+
544
577
  /**
545
578
  * A simplified markdown parser that splits content into linear blocks.
546
579
  * For a production app, we would use a robust AST walker (remark),
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Host selection actions — the opt-in seam that lets a host put its own
3
+ * commands on the selection toolbar beside (or instead of) the quick labels.
4
+ *
5
+ * The package renders the button and the dropdown and hands back the
6
+ * selection context; it creates NO annotation of its own. What an action does
7
+ * is entirely the host's business. Plannotator supplies none of these, so
8
+ * nothing here runs in Plannotator's apps.
9
+ */
10
+
11
+ import type React from 'react';
12
+
13
+ /** What the toolbar knows about the selection an action was invoked on. */
14
+ export interface SelectionActionContext {
15
+ /** The selected text (the toolbar's copy text, else the element's text). */
16
+ text: string;
17
+ /** The enclosing `[data-block-id]`, or '' on surfaces that have none (raw HTML). */
18
+ blockId: string;
19
+ /** Offset of the selection within the block's text, 0 when unknown. */
20
+ startOffset: number;
21
+ /** `startOffset + text.length` — so on a block-less surface it is the
22
+ * selection's length, not 0. */
23
+ endOffset: number;
24
+ /** The element the toolbar is anchored to. */
25
+ element: HTMLElement;
26
+ }
27
+
28
+ export interface SelectionAction {
29
+ id: string;
30
+ label: string;
31
+ /** Optional dimmed second line under the label. */
32
+ detail?: string;
33
+ /** Optional leading icon; a colored bar is drawn when absent. */
34
+ icon?: React.ReactNode;
35
+ onSelect: (ctx: SelectionActionContext) => void;
36
+ }
37
+
38
+ /**
39
+ * Build the context for an invoked action from what the toolbar has: its
40
+ * anchor element and the selected text. The block lookup and the offset
41
+ * arithmetic deliberately reproduce `createAnnotationFromSource`'s, so an
42
+ * action sees the same coordinates an annotation created from the same
43
+ * selection would carry.
44
+ *
45
+ * ONE deliberate deviation: when the selection is not found inside the block
46
+ * at all — a selection spanning two blocks, where the anchor sits in the
47
+ * first — `String.split` yields the whole block text and the annotation path
48
+ * would report `blockText.length`. A host gets 0 instead, since an offset
49
+ * past the end of the block is worse than an admitted "unknown".
50
+ */
51
+ export function buildSelectionActionContext(
52
+ element: HTMLElement,
53
+ text: string,
54
+ ): SelectionActionContext {
55
+ const blockEl = element.closest<HTMLElement>('[data-block-id]');
56
+ const blockId = blockEl?.dataset.blockId ?? '';
57
+ let startOffset = 0;
58
+ if (blockEl && text) {
59
+ const blockText = blockEl.textContent || '';
60
+ const before = blockText.split(text)[0];
61
+ startOffset = before === blockText ? 0 : before?.length || 0;
62
+ }
63
+ return { text, blockId, startOffset, endOffset: startOffset + text.length, element };
64
+ }
package/utils/sharing.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * Inspired by textarea.my's approach.
9
9
  */
10
10
 
11
- import { AnnotationType, type Annotation, type ImageAttachment } from '../types';
11
+ import { AnnotationType, type Annotation, type DocumentRenderAs, type ImageAttachment } from '../types';
12
+ import { isDiagramRenderKind } from '@plannotator/core/annotatable';
12
13
  import { compress, decompress } from '@plannotator/core/compress';
13
14
  import { encrypt, decrypt } from '@plannotator/core/crypto';
14
15
 
@@ -31,6 +32,32 @@ export interface SharePayload {
31
32
  r?: 'html'; // render mode flag (omitted = markdown)
32
33
  }
33
34
 
35
+ /**
36
+ * The markdown a document ships as in a share payload's `p`.
37
+ *
38
+ * A whole-file diagram source (.mmd/.dot) has no fence of its own — the
39
+ * annotate session renders it as one diagram because the SERVER said so in
40
+ * `renderAs`, and a share link carries no server. So it travels as the fenced
41
+ * form, which the portal's ordinary markdown parse turns back into the same
42
+ * diagram block. This is deliberately smaller than adding a render-mode flag
43
+ * (`r: 'html'`'s sibling): the portal needs no change at all.
44
+ *
45
+ * Diagram comments themselves still degrade to text comments in a share link,
46
+ * exactly as they do today — `diagramAnchor` is dropped like `htmlAnchor`
47
+ * (see sharing.multiTarget.test.ts).
48
+ */
49
+ export function shareableDocumentMarkdown(
50
+ markdown: string,
51
+ renderAs: DocumentRenderAs | undefined,
52
+ ): string {
53
+ if (!isDiagramRenderKind(renderAs) || markdown === '') return markdown;
54
+ const language = renderAs === 'graphviz' ? 'dot' : 'mermaid';
55
+ // A diagram source can itself contain a ``` run only in a comment/label;
56
+ // a four-backtick fence keeps such a body intact.
57
+ const fence = markdown.includes('```') ? '````' : '```';
58
+ return `${fence}${language}\n${markdown.replace(/\n+$/, '')}\n${fence}`;
59
+ }
60
+
34
61
  /**
35
62
  * Convert ShareableImage[] to ImageAttachment[] (handles old plain-string format)
36
63
  */