@plannotator/ui 0.42.0 → 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.
@@ -0,0 +1,110 @@
1
+ import { createPortal } from 'react-dom';
2
+ import type { MentionPerson } from '../utils/mentions';
3
+
4
+ /**
5
+ * The `@` menu over the comment composer's textarea.
6
+ *
7
+ * NOTHING IS PRESELECTED (`active === null` until the person arrows into the
8
+ * list), the same rule `useSkillReferenceAutocomplete` keeps for `/` and `$`:
9
+ * until then Enter and Tab mean what they always meant in a comment box.
10
+ *
11
+ * PORTALED AND FIXED, not absolute inside the composer: the popover clips its
12
+ * own box and the expanded dialog scrolls, so a menu positioned inside the
13
+ * textarea's wrapper is cut off at the card's edge. It is placed from the
14
+ * textarea's measured rect instead — above it by default, below when the
15
+ * composer sits too close to the top of the window for the menu to open
16
+ * upward.
17
+ */
18
+
19
+ export interface MentionAnchor {
20
+ readonly top: number;
21
+ readonly bottom: number;
22
+ readonly left: number;
23
+ readonly width: number;
24
+ }
25
+
26
+ /** The menu's own height cap (max-h-48 = 12rem), in px. */
27
+ const MENU_MAX_PX = 192;
28
+ const MENU_GAP_PX = 4;
29
+
30
+ /** Measure the composer textarea for {@link MentionPicker}. Null closes the menu. */
31
+ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | null {
32
+ if (element === null) return null;
33
+ const rect = element.getBoundingClientRect();
34
+ return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
35
+ }
36
+
37
+ export function MentionPicker({
38
+ id,
39
+ people,
40
+ emptyNotice,
41
+ active,
42
+ anchor,
43
+ onPick,
44
+ onHover,
45
+ }: {
46
+ /** Id referenced by the focused textarea's aria-controls attribute. */
47
+ readonly id?: string;
48
+ readonly people: readonly MentionPerson[];
49
+ /** Shown as one non-selectable row when `people` is empty. */
50
+ readonly emptyNotice?: string | null;
51
+ /** Index of the arrow-focused row, or null for "nothing preselected". */
52
+ readonly active: number | null;
53
+ readonly anchor: MentionAnchor | null;
54
+ readonly onPick: (person: MentionPerson) => void;
55
+ readonly onHover: (index: number) => void;
56
+ }) {
57
+ if (anchor === null || typeof document === 'undefined') return null;
58
+ if (people.length === 0 && !emptyNotice) return null;
59
+ const above = anchor.top >= MENU_MAX_PX + MENU_GAP_PX;
60
+ const style = above
61
+ ? { bottom: window.innerHeight - anchor.top + MENU_GAP_PX }
62
+ : { top: anchor.bottom + MENU_GAP_PX };
63
+ return createPortal(
64
+ <div
65
+ id={id}
66
+ role="listbox"
67
+ aria-label="People"
68
+ data-mention-picker
69
+ data-placement={above ? 'above' : 'below'}
70
+ style={{ position: 'fixed', left: anchor.left, width: anchor.width, ...style }}
71
+ className="z-[120] max-h-48 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-xl"
72
+ >
73
+ {people.length === 0 ? (
74
+ <p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
75
+ {emptyNotice}
76
+ </p>
77
+ ) : (
78
+ people.map((person, index) => (
79
+ <button
80
+ key={person.id}
81
+ type="button"
82
+ role="option"
83
+ id={id ? `${id}-option-${index}` : undefined}
84
+ aria-selected={active === index}
85
+ data-mention-option={person.id}
86
+ // The pick must run before the textarea loses focus, or the blur
87
+ // would close the menu and swallow the click.
88
+ onMouseDown={(event) => {
89
+ event.preventDefault();
90
+ onPick(person);
91
+ }}
92
+ onMouseEnter={() => onHover(index)}
93
+ className={`flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-left text-xs transition-colors ${
94
+ active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
95
+ }`}
96
+ >
97
+ <span className="min-w-0 flex-1 truncate">{person.label}</span>
98
+ {person.detail && (
99
+ // The NAME is what the person reads; the detail gives way first.
100
+ <span className="min-w-0 max-w-[44%] shrink-0 truncate text-[10px] text-muted-foreground">
101
+ {person.detail}
102
+ </span>
103
+ )}
104
+ </button>
105
+ ))
106
+ )}
107
+ </div>,
108
+ document.body,
109
+ );
110
+ }
@@ -0,0 +1,173 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { SelectionAction } from '../utils/selectionActions';
4
+
5
+ /**
6
+ * The host-actions dropdown: the same context-menu shape `QuickLabelDropdown`
7
+ * draws (single column, accent bar, full-width rows) and the same floating
8
+ * placement `FloatingQuickLabelPicker` computes, opened directly below the
9
+ * toolbar's wand button.
10
+ *
11
+ * Keyboard here is arrows + Enter + Escape rather than the quick labels'
12
+ * digit keys: a host's list is not a fixed ten with memorized numbers.
13
+ * Nothing is preselected until the first arrow, so a stray Enter while the
14
+ * dropdown is open never invokes a host command.
15
+ */
16
+
17
+ const PICKER_WIDTH = 224;
18
+ const GAP = 6;
19
+ const VIEWPORT_PADDING = 12;
20
+ /** Room the dropdown wants below the anchor before it flips above. */
21
+ const FLIP_THRESHOLD = 220;
22
+
23
+ function computePosition(anchorEl: HTMLElement): { top: number; left: number; flipAbove: boolean } {
24
+ const rect = anchorEl.getBoundingClientRect();
25
+ const flipAbove = window.innerHeight - rect.bottom < FLIP_THRESHOLD;
26
+ const top = flipAbove ? rect.top - GAP : rect.bottom + GAP;
27
+ let left = rect.left + rect.width / 2 - PICKER_WIDTH / 2;
28
+ left = Math.max(
29
+ VIEWPORT_PADDING,
30
+ Math.min(left, window.innerWidth - PICKER_WIDTH - VIEWPORT_PADDING),
31
+ );
32
+ return { top, left, flipAbove };
33
+ }
34
+
35
+ export const SelectionActionsDropdown: React.FC<{
36
+ actions: SelectionAction[];
37
+ activeIndex: number | null;
38
+ onSelect: (action: SelectionAction) => void;
39
+ onHover: (index: number) => void;
40
+ }> = ({ actions, activeIndex, onSelect, onHover }) => (
41
+ <div className="py-1" onMouseDown={(e) => e.stopPropagation()}>
42
+ {actions.map((action, index) => (
43
+ <button
44
+ key={action.id}
45
+ type="button"
46
+ role="option"
47
+ aria-selected={activeIndex === index}
48
+ data-selection-action={action.id}
49
+ onClick={() => onSelect(action)}
50
+ onMouseEnter={() => onHover(index)}
51
+ className={`group w-full flex items-center gap-2 px-2 py-[5px] text-left transition-colors ${
52
+ activeIndex === index ? 'bg-muted' : 'hover:bg-muted/60 active:bg-muted'
53
+ }`}
54
+ >
55
+ {action.icon ? (
56
+ <span className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-muted-foreground">
57
+ {action.icon}
58
+ </span>
59
+ ) : (
60
+ <span className="w-[3px] self-stretch rounded-full flex-shrink-0 bg-primary/70" />
61
+ )}
62
+ <span className="min-w-0 flex-1">
63
+ <span className="block text-[11px] leading-tight text-foreground/85 group-hover:text-foreground truncate">
64
+ {action.label}
65
+ </span>
66
+ {action.detail && (
67
+ <span className="block text-[9px] leading-tight text-muted-foreground/70 truncate">
68
+ {action.detail}
69
+ </span>
70
+ )}
71
+ </span>
72
+ </button>
73
+ ))}
74
+ </div>
75
+ );
76
+
77
+ export const FloatingSelectionActionsPicker: React.FC<{
78
+ anchorEl: HTMLElement;
79
+ actions: SelectionAction[];
80
+ onSelect: (action: SelectionAction) => void;
81
+ onDismiss: () => void;
82
+ }> = ({ anchorEl, actions, onSelect, onDismiss }) => {
83
+ const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean } | null>(null);
84
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
85
+ const ref = useRef<HTMLDivElement>(null);
86
+
87
+ useEffect(() => {
88
+ const update = () => setPosition(computePosition(anchorEl));
89
+ update();
90
+ window.addEventListener('scroll', update, true);
91
+ window.addEventListener('resize', update);
92
+ return () => {
93
+ window.removeEventListener('scroll', update, true);
94
+ window.removeEventListener('resize', update);
95
+ };
96
+ }, [anchorEl]);
97
+
98
+ useEffect(() => {
99
+ const handleKeyDown = (e: KeyboardEvent) => {
100
+ if (e.isComposing) return;
101
+ if (e.key === 'Escape') {
102
+ e.preventDefault();
103
+ onDismiss();
104
+ return;
105
+ }
106
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
107
+ e.preventDefault();
108
+ setActiveIndex((prev) => {
109
+ if (actions.length === 0) return null;
110
+ if (prev === null) return e.key === 'ArrowDown' ? 0 : actions.length - 1;
111
+ return e.key === 'ArrowDown'
112
+ ? (prev + 1) % actions.length
113
+ : (prev - 1 + actions.length) % actions.length;
114
+ });
115
+ return;
116
+ }
117
+ if (e.key === 'Enter') {
118
+ // Nothing preselected: Enter before the first arrow is not aimed here.
119
+ if (activeIndex === null) return;
120
+ e.preventDefault();
121
+ const action = actions[activeIndex];
122
+ if (action) onSelect(action);
123
+ }
124
+ };
125
+ window.addEventListener('keydown', handleKeyDown, true);
126
+ return () => window.removeEventListener('keydown', handleKeyDown, true);
127
+ }, [actions, activeIndex, onDismiss, onSelect]);
128
+
129
+ // Click outside dismisses. Deferred capture-phase registration, so the click
130
+ // that opened the dropdown does not immediately close it.
131
+ useEffect(() => {
132
+ const handlePointerDown = (e: PointerEvent) => {
133
+ if (ref.current && !ref.current.contains(e.target as Node)) onDismiss();
134
+ };
135
+ const timer = setTimeout(() => {
136
+ document.addEventListener('pointerdown', handlePointerDown, true);
137
+ }, 0);
138
+ return () => {
139
+ clearTimeout(timer);
140
+ document.removeEventListener('pointerdown', handlePointerDown, true);
141
+ };
142
+ }, [onDismiss]);
143
+
144
+ if (!position) return null;
145
+
146
+ return createPortal(
147
+ <div
148
+ ref={ref}
149
+ role="listbox"
150
+ aria-label="Actions"
151
+ data-selection-actions-picker
152
+ data-floating-picker="true"
153
+ className="fixed z-[100]"
154
+ style={{
155
+ top: position.top,
156
+ left: position.left,
157
+ width: PICKER_WIDTH,
158
+ ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
159
+ }}
160
+ onMouseDown={(e) => e.stopPropagation()}
161
+ >
162
+ <div className="bg-popover border border-border/60 rounded-lg shadow-xl overflow-hidden">
163
+ <SelectionActionsDropdown
164
+ actions={actions}
165
+ activeIndex={activeIndex}
166
+ onSelect={onSelect}
167
+ onHover={setActiveIndex}
168
+ />
169
+ </div>
170
+ </div>,
171
+ document.body,
172
+ );
173
+ };
@@ -59,6 +59,7 @@ import { DiagramBlockPending } from './diagram/DiagramPending';
59
59
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
60
60
  import { getIdentity } from '../utils/identity';
