@plannotator/ui 0.41.2 → 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.
- package/HANDOFF.md +187 -0
- package/README.md +37 -1
- package/components/AnnotationToolbar.tsx +78 -8
- package/components/CommentPopover.tsx +90 -14
- package/components/DiagramBlock.tsx +3 -1
- package/components/MentionPicker.tsx +110 -0
- package/components/SelectionActionsDropdown.tsx +173 -0
- package/components/Viewer.tsx +19 -0
- package/components/html-viewer/HtmlViewer.tsx +7 -0
- package/hooks/useLinkedDoc.ts +11 -6
- package/hooks/useMentionAutocomplete.ts +246 -0
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +32 -0
- package/utils/mentions.ts +149 -0
- package/utils/parser.ts +33 -0
- package/utils/selectionActions.ts +64 -0
- package/utils/sharing.ts +28 -1
|
@@ -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
|
+
};
|
package/components/Viewer.tsx
CHANGED
|
@@ -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
|
}}
|
package/hooks/useLinkedDoc.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { useState, useCallback, useRef } from "react";
|
|
10
10
|
import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
|
|
11
|
-
import
|
|
11
|
+
import { isDiagramRenderKind } from "@plannotator/core/annotatable";
|
|
12
|
+
import type { Annotation, DocumentRenderAs, ImageAttachment } from "../types";
|
|
12
13
|
import type { ViewerHandle } from "../components/Viewer";
|
|
13
14
|
import type { SidebarTab } from "./useSidebar";
|
|
14
15
|
import type { SourceSaveCapability } from "@plannotator/core/source-save";
|
|
@@ -18,7 +19,7 @@ export interface LinkedDocLoadData {
|
|
|
18
19
|
markdown?: string;
|
|
19
20
|
filepath?: string;
|
|
20
21
|
isConverted?: boolean;
|
|
21
|
-
renderAs?:
|
|
22
|
+
renderAs?: DocumentRenderAs;
|
|
22
23
|
rawHtml?: string;
|
|
23
24
|
shareHtml?: string;
|
|
24
25
|
sourceSave?: SourceSaveCapability;
|
|
@@ -72,10 +73,10 @@ export interface UseLinkedDocOptions {
|
|
|
72
73
|
setGlobalAttachments: (att: ImageAttachment[]) => void;
|
|
73
74
|
/** Current render mode + raw HTML of the base document. An HTML linked/folder file
|
|
74
75
|
* swaps these to render raw; back() restores the base values from this snapshot. */
|
|
75
|
-
renderAs:
|
|
76
|
+
renderAs: DocumentRenderAs;
|
|
76
77
|
rawHtml: string;
|
|
77
78
|
shareHtml: string;
|
|
78
|
-
setRenderAs: (r:
|
|
79
|
+
setRenderAs: (r: DocumentRenderAs) => void;
|
|
79
80
|
setRawHtml: (html: string) => void;
|
|
80
81
|
setShareHtml: (html: string) => void;
|
|
81
82
|
viewerRef: React.RefObject<ViewerHandle | null>;
|
|
@@ -105,7 +106,7 @@ interface SavedPlanState {
|
|
|
105
106
|
annotations: Annotation[];
|
|
106
107
|
selectedAnnotationId: string | null;
|
|
107
108
|
globalAttachments: ImageAttachment[];
|
|
108
|
-
renderAs:
|
|
109
|
+
renderAs: DocumentRenderAs;
|
|
109
110
|
rawHtml: string;
|
|
110
111
|
shareHtml: string;
|
|
111
112
|
}
|
|
@@ -382,7 +383,11 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
382
383
|
// Swap to linked doc — an .html file renders raw (HtmlViewer), a markdown
|
|
383
384
|
// file parses to blocks (Viewer). Drive renderAs/rawHtml per file so the
|
|
384
385
|
// App's renderAs === 'html' ? HtmlViewer : Viewer switch flips automatically.
|
|
385
|
-
|
|
386
|
+
// A diagram source (.mmd/.dot) keeps the markdown state path — the raw
|
|
387
|
+
// text is the document body — but names its engine so the App renders it
|
|
388
|
+
// as one diagram instead of parsing it as markdown.
|
|
389
|
+
const docRenderAs: DocumentRenderAs =
|
|
390
|
+
data.renderAs === 'html' ? 'html' : isDiagramRenderKind(data.renderAs) ? data.renderAs : 'markdown';
|
|
386
391
|
const hostMarkdown = docRenderAs === 'html' || !notifyDocumentLoaded ? undefined : onDocumentLoaded?.(data);
|
|
387
392
|
const nextMarkdown = notifyDocumentLoaded
|
|
388
393
|
? hostMarkdown ?? cached?.markdown ?? data.markdown ?? ''
|
|
@@ -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.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@lezer/highlight": "^1.2.3",
|
|
77
77
|
"@pierre/diffs": "1.3.6",
|
|
78
78
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
79
|
-
"@plannotator/core": "0.25.
|
|
79
|
+
"@plannotator/core": "0.25.5",
|
|
80
80
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
81
81
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
82
82
|
"@tanstack/react-table": "^8.21.3",
|