@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
|
@@ -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
|
-
|
|
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 = (
|
|
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
|
+
}
|