@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
|
@@ -9,6 +9,9 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
|
9
9
|
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
10
10
|
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
11
11
|
import type { SkillReferenceToken } from '../utils/skillReferences';
|
|
12
|
+
import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
|
|
13
|
+
import { MentionPicker } from './MentionPicker';
|
|
14
|
+
import type { MentionSource } from '../utils/mentions';
|
|
12
15
|
import {
|
|
13
16
|
hasPrimaryCoarsePointer,
|
|
14
17
|
shouldUseExpandedComposer,
|
|
@@ -51,8 +54,13 @@ interface CommentPopoverProps {
|
|
|
51
54
|
isGlobal: boolean;
|
|
52
55
|
/** Pre-filled text (for type-to-comment) */
|
|
53
56
|
initialText?: string;
|
|
54
|
-
/**
|
|
55
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Called on submit with comment text and optional images. The third
|
|
59
|
+
* argument carries the mention ids the body still tags, and is passed ONLY
|
|
60
|
+
* when a `mentionSource` is supplied — without one the call is the two
|
|
61
|
+
* arguments it has always been.
|
|
62
|
+
*/
|
|
63
|
+
onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void;
|
|
56
64
|
/**
|
|
57
65
|
* One-click "Looks good" action (comment-only HTML/live surfaces, where
|
|
58
66
|
* pinpoint clicks open this composer directly and never see the selection
|
|
@@ -77,6 +85,13 @@ interface CommentPopoverProps {
|
|
|
77
85
|
askAIDisabled?: boolean;
|
|
78
86
|
/** Opt-in: `/` and `$` skill-reference autocomplete (document UI surfaces). Off by default. */
|
|
79
87
|
skillReferences?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Opt-in host capability: an `@` mention source for this composer. Supplies
|
|
90
|
+
* the people, an optional honest-empty notice, and optional callbacks for
|
|
91
|
+
* the surviving mention ids and for a picked person the host blocks.
|
|
92
|
+
* Absent → no listener, no picker, no extra DOM: byte-identical composer.
|
|
93
|
+
*/
|
|
94
|
+
mentionSource?: MentionSource;
|
|
80
95
|
/** Opt-in (HTML multi-select): selected targets rendered as horizontally
|
|
81
96
|
* scrollable chips above the textarea. Absent → byte-identical composer. */
|
|
82
97
|
targetChips?: CommentTargetChip[];
|
|
@@ -166,6 +181,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
166
181
|
askAIContext,
|
|
167
182
|
askAIDisabled = false,
|
|
168
183
|
skillReferences = false,
|
|
184
|
+
mentionSource,
|
|
169
185
|
targetChips,
|
|
170
186
|
onRemoveTargetChip,
|
|
171
187
|
onHoverTargetChip,
|
|
@@ -451,14 +467,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
451
467
|
</div>
|
|
452
468
|
) : null;
|
|
453
469
|
|
|
470
|
+
const mentionAc = useMentionAutocomplete({
|
|
471
|
+
text,
|
|
472
|
+
setText,
|
|
473
|
+
textareaRef,
|
|
474
|
+
source: mentionSource,
|
|
475
|
+
});
|
|
476
|
+
|
|
454
477
|
const handleSubmit = useCallback(() => {
|
|
455
478
|
const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
|
|
456
479
|
if (hasUnsavedContent || canSubmitEmpty) {
|
|
457
480
|
if (draftKey) draftStore.delete(draftKey);
|
|
458
|
-
|
|
481
|
+
const submitImages = allowImages && images.length > 0 ? images : undefined;
|
|
482
|
+
// Without a mentionSource this is the two-argument call it always was.
|
|
483
|
+
if (mentionSource) onSubmit(text, submitImages, mentionAc.mentionIds);
|
|
484
|
+
else onSubmit(text, submitImages);
|
|
459
485
|
restoreOpeningFocus();
|
|
460
486
|
}
|
|
461
|
-
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
|
|
487
|
+
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus, mentionSource, mentionAc.mentionIds]);
|
|
462
488
|
|
|
463
489
|
const handleAskAI = useCallback(async () => {
|
|
464
490
|
const question = text.trim();
|
|
@@ -492,14 +518,31 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
492
518
|
textareaRef,
|
|
493
519
|
enabled: skillReferences,
|
|
494
520
|
});
|
|
521
|
+
const readComposerCaret = useCallback(() => {
|
|
522
|
+
skillAc.onSelect();
|
|
523
|
+
mentionAc.onSelect();
|
|
524
|
+
}, [mentionAc, skillAc]);
|
|
495
525
|
const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
|
|
526
|
+
const mentionListboxId = `${skillListboxId}-mentions`;
|
|
496
527
|
const activeSkillOptionId =
|
|
497
528
|
skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
|
|
498
529
|
? undefined
|
|
499
530
|
: `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
|
|
531
|
+
// At most one of the two menus can be open (their triggers are disjoint), so
|
|
532
|
+
// the textarea's ARIA relationship points at whichever one is. All three are
|
|
533
|
+
// `undefined`/false with no menu open, which is every render Plannotator
|
|
534
|
+
// makes without a `mentionSource`.
|
|
535
|
+
const activeMentionOptionId =
|
|
536
|
+
mentionAc.menu === null || mentionAc.menu.activeIndex === null
|
|
537
|
+
? undefined
|
|
538
|
+
: `${mentionListboxId}-option-${mentionAc.menu.activeIndex}`;
|
|
539
|
+
const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId;
|
|
540
|
+
const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
|
|
541
|
+
const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
|
|
500
542
|
|
|
501
543
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
502
544
|
if (skillAc.onKeyDown(e)) return;
|
|
545
|
+
if (mentionAc.onKeyDown(e)) return;
|
|
503
546
|
if (e.key === 'Escape') {
|
|
504
547
|
e.stopPropagation();
|
|
505
548
|
if (mode === 'dialog') {
|
|
@@ -634,18 +677,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
634
677
|
<ComposerTextarea
|
|
635
678
|
textareaRef={focusOnMountRef}
|
|
636
679
|
value={text}
|
|
637
|
-
onChange={(e) => { setText(e.target.value);
|
|
680
|
+
onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
|
|
638
681
|
onKeyDown={handleKeyDown}
|
|
639
|
-
onSelectCaret={
|
|
682
|
+
onSelectCaret={readComposerCaret}
|
|
640
683
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
641
684
|
sizeClassName="min-h-32 max-h-full"
|
|
642
685
|
skillReferences={skillReferences}
|
|
643
686
|
tokens={skillAc.referenceTokens}
|
|
644
|
-
listboxId={
|
|
645
|
-
listboxOpen={
|
|
646
|
-
activeOptionId={
|
|
687
|
+
listboxId={composerListboxId}
|
|
688
|
+
listboxOpen={composerListboxOpen}
|
|
689
|
+
activeOptionId={activeComposerOptionId}
|
|
647
690
|
/>
|
|
648
691
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
692
|
+
{mentionAc.menu && (
|
|
693
|
+
<MentionPicker
|
|
694
|
+
id={mentionListboxId}
|
|
695
|
+
people={mentionAc.menu.items}
|
|
696
|
+
emptyNotice={mentionAc.menu.emptyNotice}
|
|
697
|
+
active={mentionAc.menu.activeIndex}
|
|
698
|
+
anchor={mentionAc.menu.anchor}
|
|
699
|
+
onPick={(person) => {
|
|
700
|
+
const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
|
|
701
|
+
if (index >= 0) mentionAc.select(index);
|
|
702
|
+
}}
|
|
703
|
+
onHover={() => {}}
|
|
704
|
+
/>
|
|
705
|
+
)}
|
|
649
706
|
</div>
|
|
650
707
|
|
|
651
708
|
{/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
|
|
@@ -784,18 +841,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
784
841
|
<ComposerTextarea
|
|
785
842
|
textareaRef={focusOnMountRef}
|
|
786
843
|
value={text}
|
|
787
|
-
onChange={(e) => { setText(e.target.value);
|
|
844
|
+
onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
|
|
788
845
|
onKeyDown={handleKeyDown}
|
|
789
|
-
onSelectCaret={
|
|
846
|
+
onSelectCaret={readComposerCaret}
|
|
790
847
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
791
848
|
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
792
849
|
skillReferences={skillReferences}
|
|
793
850
|
tokens={skillAc.referenceTokens}
|
|
794
|
-
listboxId={
|
|
795
|
-
listboxOpen={
|
|
796
|
-
activeOptionId={
|
|
851
|
+
listboxId={composerListboxId}
|
|
852
|
+
listboxOpen={composerListboxOpen}
|
|
853
|
+
activeOptionId={activeComposerOptionId}
|
|
797
854
|
/>
|
|
798
855
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
856
|
+
{mentionAc.menu && (
|
|
857
|
+
<MentionPicker
|
|
858
|
+
id={mentionListboxId}
|
|
859
|
+
people={mentionAc.menu.items}
|
|
860
|
+
emptyNotice={mentionAc.menu.emptyNotice}
|
|
861
|
+
active={mentionAc.menu.activeIndex}
|
|
862
|
+
anchor={mentionAc.menu.anchor}
|
|
863
|
+
onPick={(person) => {
|
|
864
|
+
const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
|
|
865
|
+
if (index >= 0) mentionAc.select(index);
|
|
866
|
+
}}
|
|
867
|
+
onHover={() => {}}
|
|
868
|
+
/>
|
|
869
|
+
)}
|
|
799
870
|
</div>
|
|
800
871
|
|
|
801
872
|
{/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
|
|
@@ -1013,6 +1084,11 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1013
1084
|
return (
|
|
1014
1085
|
<textarea
|
|
1015
1086
|
data-pn-mobile-editable="true"
|
|
1087
|
+
// Absent any open menu these are all undefined, so the plain textarea
|
|
1088
|
+
// renders exactly the attributes it always did.
|
|
1089
|
+
aria-autocomplete={listboxOpen ? 'list' : undefined}
|
|
1090
|
+
aria-controls={listboxOpen ? listboxId : undefined}
|
|
1091
|
+
aria-activedescendant={activeOptionId}
|
|
1016
1092
|
ref={attachRef}
|
|
1017
1093
|
value={value}
|
|
1018
1094
|
onChange={onChange}
|
|
@@ -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,8 @@ 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';
|
|
63
|
+
import type { MentionSource } from '../utils/mentions';
|
|
62
64
|
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
63
65
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
64
66
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
@@ -91,6 +93,28 @@ export interface ViewerAnnotationHeaderConfig {
|
|
|
91
93
|
|
|
92
94
|
/** Public properties for the Markdown document Viewer. */
|
|
93
95
|
export interface ViewerProps {
|
|
96
|
+
/**
|
|
97
|
+
* Opt-in host capability, passed straight through to the selection
|
|
98
|
+
* toolbars: the host's own commands for the current selection, rendered as
|
|
99
|
+
* one wand button that opens the package's dropdown. Absent → unchanged.
|
|
100
|
+
*/
|
|
101
|
+
selectionActions?: SelectionAction[];
|
|
102
|
+
/** Opt-in host capability: the glyph on the `selectionActions` button on
|
|
103
|
+
* both toolbars. Absent → the package's own wand. */
|
|
104
|
+
selectionActionsIcon?: React.ReactNode;
|
|
105
|
+
/**
|
|
106
|
+
* Whether the package's quick labels are offered on the selection toolbars
|
|
107
|
+
* (default true). `false` hides the Zap picker and the Alt+digit label
|
|
108
|
+
* shortcuts; the 👍 button is unaffected.
|
|
109
|
+
*/
|
|
110
|
+
quickLabels?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Opt-in host capability, forwarded to BOTH comment composers this viewer
|
|
113
|
+
* mounts (the text-selection composer and the global / code-block one): the
|
|
114
|
+
* `@` mention source for the composer's picker. The picked ids ride onto the
|
|
115
|
+
* created annotation as `Annotation.mentions`. Absent → unchanged.
|
|
116
|
+
*/
|
|
117
|
+
mentionSource?: MentionSource;
|
|
94
118
|
blocks: Block[];
|
|
95
119
|
markdown: string;
|
|
96
120
|
frontmatter?: Frontmatter | null;
|
|
@@ -360,6 +384,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
360
384
|
mode,
|
|
361
385
|
inputMethod = 'drag',
|
|
362
386
|
taterMode,
|
|
387
|
+
selectionActions,
|
|
388
|
+
selectionActionsIcon,
|
|
389
|
+
quickLabels,
|
|
390
|
+
mentionSource,
|
|
363
391
|
globalAttachments = [],
|
|
364
392
|
onAddGlobalAttachment,
|
|
365
393
|
onRemoveGlobalAttachment,
|
|
@@ -543,6 +571,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
543
571
|
images?: ImageAttachment[],
|
|
544
572
|
isQuickLabel?: boolean,
|
|
545
573
|
quickLabelTip?: string,
|
|
574
|
+
mentions?: readonly string[],
|
|
546
575
|
) => {
|
|
547
576
|
if (readOnlyRef.current) return;
|
|
548
577
|
|
|
@@ -562,6 +591,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
562
591
|
createdA: Date.now(),
|
|
563
592
|
author: getIdentity(),
|
|
564
593
|
images,
|
|
594
|
+
// Host capability: present only when a mentionSource was supplied AND a
|
|
595
|
+
// token survived, so a code-block comment without one is unchanged.
|
|
596
|
+
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
565
597
|
...(isQuickLabel ? { isQuickLabel: true } : {}),
|
|
566
598
|
...(quickLabelTip ? { quickLabelTip } : {}),
|
|
567
599
|
};
|
|
@@ -942,7 +974,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
942
974
|
setCodeBlockToolbar(null);
|
|
943
975
|
};
|
|
944
976
|
|
|
945
|
-
const handleViewerCommentSubmit = (
|
|
977
|
+
const handleViewerCommentSubmit = (
|
|
978
|
+
text: string,
|
|
979
|
+
images?: ImageAttachment[],
|
|
980
|
+
mentions?: readonly string[],
|
|
981
|
+
) => {
|
|
946
982
|
if (readOnlyRef.current || !viewerCommentPopover) return;
|
|
947
983
|
|
|
948
984
|
if (viewerCommentPopover.isGlobal) {
|
|
@@ -960,12 +996,22 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
960
996
|
createdA: Date.now(),
|
|
961
997
|
author: getIdentity(),
|
|
962
998
|
images,
|
|
999
|
+
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
963
1000
|
};
|
|
964
1001
|
onAddAnnotation(newAnnotation);
|
|
965
1002
|
} else if (viewerCommentPopover.codeBlock) {
|
|
966
1003
|
const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
|
|
967
1004
|
if (codeEl) {
|
|
968
|
-
applyCodeBlockAnnotation(
|
|
1005
|
+
applyCodeBlockAnnotation(
|
|
1006
|
+
viewerCommentPopover.codeBlock.block.id,
|
|
1007
|
+
codeEl,
|
|
1008
|
+
AnnotationType.COMMENT,
|
|
1009
|
+
text,
|
|
1010
|
+
images,
|
|
1011
|
+
undefined,
|
|
1012
|
+
undefined,
|
|
1013
|
+
mentions,
|
|
1014
|
+
);
|
|
969
1015
|
}
|
|
970
1016
|
}
|
|
971
1017
|
|
|
@@ -1296,6 +1342,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1296
1342
|
onClose={handleToolbarClose}
|
|
1297
1343
|
onRequestComment={handleRequestComment}
|
|
1298
1344
|
onQuickLabel={handleQuickLabel}
|
|
1345
|
+
selectionActions={selectionActions}
|
|
1346
|
+
selectionActionsIcon={selectionActionsIcon}
|
|
1347
|
+
quickLabels={quickLabels}
|
|
1299
1348
|
copyText={toolbarState.selectionText}
|
|
1300
1349
|
hideCopyButton={!isTouchDevice}
|
|
1301
1350
|
closeOnScrollOut
|
|
@@ -1351,6 +1400,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1351
1400
|
onClose={handleCodeBlockToolbarClose}
|
|
1352
1401
|
onRequestComment={handleCodeBlockRequestComment}
|
|
1353
1402
|
onQuickLabel={handleCodeBlockQuickLabel}
|
|
1403
|
+
selectionActions={selectionActions}
|
|
1404
|
+
selectionActionsIcon={selectionActionsIcon}
|
|
1405
|
+
quickLabels={quickLabels}
|
|
1354
1406
|
isExiting={isCodeBlockToolbarExiting}
|
|
1355
1407
|
onMouseEnter={() => {
|
|
1356
1408
|
if (hoverTimeoutRef.current) {
|
|
@@ -1428,6 +1480,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1428
1480
|
draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
|
|
1429
1481
|
onSubmit={hookCommentSubmit}
|
|
1430
1482
|
onClose={hookCommentClose}
|
|
1483
|
+
mentionSource={mentionSource}
|
|
1431
1484
|
allowImages={allowImages}
|
|
1432
1485
|
skillReferences
|
|
1433
1486
|
onAskAI={onAskAI}
|
|
@@ -1452,6 +1505,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1452
1505
|
}`}
|
|
1453
1506
|
onSubmit={handleViewerCommentSubmit}
|
|
1454
1507
|
onClose={handleViewerCommentClose}
|
|
1508
|
+
mentionSource={mentionSource}
|
|
1455
1509
|
allowImages={allowImages}
|
|
1456
1510
|
skillReferences
|
|
1457
1511
|
onAskAI={onAskAI}
|