@plannotator/ui 0.42.0 → 0.43.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/types.ts CHANGED
@@ -83,6 +83,7 @@ export interface Annotation {
83
83
  author?: string; // Tater identity for collaborative sharing
84
84
  source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API
85
85
  images?: ImageAttachment[]; // Attached images with human-readable names
86
+ mentions?: readonly string[]; // opaque host ids named with `@` in the comment body, set ONLY when a host supplied a `mentionSource` to the composer and at least one token survived; the key is absent otherwise. Host data: the package never renders, exports, shares or archives it.
86
87
  isQuickLabel?: boolean; // true if created via quick label chip
87
88
  quickLabelTip?: string; // optional instruction tip from the label definition
88
89
  diffContext?: 'added' | 'removed' | 'modified'; // set when annotation created in plan diff view
@@ -450,3 +451,15 @@ export type {
450
451
  AgentCapability,
451
452
  AgentCapabilities,
452
453
  } from '@plannotator/core/agent-jobs';
454
+
455
+ /** Host toolbar seams (opt-in; Plannotator supplies neither). */
456
+ export type {
457
+ SelectionAction,
458
+ SelectionActionContext,
459
+ } from './utils/selectionActions';
460
+
461
+ export type {
462
+ MentionPerson,
463
+ MentionSource,
464
+ MentionTrigger,
465
+ } 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
+ }
@@ -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
+ }