61
61
  import { type QuickLabel } from '../utils/quickLabels';
62
+ import type { SelectionAction } from '../utils/selectionActions';
62
63
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
63
64
  import { PinpointOverlay } from './PinpointOverlay';
64
65
  import { usePinpoint } from '../hooks/usePinpoint';
@@ -91,6 +92,18 @@ export interface ViewerAnnotationHeaderConfig {
91
92
 
92
93
  /** Public properties for the Markdown document Viewer. */
93
94
  export interface ViewerProps {
95
+ /**
96
+ * Opt-in host capability, passed straight through to the selection
97
+ * toolbars: the host's own commands for the current selection, rendered as
98
+ * one wand button that opens the package's dropdown. Absent → unchanged.
99
+ */
100
+ selectionActions?: SelectionAction[];
101
+ /**
102
+ * Whether the package's quick labels are offered on the selection toolbars
103
+ * (default true). `false` hides the Zap picker and the Alt+digit label
104
+ * shortcuts; the 👍 button is unaffected.
105
+ */
106
+ quickLabels?: boolean;
94
107
  blocks: Block[];
95
108
  markdown: string;
96
109
  frontmatter?: Frontmatter | null;
@@ -360,6 +373,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
360
373
  mode,
361
374
  inputMethod = 'drag',
362
375
  taterMode,
376
+ selectionActions,
377
+ quickLabels,
363
378
  globalAttachments = [],
364
379
  onAddGlobalAttachment,
365
380
  onRemoveGlobalAttachment,
@@ -1296,6 +1311,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1296
1311
  onClose={handleToolbarClose}
1297
1312
  onRequestComment={handleRequestComment}
1298
1313
  onQuickLabel={handleQuickLabel}
1314
+ selectionActions={selectionActions}
1315
+ quickLabels={quickLabels}
1299
1316
  copyText={toolbarState.selectionText}
1300
1317
  hideCopyButton={!isTouchDevice}
1301
1318
  closeOnScrollOut
@@ -1351,6 +1368,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1351
1368
  onClose={handleCodeBlockToolbarClose}
1352
1369
  onRequestComment={handleCodeBlockRequestComment}
1353
1370
  onQuickLabel={handleCodeBlockQuickLabel}
1371
+ selectionActions={selectionActions}
1372
+ quickLabels={quickLabels}
1354
1373
  isExiting={isCodeBlockToolbarExiting}
1355
1374
  onMouseEnter={() => {
1356
1375
  if (hoverTimeoutRef.current) {
@@ -24,6 +24,7 @@ import {
24
24
  type VimHudCommand,
25
25
  } from "../../utils/vimHud";
26
26
  import { AnnotationToolbar } from "../AnnotationToolbar";
27
+ import type { SelectionAction } from "../../utils/selectionActions";
27
28
  import { AttachmentsButton } from "../AttachmentsButton";
28
29
  import {
29
30
  CommentPopover,
@@ -265,6 +266,10 @@ export interface HtmlViewerProps {
265
266
  * to the bridge so the in-page toggle stops at the same number. Default
266
267
  * 16 (the package cap); absent leaves every message unchanged. */
267
268
  maxAdditionalTargets?: number;
269
+ /** Opt-in host capability, passed straight through to the selection
270
+ * toolbar: the host's own commands for the current selection, rendered as
271
+ * one wand button that opens the package's dropdown. Absent → unchanged. */
272
+ selectionActions?: SelectionAction[];
268
273
  /** scrollIntoView behavior when a selected annotation is scrolled into
269
274
  * view inside the page. Default 'smooth'; pass 'auto' to carry the
270
275
  * parent's reduced-motion preference across the iframe boundary. */
@@ -352,6 +357,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
352
357
  readOnly = false,
353
358
  onUnanchoredChange,
354
359
  maxAdditionalTargets,
360
+ selectionActions,
355
361
  scrollBehavior,
356
362
  title = "HTML Plan Viewer",
357
363
  bridgeScriptUrl,
@@ -1202,6 +1208,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1202
1208
  // wrapper filters by id as defense in depth, so no present or
1203
1209
  // future toolbar path can emit an arbitrary label here.
1204
1210
  commentOnly
1211
+ selectionActions={selectionActions}
1205
1212
  onQuickLabel={(label) => {
1206
1213
  if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
1207
1214
  }}
@@ -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.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",