@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/HANDOFF.md +297 -0
- package/README.md +49 -1
- package/components/AnnotationToolbar.tsx +87 -8
- package/components/CommentPopover.tsx +90 -14
- package/components/MentionPicker.tsx +110 -0
- package/components/SelectionActionsDropdown.tsx +173 -0
- package/components/Viewer.tsx +56 -2
- package/components/html-viewer/HtmlViewer.tsx +24 -1
- package/components/html-viewer/useHtmlAnnotation.ts +4 -1
- package/hooks/useAnnotationHighlighter.ts +22 -3
- package/hooks/useMentionAutocomplete.ts +246 -0
- package/package.json +1 -1
- package/styles.css +1 -1
- package/types.ts +13 -0
- package/utils/mentions.ts +149 -0
- package/utils/selectionActions.ts +64 -0
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
|
+
}
|