@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.
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  useState,
9
+ type ReactNode,
9
10
  } from "react";
10
11
  import { createPortal } from "react-dom";
11
12
  import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus";
@@ -24,6 +25,8 @@ import {
24
25
  type VimHudCommand,
25
26
  } from "../../utils/vimHud";
26
27
  import { AnnotationToolbar } from "../AnnotationToolbar";
28
+ import type { SelectionAction } from "../../utils/selectionActions";
29
+ import type { MentionSource } from "../../utils/mentions";
27
30
  import { AttachmentsButton } from "../AttachmentsButton";
28
31
  import {
29
32
  CommentPopover,
@@ -265,6 +268,18 @@ export interface HtmlViewerProps {
265
268
  * to the bridge so the in-page toggle stops at the same number. Default
266
269
  * 16 (the package cap); absent leaves every message unchanged. */
267
270
  maxAdditionalTargets?: number;
271
+ /** Opt-in host capability, passed straight through to the selection
272
+ * toolbar: the host's own commands for the current selection, rendered as
273
+ * one wand button that opens the package's dropdown. Absent → unchanged. */
274
+ selectionActions?: SelectionAction[];
275
+ /** Opt-in host capability: the glyph on the `selectionActions` button.
276
+ * Absent → the package's own wand. */
277
+ selectionActionsIcon?: ReactNode;
278
+ /** Opt-in host capability, forwarded to BOTH comment composers this viewer
279
+ * mounts (the pinpoint/selection composer and the global one): the `@`
280
+ * mention source for the composer's picker. The picked ids ride onto the
281
+ * created annotation as `Annotation.mentions`. Absent → unchanged. */
282
+ mentionSource?: MentionSource;
268
283
  /** scrollIntoView behavior when a selected annotation is scrolled into
269
284
  * view inside the page. Default 'smooth'; pass 'auto' to carry the
270
285
  * parent's reduced-motion preference across the iframe boundary. */
@@ -352,6 +367,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
352
367
  readOnly = false,
353
368
  onUnanchoredChange,
354
369
  maxAdditionalTargets,
370
+ selectionActions,
371
+ selectionActionsIcon,
372
+ mentionSource,
355
373
  scrollBehavior,
356
374
  title = "HTML Plan Viewer",
357
375
  bridgeScriptUrl,
@@ -989,7 +1007,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
989
1007
  }));
990
1008
 
