@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.
- package/HANDOFF.md +186 -0
- package/README.md +37 -1
- package/components/AnnotationToolbar.tsx +78 -8
- package/components/CommentPopover.tsx +90 -14
- 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/useMentionAutocomplete.ts +246 -0
- package/package.json +1 -1
- package/styles.css +1 -1
- package/types.ts +12 -0
- package/utils/mentions.ts +149 -0
- package/utils/selectionActions.ts +64 -0
package/HANDOFF.md
CHANGED
|
@@ -213,6 +213,8 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
213
213
|
| `components/HtmlSurfaceControls` | The eye / refresh / pen header controls for an HTML surface, with per-string `labels` overrides. Presentation only. See "HTML annotation parity seams". |
|
|
214
214
|
| `hooks/useHtmlRefresh` | Re-fetch a rendered HTML document through a host-supplied `fetchSnapshot`, remount the viewer on a reload generation, acknowledge the restore report once. See "HTML annotation parity seams". |
|
|
215
215
|
| `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A toggles annotate mode, Mod+Shift+X shows/hides the tools). Pure: React plus `utils/platform`; no backend. |
|
|
216
|
+
| `utils/selectionActions` + `components/SelectionActionsDropdown` | The host selection-actions seam: `SelectionAction`, `SelectionActionContext`, the pure `buildSelectionActionContext`, and the dropdown `AnnotationToolbar` opens. Pure React; no backend. *(Blessed in 0.43.0.)* |
|
|
217
|
+
| `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource`: the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
|
|
216
218
|
| `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
|
|
217
219
|
| `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
|
|
218
220
|
| `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
|
|
@@ -873,8 +875,192 @@ to fall back on:
|
|
|
873
875
|
|
|
874
876
|
---
|
|
875
877
|
|
|
878
|
+
## Host toolbar seams (0.43.0)
|
|
879
|
+
|
|
880
|
+
Two additive props, ruled in together by Plannotator's owner **on one
|
|
881
|
+
condition: each is an opt-in host capability that changes nothing for
|
|
882
|
+
Plannotator's own users when it is not supplied.** Plannotator is the provider
|
|
883
|
+
of these capabilities; it is not a product that uses them. It passes neither
|
|
884
|
+
prop anywhere, and `packages/editor` / `packages/review-editor` are untouched
|
|
885
|
+
by this release. Core is UNCHANGED at `0.25.5`, so **ui 0.43.0 publishes
|
|
886
|
+
alone**.
|
|
887
|
+
|
|
888
|
+
### 1. `AnnotationToolbar` `selectionActions` — the host's own commands on a selection
|
|
889
|
+
|
|
890
|
+
```ts
|
|
891
|
+
import type { SelectionAction, SelectionActionContext } from "@plannotator/ui/types";
|
|
892
|
+
// (also @plannotator/ui/utils/selectionActions)
|
|
893
|
+
|
|
894
|
+
interface SelectionActionContext {
|
|
895
|
+
text: string; // the toolbar's copy text, else the element's text
|
|
896
|
+
blockId: string; // the enclosing [data-block-id], '' on raw-HTML surfaces
|
|
897
|
+
startOffset: number; // offset of the selection inside that block's text
|
|
898
|
+
endOffset: number; // startOffset + text.length
|
|
899
|
+
element: HTMLElement; // the element the toolbar is anchored to
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
interface SelectionAction {
|
|
903
|
+
id: string;
|
|
904
|
+
label: string;
|
|
905
|
+
detail?: string; // dimmed second line
|
|
906
|
+
icon?: React.ReactNode; // a colored accent bar is drawn when absent
|
|
907
|
+
onSelect(ctx: SelectionActionContext): void;
|
|
908
|
+
}
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
One toolbar button (a wand, `data-selection-actions`) opens the package's own
|
|
912
|
+
dropdown directly below it, in the quick-label picker's placement and chrome
|
|
913
|
+
(`components/SelectionActionsDropdown`: `SelectionActionsDropdown` is the list,
|
|
914
|
+
`FloatingSelectionActionsPicker` the portaled, viewport-clamped, flip-above
|
|
915
|
+
picker; rows carry `data-selection-action="<id>"` under `role="listbox"`).
|
|
916
|
+
Selecting an item calls `onSelect(ctx)` and closes the toolbar exactly as a
|
|
917
|
+
quick label does — **the package creates no annotation**; what an action means
|
|
918
|
+
is entirely the host's business.
|
|
919
|
+
|
|
920
|
+
- **Keyboard:** ArrowDown / ArrowUp move, Enter invokes, Escape closes the
|
|
921
|
+
dropdown (and only the dropdown — the toolbar stays open). Nothing is
|
|
922
|
+
preselected until the first arrow, the package-wide rule, so a stray Enter
|
|
923
|
+
over an open dropdown never fires a host command. Pointer hover highlights a
|
|
924
|
+
row and a click invokes it directly. While the dropdown is open, the
|
|
925
|
+
toolbar's own type-to-comment and Alt+digit listeners stand down, the same
|
|
926
|
+
way they do for `FloatingQuickLabelPicker`.
|
|
927
|
+
- **Where it sits:** the wand takes the slot the quick-labels Zap occupied and
|
|
928
|
+
the Zap moves one place right (`Copy | Delete | Comment | Actions | Quick
|
|
929
|
+
label | 👍 | Cancel`). Chosen over "actions to the right of the Zap" so that
|
|
930
|
+
ONE geometry rule covers both host configurations: with `quickLabels: false`
|
|
931
|
+
— the expected host setup — the wand sits exactly where the Zap sat, and a
|
|
932
|
+
host that keeps both gets its own actions in the primary slot it opted into.
|
|
933
|
+
- `undefined` or `[]` renders no button at all, and the empty array is pinned
|
|
934
|
+
by a test because "the host has no actions right now" is a real state.
|
|
935
|
+
- **The context is derived, not threaded.** `buildSelectionActionContext`
|
|
936
|
+
(`utils/selectionActions`, pure) walks up from the anchor element for
|
|
937
|
+
`[data-block-id]` and computes the offset by splitting the block's text on
|
|
938
|
+
the selection — deliberately the same arithmetic
|
|
939
|
+
`createAnnotationFromSource` uses, so an action sees the coordinates an
|
|
940
|
+
annotation created from that same selection would carry. On a surface with
|
|
941
|
+
no blocks (raw HTML) it degrades to `blockId: ''` and `startOffset: 0`
|
|
942
|
+
(`endOffset` is then the selection's length; an HTML annotation itself
|
|
943
|
+
stores `0`/`0`). The one deliberate deviation from the annotation path is
|
|
944
|
+
a selection the block does not contain — one spanning two blocks — where
|
|
945
|
+
the annotation path reports `blockText.length` and a host gets `0`.
|
|
946
|
+
|
|
947
|
+
### 2. `AnnotationToolbar` `quickLabels` — the opt-out switch
|
|
948
|
+
|
|
949
|
+
`quickLabels?: boolean`, default `true`. `false` hides the Zap picker button
|
|
950
|
+
**and** makes the Alt+digit label shortcuts inert on that toolbar (hiding the
|
|
951
|
+
button while leaving the keys live was the obvious bug; a test pins both). It
|
|
952
|
+
does not touch the one-click 👍, which is a separate affordance, and it does
|
|
953
|
+
not clamp editor mode — a host that persists `'quickLabel'` mode still keeps
|
|
954
|
+
that state out of `Viewer`, exactly as with `AnnotationToolstrip`'s
|
|
955
|
+
`hideQuickLabel` (0.35.0).
|
|
956
|
+
|
|
957
|
+
### 3. `CommentPopover` `mentionSource` — an `@` mention source for the composer
|
|
958
|
+
|
|
959
|
+
```ts
|
|
960
|
+
import type { MentionPerson, MentionSource } from "@plannotator/ui/types";
|
|
961
|
+
// (also @plannotator/ui/utils/mentions)
|
|
962
|
+
|
|
963
|
+
interface MentionPerson {
|
|
964
|
+
readonly id: string; // opaque host id, reported back verbatim
|
|
965
|
+
readonly kind: "user" | "agent"; // agents are never taggable in a comment
|
|
966
|
+
readonly label: string;
|
|
967
|
+
readonly detail: string | null; // right-aligned hint (an email, "Agent")
|
|
968
|
+
readonly canOpen: boolean; // host access data; see onPickBlocked
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
interface MentionSource {
|
|
972
|
+
readonly people: readonly MentionPerson[];
|
|
973
|
+
readonly emptyNotice?: string | null; // honest-empty row
|
|
974
|
+
readonly onMentionsChange?: (ids: readonly string[]) => void;
|
|
975
|
+
readonly onPickBlocked?: (person: MentionPerson) => void;
|
|
976
|
+
}
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
This is the shape the host's own reply box already uses (`MentionPerson` is
|
|
980
|
+
copied field for field from its `plannotator/mention-extension.ts`), so the
|
|
981
|
+
host fills `people` from its existing candidate hook and nothing has to be
|
|
982
|
+
mapped. The **package owns the typing rules and the picker**; the host owns
|
|
983
|
+
the people and what a mention means.
|
|
984
|
+
|
|
985
|
+
- **The grammar is ported, not reinvented** (`utils/mentions`, pure, unit
|
|
986
|
+
tested): the same `MENTION_QUERY_RE = /@[\w .-]*$/`, the same word-boundary
|
|
987
|
+
guard that makes `a@b.com` never open a menu, the same users-only /
|
|
988
|
+
not-already-tagged filtering on label OR detail, the same `@Label ` insertion
|
|
989
|
+
with the caret after it, the same `sanitizeMentionLabel`, and the same
|
|
990
|
+
surviving-token rule — deleting a token untags that person, so the body and
|
|
991
|
+
the reported ids can never disagree about who was named.
|
|
992
|
+
- **The picker** (`components/MentionPicker`) is portaled and `position:
|
|
993
|
+
fixed`, measured from the textarea's rect, above it by default and below
|
|
994
|
+
when there is not 196px of headroom — the composer card clips its own box,
|
|
995
|
+
so a menu positioned inside the textarea's wrapper is cut off. `role=
|
|
996
|
+
"listbox"` with `data-mention-picker`, rows `data-mention-option="<id>"`, the
|
|
997
|
+
empty notice `data-mention-empty` (one non-selectable row; with `people: []`
|
|
998
|
+
and no notice the menu simply stays closed).
|
|
999
|
+
- **Keyboard** (`hooks/useMentionAutocomplete`, modelled on
|
|
1000
|
+
`useSkillReferenceAutocomplete` and living in the same textarea beside it —
|
|
1001
|
+
`handleKeyDown` offers the event to the skill hook first, then this one):
|
|
1002
|
+
nothing is preselected, so Enter is a newline and Tab leaves the field until
|
|
1003
|
+
an arrow engages a row; ArrowDown from none lands on the first row, ArrowUp
|
|
1004
|
+
on the last; a mouse pick uses `mousedown` + `preventDefault` so it beats the
|
|
1005
|
+
blur. **One deliberate difference from the `/` and `$` trigger:** the arrows
|
|
1006
|
+
engage this menu even on a bare `@`, and Escape closes it whenever it is
|
|
1007
|
+
visible. `$` and `/` are ordinary prose characters whose menu must yield the
|
|
1008
|
+
arrows back to caret navigation; `@` at a word boundary is an unambiguous tag
|
|
1009
|
+
gesture, and typing `@` then ArrowDown is how the host's own reply box
|
|
1010
|
+
behaves.
|
|
1011
|
+
- **`onPickBlocked` is the no-access rule.** When a `canOpen: false` person is
|
|
1012
|
+
picked AND the host supplied `onPickBlocked`, the handler fires and
|
|
1013
|
+
**nothing is inserted** (the host shows its own no-access dialog). Without
|
|
1014
|
+
the handler such a person inserts like anyone else — the package never
|
|
1015
|
+
renders a disabled row it cannot explain. Both branches are pinned by tests.
|
|
1016
|
+
- **The ids reach the host two ways.** `onMentionsChange(ids)` fires on every
|
|
1017
|
+
text change with the surviving ids, and `onSubmit` gained an optional THIRD
|
|
1018
|
+
argument: `onSubmit(text, images?, mentions?)`. The third argument is passed
|
|
1019
|
+
**only when `mentionSource` is supplied** — without one the call is the
|
|
1020
|
+
two-argument call it has always been (`arguments.length === 2`, pinned).
|
|
1021
|
+
Hosts can use either; `onMentionsChange` alone is enough for a host that
|
|
1022
|
+
keeps its draft state outside the popover.
|
|
1023
|
+
- Nothing is wired to Plannotator data: there is no mention provider in this
|
|
1024
|
+
repo, and `configurePlannotatorUI` gains no seam for one. A host passes the
|
|
1025
|
+
prop where it renders the composer, the `HtmlViewer` / `Viewer` pattern.
|
|
1026
|
+
|
|
1027
|
+
### Threading points
|
|
1028
|
+
|
|
1029
|
+
- `AnnotationToolbar` (`selectionActions`, `quickLabels`) — the props live here.
|
|
1030
|
+
- `Viewer` forwards both to BOTH of its toolbars (the text-selection toolbar
|
|
1031
|
+
and the code-block hover toolbar).
|
|
1032
|
+
- `HtmlViewer` forwards `selectionActions` to its selection toolbar. It does
|
|
1033
|
+
not take `quickLabels`: that surface is already `commentOnly`, which hides
|
|
1034
|
+
the Zap and the Alt+digit keys outright.
|
|
1035
|
+
- `plan-diff/PlanCleanDiffView` mounts a toolbar too and is deliberately NOT
|
|
1036
|
+
threaded: it is a Plannotator-only surface (the plan-version diff) and is not
|
|
1037
|
+
on the supported-import list. Ask if a host needs it.
|
|
1038
|
+
- `CommentPopover` (`mentionSource`). `Viewer` does NOT forward it: the viewer
|
|
1039
|
+
owns several composers and a per-composer decision belongs to the host that
|
|
1040
|
+
renders them. Ask if you would rather pass it once on `Viewer`.
|
|
1041
|
+
|
|
1042
|
+
### The no-op guarantee, and how it is pinned
|
|
1043
|
+
|
|
1044
|
+
With neither prop supplied, the rendered DOM of both components is **byte-for-byte
|
|
1045
|
+
what `origin/main` renders**: the same components were mounted on the base commit
|
|
1046
|
+
and on this branch in the same harness and their `outerHTML` diffed to zero
|
|
1047
|
+
(`.annotation-toolbar` + `[data-comment-popover]`, 6376 bytes each, identical).
|
|
1048
|
+
On top of that, committed tests pin the structure rather than a snapshot:
|
|
1049
|
+
the default toolbar's button set and order (`Copy, Delete, Comment, Quick
|
|
1050
|
+
label, Looks good, Cancel`), the absence of `[data-selection-actions]` and of
|
|
1051
|
+
the picker, the exact attribute list on the Zap button, and — for the composer —
|
|
1052
|
+
that typing `@` opens nothing and that submit stays a two-argument call.
|
|
1053
|
+
`useMentionAutocomplete` with no source registers no listener, opens no menu
|
|
1054
|
+
state and returns one frozen empty id array; `AnnotationToolbar` renders no
|
|
1055
|
+
extra element and spreads no extra attributes.
|
|
1056
|
+
|
|
1057
|
+
Tests: `utils/mentions.test.ts` (11, DOM-free),
|
|
1058
|
+
`components/AnnotationToolbar.selectionActions.test.tsx` (8, DOM-gated),
|
|
1059
|
+
`components/CommentPopover.mentionSource.test.tsx` (11, DOM-gated).
|
|
1060
|
+
|
|
876
1061
|
## Publishing & versioning
|
|
877
1062
|
|
|
1063
|
+
- **ui 0.43.0 (host toolbar seams): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive: `selectionActions` + `quickLabels` on `AnnotationToolbar` (forwarded by `Viewer`; `selectionActions` also by `HtmlViewer`), `mentionSource` on `CommentPopover`, an optional third `mentions` argument on that component's `onSubmit`, and the new supported modules `utils/selectionActions`, `utils/mentions`, `components/SelectionActionsDropdown`, `components/MentionPicker`, `hooks/useMentionAutocomplete`. Nothing is removed and Plannotator passes none of it. See "Host toolbar seams (0.43.0)".
|
|
878
1064
|
- **core 0.25.5 / ui 0.42.0 (diagram FILES, `.mmd`/`.mermaid`/`.dot`/`.gv`): additive on both packages, so the next publish is core-first.** `@plannotator/core/annotatable` gains `DiagramRenderKind`, `diagramRenderKindForPath`, `isDiagramRenderKind` and `annotateDiagramRenderKind`, and its built-in annotatable sets now include the four diagram extensions (`shouldStripFrontmatter` returns false for them — Mermaid's `--- … ---` config block is content — and they can no longer be registered through `markdownExtensions`). `@plannotator/ui` gains `diagramDocumentBlocks(text, kind)` on `utils/parser` (the ONE `code` block a whole-file diagram source renders as), `shareableDocumentMarkdown(markdown, renderAs)` on `utils/sharing`, the `DocumentRenderAs` type on `types` (`'markdown' | 'html' | DiagramRenderKind`, re-exporting core's kind), and an optional `Block.diagramSourceLineOffset` that `DiagramBlock` prefers over `Block.startLine` when resolving a diagram comment's `sourceLine` (unset on every parser-produced fence, so fences are byte-identical). `useLinkedDoc`'s `renderAs`/`setRenderAs`/`LinkedDocLoadData.renderAs` widen from `'markdown' | 'html'` to `DocumentRenderAs` — source-compatible for a host that only ever passes the old two, but a host whose own state is typed `'markdown' | 'html'` must widen its setter. Since core changes, **publish `core` first** and update UI's exact core dependency before packing ui.
|
|
879
1065
|
- **The current pair is `@plannotator/ui` `0.41.2` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.1, so 0.41.2 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.2 ships the palette-derived Mermaid node shadow at a default of 70% (`DEFAULT_MERMAID_SHADOW_AMOUNT`) and the Settings → Display "Diagram Shadow" control (0 / 40 / 70 / 100); see "Node shadow (the one thing that is not a colour)" under "Theme-aware Mermaid diagrams (0.40.0)" for the mapping — no API removal, only additive exports (`buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`, `utils/diagramShadow`).
|
|
880
1066
|
- The pair 0.41.1 shipped as was `@plannotator/ui` `0.41.1` on `@plannotator/core` `0.25.4`. Core is UNCHANGED from 0.41.0, so 0.41.1 published alone (`ui` only; core 0.25.4 must already be published). 0.41.1 is two fixes over 0.41.0 with no API change — the diagram engine is loaded lazily by the first diagram fence instead of riding every document read, and a press on the canvas's own controls no longer comments on the part behind them; see "0.41.1 — the engine is lazy, and the controls are not part of the diagram". The 0.41.0 notes below still describe the engine itself.
|
package/README.md
CHANGED
|
@@ -220,6 +220,42 @@ the in-flow host integration.
|
|
|
220
220
|
|
|
221
221
|
One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode, shadowAmount? }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.30.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
|
|
222
222
|
|
|
223
|
+
### Host toolbar seams (0.43.0)
|
|
224
|
+
|
|
225
|
+
Two opt-in props for hosts that want their own commands and their own people
|
|
226
|
+
inside the annotation UI. Both default to today's behavior — pass neither and
|
|
227
|
+
the toolbar and the comment composer render byte-for-byte what they rendered
|
|
228
|
+
in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
229
|
+
|
|
230
|
+
- **`AnnotationToolbar` `selectionActions`** (forwarded by `Viewer` to both its
|
|
231
|
+
toolbars, and by `HtmlViewer` to the HTML selection toolbar): an array of
|
|
232
|
+
`{ id, label, detail?, icon?, onSelect(ctx) }`. They render as ONE wand
|
|
233
|
+
button (`data-selection-actions`) in the slot the quick-labels Zap occupied,
|
|
234
|
+
opening the package's dropdown below it (arrows + Enter + Escape, nothing
|
|
235
|
+
preselected until the first arrow). `onSelect` receives
|
|
236
|
+
`{ text, blockId, startOffset, endOffset, element }` — the same coordinates
|
|
237
|
+
an annotation created from that selection would carry (`blockId: ''` and
|
|
238
|
+
`startOffset: 0` on a surface with no blocks, such as raw HTML) — and the
|
|
239
|
+
toolbar closes. **The package creates no annotation:** what an action does is yours.
|
|
240
|
+
An empty array renders no button.
|
|
241
|
+
- **`AnnotationToolbar` `quickLabels`** (default `true`): `false` hides the Zap
|
|
242
|
+
picker and makes the Alt+digit label shortcuts inert on that toolbar. The
|
|
243
|
+
one-click 👍 is unaffected, and mode state is still yours to clamp.
|
|
244
|
+
- **`CommentPopover` `mentionSource`**: `{ people, emptyNotice?,
|
|
245
|
+
onMentionsChange?, onPickBlocked? }` over `MentionPerson`
|
|
246
|
+
(`{ id, kind, label, detail, canOpen }`). Typing `@` at a word boundary opens
|
|
247
|
+
a portaled picker measured from the textarea; picking inserts the readable
|
|
248
|
+
`@Label ` token, and the ids ride `onMentionsChange(ids)` plus an optional
|
|
249
|
+
third `onSubmit(text, images?, mentions?)` argument that is passed **only**
|
|
250
|
+
when a source is supplied. Deleting a token untags that person. A
|
|
251
|
+
`canOpen: false` person inserts nothing when you supply `onPickBlocked` (your
|
|
252
|
+
no-access dialog) and inserts normally when you do not. Not wired to any
|
|
253
|
+
Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
|
|
254
|
+
render the composer.
|
|
255
|
+
|
|
256
|
+
See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
|
|
257
|
+
rules and the threading points.
|
|
258
|
+
|
|
223
259
|
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
224
260
|
|
|
225
261
|
The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
|
|
@@ -255,7 +291,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
255
291
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
256
292
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
|
|
257
293
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
258
|
-
- Currently `@plannotator/ui` 0.
|
|
294
|
+
- Currently `@plannotator/ui` 0.43.0 depends exactly on `@plannotator/core` 0.25.5. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
|
|
259
295
|
|
|
260
296
|
## The one rule
|
|
261
297
|
|
|
@@ -6,6 +6,8 @@ import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quick
|
|
|
6
6
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
7
7
|
import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
|
|
8
8
|
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
|
|
9
|
+
import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
|
|
10
|
+
import { buildSelectionActionContext, type SelectionAction } from "../utils/selectionActions";
|
|
9
11
|
|
|
10
12
|
type PositionMode = 'center-above' | 'top-right';
|
|
11
13
|
|
|
@@ -33,6 +35,21 @@ interface AnnotationToolbarProps {
|
|
|
33
35
|
* the one label affordance restored to these surfaces. Markdown surfaces
|
|
34
36
|
* keep the full toolbar. */
|
|
35
37
|
commentOnly?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Opt-in host capability: the host's own commands for this selection,
|
|
40
|
+
* rendered as ONE wand button (`data-selection-actions`) that opens the
|
|
41
|
+
* package's dropdown below it. Selecting an item calls `onSelect` with the
|
|
42
|
+
* selection context and closes the toolbar, exactly as a quick label does;
|
|
43
|
+
* the package creates no annotation — the host decides what an action means.
|
|
44
|
+
* Absent or empty → no button, no dropdown: today's toolbar.
|
|
45
|
+
*/
|
|
46
|
+
selectionActions?: SelectionAction[];
|
|
47
|
+
/**
|
|
48
|
+
* Whether the package's own quick labels are offered (default true).
|
|
49
|
+
* `false` hides the Zap picker button and the Alt+digit label shortcuts on
|
|
50
|
+
* this toolbar. The one-click 👍 is a separate affordance and is unaffected.
|
|
51
|
+
*/
|
|
52
|
+
quickLabels?: boolean;
|
|
36
53
|
/** Hide the copy button (set when a keyboard copy handler exists) */
|
|
37
54
|
hideCopyButton?: boolean;
|
|
38
55
|
/** Close toolbar when element scrolls out of viewport */
|
|
@@ -51,6 +68,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
51
68
|
onClose,
|
|
52
69
|
onRequestComment,
|
|
53
70
|
onQuickLabel,
|
|
71
|
+
selectionActions,
|
|
72
|
+
quickLabels: quickLabelsEnabled = true,
|
|
54
73
|
copyText,
|
|
55
74
|
commentOnly = false,
|
|
56
75
|
hideCopyButton = false,
|
|
@@ -62,10 +81,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
62
81
|
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
|
63
82
|
const [copied, setCopied] = useState(false);
|
|
64
83
|
const [showQuickLabels, setShowQuickLabels] = useState(false);
|
|
84
|
+
const [showSelectionActions, setShowSelectionActions] = useState(false);
|
|
65
85
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
66
86
|
const zapButtonRef = useRef<HTMLButtonElement>(null);
|
|
87
|
+
const actionsButtonRef = useRef<HTMLButtonElement>(null);
|
|
67
88
|
const quickLabels = useMemo(() => getQuickLabels(), []);
|
|
68
89
|
|
|
90
|
+
// A picker counts as open only while the button that opens it is actually
|
|
91
|
+
// rendered. Without this, a host whose `selectionActions` go empty (or who
|
|
92
|
+
// flips `quickLabels` off) while its dropdown is open unmounts the dropdown
|
|
93
|
+
// and its Escape handler, but leaves the flag set — and the flag is what
|
|
94
|
+
// stands the toolbar's own Escape, type-to-comment and outside-dismiss
|
|
95
|
+
// listeners down, wedging the toolbar until the ✕ is clicked. Both flags are
|
|
96
|
+
// false whenever the props are absent, so Plannotator's toolbar is
|
|
97
|
+
// unchanged.
|
|
98
|
+
const hasSelectionActions = !!selectionActions && selectionActions.length > 0;
|
|
99
|
+
const selectionActionsOpen = showSelectionActions && hasSelectionActions;
|
|
100
|
+
const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
|
|
101
|
+
|
|
69
102
|
useEffect(() => { setCopied(false); }, [element]);
|
|
70
103
|
|
|
71
104
|
const handleCopy = async () => {
|
|
@@ -119,8 +152,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
119
152
|
if (e.defaultPrevented || e.isComposing) return;
|
|
120
153
|
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
|
|
121
154
|
|
|
122
|
-
// When picker is open, let
|
|
123
|
-
if (
|
|
155
|
+
// When a picker is open, let it own all keyboard input
|
|
156
|
+
if (quickLabelsOpen || selectionActionsOpen) return;
|
|
124
157
|
|
|
125
158
|
if (e.key === "Escape") {
|
|
126
159
|
onClose();
|
|
@@ -132,7 +165,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
132
165
|
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
|
|
133
166
|
if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
|
|
134
167
|
e.preventDefault();
|
|
135
|
-
if (!commentOnly) {
|
|
168
|
+
if (!commentOnly && quickLabelsEnabled) {
|
|
136
169
|
const digit = parseInt(e.code.slice(5), 10);
|
|
137
170
|
const index = digit === 0 ? 9 : digit - 1;
|
|
138
171
|
if (index < quickLabels.length) {
|
|
@@ -158,10 +191,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
158
191
|
window.removeEventListener("keydown", handleKeyDown);
|
|
159
192
|
releaseCapture();
|
|
160
193
|
};
|
|
161
|
-
}, [onClose, onRequestComment, onQuickLabel, quickLabels,
|
|
194
|
+
}, [onClose, onRequestComment, onQuickLabel, quickLabels, quickLabelsOpen, selectionActionsOpen, commentOnly, quickLabelsEnabled]);
|
|
162
195
|
|
|
163
196
|
useDismissOnOutsideAndEscape({
|
|
164
|
-
enabled: !
|
|
197
|
+
enabled: !quickLabelsOpen && !selectionActionsOpen,
|
|
165
198
|
ref: toolbarRef,
|
|
166
199
|
onDismiss: onClose,
|
|
167
200
|
});
|
|
@@ -236,9 +269,37 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
236
269
|
label="Comment"
|
|
237
270
|
className="text-annotation-comment hover:bg-annotation-comment/10"
|
|
238
271
|
/>
|
|
272
|
+
{hasSelectionActions && (
|
|
273
|
+
<ToolbarButton
|
|
274
|
+
ref={actionsButtonRef}
|
|
275
|
+
onClick={() => setShowSelectionActions(prev => !prev)}
|
|
276
|
+
icon={<WandIcon />}
|
|
277
|
+
label="Actions"
|
|
278
|
+
className={selectionActionsOpen ? "text-primary bg-primary/10" : "text-primary hover:bg-primary/10"}
|
|
279
|
+
dataAttributes={{ 'data-selection-actions': 'true' }}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
{selectionActionsOpen && actionsButtonRef.current && (
|
|
283
|
+
<FloatingSelectionActionsPicker
|
|
284
|
+
anchorEl={actionsButtonRef.current}
|
|
285
|
+
actions={selectionActions!}
|
|
286
|
+
onSelect={(action) => {
|
|
287
|
+
setShowSelectionActions(false);
|
|
288
|
+
// Read at invoke time, not on every render: a toolbar with no
|
|
289
|
+
// host actions must not walk the element's text at all.
|
|
290
|
+
const actionText = copyText
|
|
291
|
+
?? element.querySelector('code')?.textContent
|
|
292
|
+
?? element.textContent
|
|
293
|
+
?? '';
|
|
294
|
+
action.onSelect(buildSelectionActionContext(element, actionText));
|
|
295
|
+
onClose();
|
|
296
|
+
}}
|
|
297
|
+
onDismiss={() => setShowSelectionActions(false)}
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
239
300
|
{onQuickLabel && (
|
|
240
301
|
<>
|
|
241
|
-
{!commentOnly && (
|
|
302
|
+
{!commentOnly && quickLabelsEnabled && (
|
|
242
303
|
<ToolbarButton
|
|
243
304
|
ref={zapButtonRef}
|
|
244
305
|
onClick={() => setShowQuickLabels(prev => !prev)}
|
|
@@ -253,7 +314,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
253
314
|
label="Looks good"
|
|
254
315
|
className="hover:bg-green-500/10"
|
|
255
316
|
/>
|
|
256
|
-
{
|
|
317
|
+
{quickLabelsOpen && zapButtonRef.current && (
|
|
257
318
|
<FloatingQuickLabelPicker
|
|
258
319
|
anchorEl={zapButtonRef.current}
|
|
259
320
|
onSelect={(label) => {
|
|
@@ -309,6 +370,12 @@ const ZapIcon = () => (
|
|
|
309
370
|
</svg>
|
|
310
371
|
);
|
|
311
372
|
|
|
373
|
+
const WandIcon = () => (
|
|
374
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
375
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 4V2m0 16v-2M8 9h2m10 0h2M17.8 11.8L19 13M15 9h0M17.8 6.2L19 5M3 21l9-9M12.2 6.2L11 5" />
|
|
376
|
+
</svg>
|
|
377
|
+
);
|
|
378
|
+
|
|
312
379
|
const CloseIcon = () => (
|
|
313
380
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
314
381
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -320,7 +387,9 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
|
|
|
320
387
|
icon: React.ReactNode;
|
|
321
388
|
label: string;
|
|
322
389
|
className: string;
|
|
323
|
-
|
|
390
|
+
/** Extra DOM attributes (e.g. data-selection-actions). Absent adds nothing. */
|
|
391
|
+
dataAttributes?: Record<string, string>;
|
|
392
|
+
}>(({ onClick, icon, label, className, dataAttributes }, ref) => (
|
|
324
393
|
<button
|
|
325
394
|
ref={ref}
|
|
326
395
|
// Icon-only controls: the markers are inert outside the compact touch
|
|
@@ -331,6 +400,7 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
|
|
|
331
400
|
onClick={onClick}
|
|
332
401
|
title={label}
|
|
333
402
|
className={`p-1.5 rounded-md transition-colors ${className}`}
|
|
403
|
+
{...dataAttributes}
|
|
334
404
|
>
|
|
335
405
|
{icon}
|
|
336
406
|
</button>
|
|
@@ -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}
|