991
1009
  const handleGlobalCommentSubmit = useCallback(
992
- (text: string, images?: ImageAttachment[]) => {
1010
+ (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
993
1011
  if (readOnly) return;
994
1012
  onAddAnnotation({
995
1013
  id: `global-${Date.now()}`,
@@ -1002,6 +1020,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1002
1020
  author: getIdentity(),
1003
1021
  createdA: Date.now(),
1004
1022
  images,
1023
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
1005
1024
  });
1006
1025
  setGlobalCommentPopover(null);
1007
1026
  },
@@ -1202,6 +1221,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1202
1221
  // wrapper filters by id as defense in depth, so no present or
1203
1222
  // future toolbar path can emit an arbitrary label here.
1204
1223
  commentOnly
1224
+ selectionActions={selectionActions}
1225
+ selectionActionsIcon={selectionActionsIcon}
1205
1226
  onQuickLabel={(label) => {
1206
1227
  if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
1207
1228
  }}
@@ -1222,6 +1243,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1222
1243
  isGlobal={false}
1223
1244
  draftKey={`html:${hook.commentPopover.draftKey}`}
1224
1245
  onSubmit={hook.handleCommentSubmit}
1246
+ mentionSource={mentionSource}
1225
1247
  // Pinpoint clicks open this composer directly, so it carries
1226
1248
  // the surface's one-click "Looks good" (the global composer
1227
1249
  // does not: a document-wide thumbs-up is not a thing).
@@ -1253,6 +1275,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1253
1275
  isGlobal={true}
1254
1276
  onSubmit={handleGlobalCommentSubmit}
1255
1277
  onClose={() => setGlobalCommentPopover(null)}
1278
+ mentionSource={mentionSource}
1256
1279
  skillReferences
1257
1280
  onAskAI={onAskAI}
1258
1281
  askAIContext={{ kind: "general", label: "Document" }}
@@ -911,7 +911,7 @@ export function useHtmlAnnotation({
911
911
  );
912
912
 
913
913
  const handleCommentSubmit = useCallback(
914
- (comment: string, images?: ImageAttachment[]) => {
914
+ (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
915
915
  if (!enabledRef.current) return;
916
916
  // Prefer the text captured when the popover opened — it can't be clobbered by
917
917
  // a later selection change or clear while the user is composing the comment.
@@ -944,6 +944,9 @@ export function useHtmlAnnotation({
944
944
  author: getIdentity(),
945
945
  createdA: Date.now(),
946
946
  images,
947
+ // Host capability: present only when a mentionSource was supplied AND
948
+ // a token survived, so a comment without one is unchanged.
949
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
947
950
  htmlAnchor: pendingAnchorRef.current ?? undefined,
948
951
  elementContext: pendingContextRef.current ?? undefined,
949
952
  htmlAdditionalTargets: additionalTargets,
@@ -631,7 +631,13 @@ export interface UseAnnotationHighlighterReturn {
631
631
  handleQuickLabel: (label: QuickLabel) => void;
632
632
  handleToolbarClose: () => void;
633
633
  handleRequestComment: (initialChar?: string) => void;
634
- handleCommentSubmit: (text: string, images?: ImageAttachment[]) => void;
634
+ /** The composer's submit. `mentions` arrives only from a `CommentPopover`
635
+ * the host gave a `mentionSource`; the ids ride onto the new annotation. */
636
+ handleCommentSubmit: (
637
+ text: string,
638
+ images?: ImageAttachment[],
639
+ mentions?: readonly string[],
640
+ ) => void;
635
641
  handleCommentClose: () => void;
636
642
  handleFloatingQuickLabel: (label: QuickLabel) => void;
637
643
  handleQuickLabelPickerDismiss: () => void;
@@ -870,6 +876,7 @@ export function useAnnotationHighlighter({
870
876
  images?: ImageAttachment[],
871
877
  isQuickLabel?: boolean,
872
878
  quickLabelTip?: string,
879
+ mentions?: readonly string[],
873
880
  ) => {
874
881
  const doms = highlighter.getDoms(source.id);
875
882
  let blockId = '';
@@ -904,6 +911,9 @@ export function useAnnotationHighlighter({
904
911
  startMeta: source.startMeta,
905
912
  endMeta: source.endMeta,
906
913
  images,
914
+ // Host capability: present only when a mentionSource was supplied AND a
915
+ // token survived, so an annotation created without one is unchanged.
916
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
907
917
  ...(mathTargets.length > 0 ? {
908
918
  mathTargets: mathTargets.map(target => ({
909
919
  blockId: target.blockId,
@@ -933,6 +943,7 @@ export function useAnnotationHighlighter({
933
943
  images?: ImageAttachment[],
934
944
  isQuickLabel?: boolean,
935
945
  quickLabelTip?: string,
946
+ mentions?: readonly string[],
936
947
  ) => {
937
948
  const id = annotationId();
938
949
  applyMathAnnotationClass(source.element, id, type, source.displayMode);
@@ -951,6 +962,7 @@ export function useAnnotationHighlighter({
951
962
  createdA: Date.now(),
952
963
  author: getIdentity(),
953
964
  images,
965
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
954
966
  ...(isQuickLabel ? { isQuickLabel: true } : {}),
955
967
  ...(quickLabelTip ? { quickLabelTip } : {}),
956
968
  };
@@ -1695,7 +1707,11 @@ export function useAnnotationHighlighter({
1695
1707
  setToolbarState(null);
1696
1708
  };
1697
1709
 
1698
- const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
1710
+ const handleCommentSubmit = (
1711
+ text: string,
1712
+ images?: ImageAttachment[],
1713
+ mentions?: readonly string[],
1714
+ ) => {
1699
1715
  if (!commentPopover) return;
1700
1716
  if (isMathAnnotationSource(commentPopover.source)) {
1701
1717
  createAnnotationFromMathSource(
@@ -1703,6 +1719,9 @@ export function useAnnotationHighlighter({
1703
1719
  AnnotationType.COMMENT,
1704
1720
  text,
1705
1721
  images,
1722
+ undefined,
1723
+ undefined,
1724
+ mentions,
1706
1725
  );
1707
1726
  clearPendingSelection();
1708
1727
  window.getSelection()?.removeAllRanges();
@@ -1712,7 +1731,7 @@ export function useAnnotationHighlighter({
1712
1731
  if (commentPopover.source && highlighterRef.current) {
1713
1732
  createAnnotationFromSource(
1714
1733
  highlighterRef.current, commentPopover.source,
1715
- AnnotationType.COMMENT, text, images
1734
+ AnnotationType.COMMENT, text, images, undefined, undefined, mentions
1716
1735
  );
1717
1736
  clearPendingSelection();
1718
1737
  window.getSelection()?.removeAllRanges();
@@ -0,0 +1,246 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type React from 'react';
3
+ import {
4
+ applyMentionPick,
5
+ mentionMatches,
6
+ mentionTrigger,
7
+ survivingMentions,
8
+ type MentionPerson,
9
+ type MentionSource,
10
+ type MentionTrigger,
11
+ } from '../utils/mentions';
12
+ import { mentionAnchorOf, type MentionAnchor } from '../components/MentionPicker';
13
+
14
+ export interface MentionMenuState {
15
+ items: readonly MentionPerson[];
16
+ /** Shown as one non-selectable row when `items` is empty. */
17
+ emptyNotice: string | null;
18
+ /** Explicitly activated row, or null — the menu opens with NOTHING active. */
19
+ activeIndex: number | null;
20
+ /** Measured textarea rect the portaled picker is placed from. */
21
+ anchor: MentionAnchor | null;
22
+ query: string;
23
+ }
24
+
25
+ export interface UseMentionAutocompleteResult {
26
+ /** Open menu state, or null. Render MentionPicker from this. */
27
+ menu: MentionMenuState | null;
28
+ /** Call in the textarea's onKeyDown; true means the event was consumed. */
29
+ onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => boolean;
30
+ /** Call from the textarea's onSelect and onChange (every caret move + input). */
31
+ onSelect: () => void;
32
+ /** Insert the given menu item at the active trigger. */
33
+ select: (index: number) => void;
34
+ /** The ids whose readable token still survives in the text. */
35
+ mentionIds: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * `@` mention autocomplete for a comment textarea, driven entirely by a
40
+ * host-supplied `MentionSource`. With no source every path here is inert:
41
+ * no listener is registered, no menu state is ever open, `mentionIds` stays
42
+ * the same frozen empty array, and the composer renders and behaves exactly
43
+ * as it did before the prop existed.
44
+ *
45
+ * NO-PRESELECTION INVARIANT (shared with `useSkillReferenceAutocomplete`):
46
+ * the menu opens with no row active, and while no row is active Enter and
47
+ * Tab behave exactly as if the menu were not open — a body that ends
48
+ * "ping @" plus Enter is still a newline. A row becomes active only through
49
+ * explicit keyboard navigation (ArrowDown from none lands on the FIRST row,
50
+ * ArrowUp from none on the LAST) or a pointer click, which inserts directly.
51
+ *
52
+ * DELIBERATE DIFFERENCE from the `/` and `$` skill trigger: the arrows engage
53
+ * this menu even on a BARE trigger (`@` with no query typed). `$` and `/` are
54
+ * ordinary prose characters, so their menu must yield the arrows back to
55
+ * caret navigation; `@` at a word boundary is an unambiguous tag gesture, and
56
+ * typing `@` then ArrowDown is how the host's own reply box already behaves.
57
+ * Escape closes the menu whenever it is visible, for the same reason.
58
+ */
59
+ const NO_IDS: readonly string[] = Object.freeze([]);
60
+ const NO_PEOPLE: readonly MentionPerson[] = Object.freeze([]);
61
+
62
+ export function useMentionAutocomplete(options: {
63
+ text: string;
64
+ setText: (text: string) => void;
65
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
66
+ source?: MentionSource;
67
+ }): UseMentionAutocompleteResult {
68
+ const { text, setText, textareaRef, source } = options;
69
+ const enabled = !!source;
70
+ const [caret, setCaret] = useState<number | null>(null);
71
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
72
+ const [tagged, setTagged] = useState<readonly MentionPerson[]>(NO_PEOPLE);
73
+ const [anchor, setAnchor] = useState<MentionAnchor | null>(null);
74
+ // Escape dismisses the menu for the trigger it was open on; the same trigger
75
+ // does not reopen until the caret leaves it.
76
+ const [dismissedStart, setDismissedStart] = useState<number | null>(null);
77
+
78
+ const trigger: MentionTrigger | null = useMemo(() => {
79
+ if (!enabled || caret === null) return null;
80
+ return mentionTrigger(text, caret);
81
+ }, [enabled, text, caret]);
82
+
83
+ // A token the author deleted stops being a tag, so the body and the
84
+ // reported ids never disagree about who was named.
85
+ const survivors = useMemo(() => survivingMentions(text, tagged), [text, tagged]);
86
+ useEffect(() => {
87
+ if (survivors.length !== tagged.length) setTagged(survivors);
88
+ }, [survivors, tagged]);
89
+
90
+ const taggedIds = useMemo(() => new Set(survivors.map((p) => p.id)), [survivors]);
91
+ const mentionIds = useMemo(
92
+ () => (enabled ? survivors.map((p) => p.id) : NO_IDS),
93
+ [enabled, survivors],
94
+ );
95
+
96
+ const onMentionsChange = source?.onMentionsChange;
97
+ const mentionsKey = mentionIds.join('\u0000');
98
+ const lastReported = useRef<string | null>(null);
99
+ useEffect(() => {
100
+ if (!onMentionsChange) return;
101
+ if (lastReported.current === mentionsKey) return;
102
+ lastReported.current = mentionsKey;
103
+ onMentionsChange(mentionIds);
104
+ // mentionIds is keyed by mentionsKey; re-running on identity alone would
105
+ // report the same ids on every render.
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [mentionsKey, onMentionsChange]);
108
+
109
+ const items = useMemo(
110
+ () => (source && trigger ? mentionMatches(source.people, trigger, taggedIds) : []),
111
+ [source, trigger, taggedIds],
112
+ );
113
+
114
+ const emptyNotice = source?.emptyNotice ?? null;
115
+ const open =
116
+ trigger !== null
117
+ && trigger.from !== dismissedStart
118
+ && (items.length > 0 || !!emptyNotice);
119
+
120
+ // A new trigger start clears the dismissal memory.
121
+ const triggerStart = trigger?.from ?? null;
122
+ const lastTriggerStart = useRef<number | null>(null);
123
+ useEffect(() => {
124
+ if (triggerStart !== lastTriggerStart.current) {
125
+ lastTriggerStart.current = triggerStart;
126
+ setDismissedStart(null);
127
+ }
128
+ }, [triggerStart]);
129
+
130
+ // Any trigger change — a new one, or more typing re-filtering the same one —
131
+ // disarms the active row, so an activation always refers to the exact list
132
+ // the user was looking at.
133
+ const triggerQuery = trigger?.query ?? null;
134
+ useEffect(() => {
135
+ setActiveIndex(null);
136
+ }, [triggerStart, triggerQuery]);
137
+
138
+ // Measure the composer while the menu is open (and follow scroll/resize).
139
+ useEffect(() => {
140
+ if (!open) {
141
+ setAnchor(null);
142
+ return;
143
+ }
144
+ const measure = () => setAnchor(mentionAnchorOf(textareaRef.current));
145
+ measure();
146
+ window.addEventListener('scroll', measure, true);
147
+ window.addEventListener('resize', measure);
148
+ return () => {
149
+ window.removeEventListener('scroll', measure, true);
150
+ window.removeEventListener('resize', measure);
151
+ };
152
+ }, [open, textareaRef, triggerStart, triggerQuery]);
153
+
154
+ const boundedActive =
155
+ activeIndex !== null && activeIndex >= 0 && activeIndex < items.length ? activeIndex : null;
156
+
157
+ const readCaret = useCallback(() => {
158
+ if (!enabled) return;
159
+ const el = textareaRef.current;
160
+ setCaret(el ? el.selectionStart : null);
161
+ }, [enabled, textareaRef]);
162
+
163
+ const select = useCallback(
164
+ (index: number) => {
165
+ const el = textareaRef.current;
166
+ if (!source || !trigger) return;
167
+ const person = items[index];
168
+ if (!person) return;
169
+ if (person.canOpen === false && source.onPickBlocked) {
170
+ // Nothing is inserted: the host owns the no-access affordance.
171
+ source.onPickBlocked(person);
172
+ setActiveIndex(null);
173
+ setDismissedStart(trigger.from);
174
+ return;
175
+ }
176
+ const result = applyMentionPick(text, trigger, person);
177
+ setText(result.text);
178
+ setCaret(result.caret);
179
+ setTagged((prev) => (prev.some((p) => p.id === person.id) ? prev : [...prev, person]));
180
+ setActiveIndex(null);
181
+ // Close deterministically: the DOM caret only moves in the timer below,
182
+ // and React's select plugin can re-read the STALE caret before then,
183
+ // transiently reopening the menu on the just-replaced query.
184
+ setDismissedStart(trigger.from);
185
+ setTimeout(() => {
186
+ if (!el || !el.isConnected) return;
187
+ el.focus();
188
+ el.setSelectionRange(result.caret, result.caret);
189
+ }, 0);
190
+ },
191
+ [items, setText, source, text, textareaRef, trigger],
192
+ );
193
+
194
+ const onKeyDown = useCallback(
195
+ (e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
196
+ if (!open || !trigger) return false;
197
+ // Never consume keys mid-composition: for Pinyin, Telex and friends the
198
+ // composition buffer is ASCII, so Enter can mean "commit this candidate".
199
+ if (e.nativeEvent.isComposing) return false;
200
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
201
+ switch (e.key) {
202
+ case 'ArrowDown':
203
+ case 'ArrowUp':
204
+ if (items.length === 0) return false; // the empty-notice row is not navigable
205
+ e.preventDefault();
206
+ if (e.key === 'ArrowDown') {
207
+ setActiveIndex(boundedActive === null ? 0 : (boundedActive + 1) % items.length);
208
+ } else {
209
+ setActiveIndex(
210
+ boundedActive === null
211
+ ? items.length - 1
212
+ : (boundedActive - 1 + items.length) % items.length,
213
+ );
214
+ }
215
+ return true;
216
+ case 'Enter':
217
+ case 'Tab':
218
+ // NO row active means these keys were NOT aimed at the menu: Enter
219
+ // stays a newline, Tab still leaves the field.
220
+ if (boundedActive === null) return false;
221
+ e.preventDefault();
222
+ select(boundedActive);
223
+ return true;
224
+ case 'Escape':
225
+ e.preventDefault();
226
+ e.stopPropagation();
227
+ setActiveIndex(null);
228
+ setDismissedStart(trigger.from);
229
+ return true;
230
+ default:
231
+ return false;
232
+ }
233
+ },
234
+ [boundedActive, items.length, open, select, trigger],
235
+ );
236
+
237
+ return {
238
+ menu: open && trigger
239
+ ? { items, emptyNotice, activeIndex: boundedActive, anchor, query: trigger.query }
240
+ : null,
241
+ onKeyDown,
242
+ onSelect: readCaret,
243
+ select,
244
+ mentionIds,
245
+ };
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",