@plannotator/ui 0.43.1 → 0.44.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 +154 -0
- package/README.md +25 -2
- package/components/CommentPopover.tsx +142 -34
- package/components/MentionPicker.tsx +43 -0
- package/hooks/useMentionAutocomplete.ts +11 -1
- package/package.json +1 -1
- package/styles.css +1 -1
- package/utils/composerTokens.ts +138 -0
- package/utils/mentions.ts +22 -0
package/HANDOFF.md
CHANGED
|
@@ -215,6 +215,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
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
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
217
|
| `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource` (and, since 0.43.1, `Viewer`'s and `HtmlViewer`'s): the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
|
|
218
|
+
| `utils/composerTokens` | The comment composer's token highlight ranges: `skillTokenRanges`, `mentionTokenRanges`, `mergeTokenRanges` and `ComposerTokenRange`. Pure (no DOM, no styling) — the one place that decides which bytes of a composer's text are a token and which source wins when two claim the same ones. *(Blessed in 0.44.0.)* |
|
|
218
219
|
| `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. |
|
|
219
220
|
| `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.)* |
|
|
220
221
|
| `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". |
|
|
@@ -956,6 +957,8 @@ that state out of `Viewer`, exactly as with `AnnotationToolstrip`'s
|
|
|
956
957
|
|
|
957
958
|
### 3. `CommentPopover` `mentionSource` — an `@` mention source for the composer
|
|
958
959
|
|
|
960
|
+
> **0.43.2 additions (opt-in, byte-identical when absent):** `MentionSource.heading?: string | null` draws one non-selectable heading row (`data-mention-heading`, e.g. "People in this workspace") above the list; `MentionPerson.avatar?: { url?, initials?, tint? }` draws an avatar (`data-mention-avatar="image" | "initials"`) before the label — an `<img>` when `url` is set, else `initials` (defaulting to the label's first letter) on a disc tinted with `tint` (any CSS color; absent means the muted surface). Neither the grammar, the keyboard state machine, `onMentionsChange`, `onPickBlocked` nor `Annotation.mentions` changes.
|
|
961
|
+
|
|
959
962
|
```ts
|
|
960
963
|
import type { MentionPerson, MentionSource } from "@plannotator/ui/types";
|
|
961
964
|
// (also @plannotator/ui/utils/mentions)
|
|
@@ -971,6 +974,7 @@ interface MentionPerson {
|
|
|
971
974
|
interface MentionSource {
|
|
972
975
|
readonly people: readonly MentionPerson[];
|
|
973
976
|
readonly emptyNotice?: string | null; // honest-empty row
|
|
977
|
+
readonly heading?: string | null; // 0.43.2: heading row above the list, absent → none
|
|
974
978
|
readonly onMentionsChange?: (ids: readonly string[]) => void;
|
|
975
979
|
readonly onPickBlocked?: (person: MentionPerson) => void;
|
|
976
980
|
}
|
|
@@ -1168,8 +1172,158 @@ global composer through its button — and
|
|
|
1168
1172
|
HtmlViewer through a bridge pinpoint message and its global button. Plus the two DOM-free pins in
|
|
1169
1173
|
§3 above.
|
|
1170
1174
|
|
|
1175
|
+
## Mention token chips in the composer (0.44.0)
|
|
1176
|
+
|
|
1177
|
+
0.43.0-0.43.2 gave the composer an `@` picker; the token it inserted was
|
|
1178
|
+
then plain text in the textarea. 0.44.0 paints it as a **chip**, so a tag
|
|
1179
|
+
looks the same in the picker row, in the input, and in the comment the host
|
|
1180
|
+
posts. Same ruling as the three releases before it: an opt-in host
|
|
1181
|
+
capability that changes nothing for Plannotator's own users when it is not
|
|
1182
|
+
supplied. `packages/editor` and `packages/review-editor` are untouched; core
|
|
1183
|
+
is UNCHANGED at `0.25.5`, so **ui 0.44.0 publishes alone**.
|
|
1184
|
+
|
|
1185
|
+
### One overlay, two sources (the refactor)
|
|
1186
|
+
|
|
1187
|
+
`ComposerTextarea` already used exactly the right technique for skill
|
|
1188
|
+
references: a mirrored, aria-hidden overlay rendered BEHIND a
|
|
1189
|
+
transparent-text textarea (a textarea cannot style substrings), sharing the
|
|
1190
|
+
font/padding/wrapping metrics and mirroring scroll, with `.pn-ref-composing`
|
|
1191
|
+
hiding it during IME composition. Chips do not add a second overlay — two
|
|
1192
|
+
mirrored layers could never stay pixel-aligned with each other, and only one
|
|
1193
|
+
of them could own the scroll sync. Instead the overlay became a TOKEN
|
|
1194
|
+
HIGHLIGHT LAYER fed by a merged list of ranges, and it turns on when EITHER
|
|
1195
|
+
source is active.
|
|
1196
|
+
|
|
1197
|
+
The range computation moved out of the render loop into
|
|
1198
|
+
`utils/composerTokens` (pure — no DOM, no styling, no React):
|
|
1199
|
+
|
|
1200
|
+
- `skillTokenRanges(tokens)` — today's positioned occurrences, unchanged.
|
|
1201
|
+
- `mentionTokenRanges(text, people)` — every occurrence of each surviving
|
|
1202
|
+
person's readable `@Label` token.
|
|
1203
|
+
- `mergeTokenRanges(text, groups)` — `groups` in priority order; drops
|
|
1204
|
+
ranges outside `[0, text.length)` and empty/inverted ones, then keeps
|
|
1205
|
+
earlier `start`, longer at the same start, earlier group at the same start
|
|
1206
|
+
and length, and drops anything beginning inside a range already kept.
|
|
1207
|
+
Nothing nests, so the overlay stays a flat sequence of spans.
|
|
1208
|
+
|
|
1209
|
+
With a single skill source this reproduces the pre-refactor loop exactly
|
|
1210
|
+
(which dropped a token whose `start` fell behind the cursor or whose `end`
|
|
1211
|
+
ran past the text). The component keeps the Tailwind classes and the `data-*`
|
|
1212
|
+
attributes in `renderTokenSpan`, both so the class scanner still sees them
|
|
1213
|
+
and so the metric rule below is read with the classes it governs.
|
|
1214
|
+
|
|
1215
|
+
### The chip
|
|
1216
|
+
|
|
1217
|
+
A chip is painted only for a person the author PICKED whose token still
|
|
1218
|
+
survives — `useMentionAutocomplete` now also returns those survivors as
|
|
1219
|
+
`mentions` (frozen-empty with no source, the same treatment `mentionIds`
|
|
1220
|
+
gets), so the ranges come from the mention id model and never from a regex
|
|
1221
|
+
over arbitrary `@words`. Editing one byte of a token un-chips it in the same
|
|
1222
|
+
render that drops the id from `onMentionsChange`, so a chip follows the body
|
|
1223
|
+
rather than a stale pick. The one case where the chips and the reported IDS
|
|
1224
|
+
can still part company is the prefix case in the limitations below, which
|
|
1225
|
+
the chips inherit rather than introduce.
|
|
1226
|
+
|
|
1227
|
+
```
|
|
1228
|
+
<span data-mention-token="user_1" data-mention-kind="user" class="…">@Marcus Chen</span>
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
- `data-mention-token` is the opaque host id; `data-mention-kind` carries
|
|
1232
|
+
`MentionPerson.kind` verbatim — the host's styling hook. Know what that
|
|
1233
|
+
means today: the picker offers USERS ONLY (`mentionMatches` drops every
|
|
1234
|
+
person whose `kind !== 'user'`), so only a user can be picked, only a user
|
|
1235
|
+
can be tagged, and the attribute only ever reads `user`. `agent` is the
|
|
1236
|
+
reserved value for the day agents become taggable — a host rule for
|
|
1237
|
+
`[data-mention-kind="agent"]` matches nothing until then.
|
|
1238
|
+
- `MentionSource.tokenClassName?: string` (new, optional) is appended to the
|
|
1239
|
+
span verbatim for a host that wants its own look.
|
|
1240
|
+
- The package default is `text-primary bg-primary/15` and a 3px radius —
|
|
1241
|
+
the skill-reference treatment one shade stronger, so the two token kinds in
|
|
1242
|
+
one overlay read as siblings. Deliberately no ring: every class it uses is
|
|
1243
|
+
one the package already emitted, so a host's generated CSS is unchanged
|
|
1244
|
+
(and so is the portable guide viewer's bundle — `guide-viewer-manifest.ts`
|
|
1245
|
+
needed no regeneration, which is why core is untouched).
|
|
1246
|
+
|
|
1247
|
+
**THE METRIC RULE (and it is the host's too).** A chip may change COLOR,
|
|
1248
|
+
BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Padding,
|
|
1249
|
+
margin, border width, font-weight, letter-spacing and font-size all move a
|
|
1250
|
+
glyph, and the overlay's glyphs must coincide with the textarea's own layout
|
|
1251
|
+
or the caret drifts away from the text it is painting. A pill's horizontal
|
|
1252
|
+
breathing room is faked with `box-shadow: 0 0 0 Npx <background>`, which
|
|
1253
|
+
paints without occupying space — that is the way to a pill look through
|
|
1254
|
+
`tokenClassName`, and the reason the rule bans padding rather than the
|
|
1255
|
+
appearance.
|
|
1256
|
+
|
|
1257
|
+
### What did NOT change
|
|
1258
|
+
|
|
1259
|
+
The overlay exists for the whole life of a mention composer, not only once
|
|
1260
|
+
somebody is tagged, so the first pick never swaps the textarea element under
|
|
1261
|
+
the caret. IME composition, scroll sync, the resize gutter, the placeholder,
|
|
1262
|
+
the `/` + `$` skill autocomplete and its menu, the Alt-typing path, drafts
|
|
1263
|
+
(`initialDraft` / `draftKey`), image attachments and `Mod+Enter` submit are
|
|
1264
|
+
all untouched, and `onSubmit(text, images?, mentions?)` is the same call.
|
|
1265
|
+
`Viewer` and `HtmlViewer` needed no change at all: they already forward
|
|
1266
|
+
`mentionSource` (0.43.1), and the chips are inside the composer it reaches.
|
|
1267
|
+
|
|
1268
|
+
Three inherited limitations are worth stating rather than fixing here:
|
|
1269
|
+
|
|
1270
|
+
- **Two people whose labels sanitize to the same token** are
|
|
1271
|
+
indistinguishable in a plain-text body, so the FIRST of them listed owns
|
|
1272
|
+
every occurrence of it. That is the same first-match rule
|
|
1273
|
+
`survivingMentions` already applies to the ids; it renders and never
|
|
1274
|
+
throws.
|
|
1275
|
+
- **A restored draft has no chips** until the author picks again, because
|
|
1276
|
+
the survivors come from the picks made in THIS composer — exactly the same
|
|
1277
|
+
reason `onMentionsChange` reports `[]` for a restored draft today (0.43.x
|
|
1278
|
+
behavior, unchanged). It reports no STALE ids either: a reopened draft
|
|
1279
|
+
starts with nobody tagged, so the chips and the ids agree on "none".
|
|
1280
|
+
- **A label that is a prefix of another label** (`Ann` and `Anna Lee`, both
|
|
1281
|
+
picked): the CHIPS are right — longest-wins means `@Anna Lee` is painted
|
|
1282
|
+
whole and is never half-covered by an `@Ann` chip. The IDS are the loose
|
|
1283
|
+
end: delete `@Ann` from a body that still reads `@Anna Lee` and
|
|
1284
|
+
`survivingMentions` keeps reporting Ann, because it asks
|
|
1285
|
+
`text.includes('@Ann')`. So the id list can outlive the chip. That is
|
|
1286
|
+
0.43.x behavior in `survivingMentions`, unchanged here — the chips only
|
|
1287
|
+
make it visible.
|
|
1288
|
+
|
|
1289
|
+
### The no-op guarantee, and how it is pinned
|
|
1290
|
+
|
|
1291
|
+
The same components were mounted on `origin/main` and on this branch in one
|
|
1292
|
+
harness and their `outerHTML` diffed:
|
|
1293
|
+
|
|
1294
|
+
- **No `mentionSource`, no `skillReferences`:** the composer is
|
|
1295
|
+
byte-identical — 3192 bytes, and the same `addEventListener` and
|
|
1296
|
+
`setTimeout` counts (287 / 1 in that harness). No overlay element exists
|
|
1297
|
+
at all.
|
|
1298
|
+
- **`skillReferences` only:** the popover (4821 bytes) and the overlay
|
|
1299
|
+
itself (634 bytes) are byte-identical, same listener and timer counts
|
|
1300
|
+
(150 / 1). `data-skill-ref-overlay="true"` is written only when
|
|
1301
|
+
`skillReferences` is on, so a skill composer's overlay keeps the exact
|
|
1302
|
+
attribute list it had; a mentions-only overlay is found by its
|
|
1303
|
+
`data-pn-mobile-editable-mirror` attribute instead.
|
|
1304
|
+
- A mention composer's overlay costs exactly one extra listener (the
|
|
1305
|
+
textarea's `scroll`, which is what mirrors the layer) — the same one a
|
|
1306
|
+
skill composer has always paid.
|
|
1307
|
+
- **The portable guide viewer's build is byte-identical** (`viewer.*.js` and
|
|
1308
|
+
`viewer.*.css` hashes and their SRI unchanged against `origin/main`), so
|
|
1309
|
+
`packages/core/guide-viewer-manifest.ts` is in sync and core is untouched.
|
|
1310
|
+
That is also why the default chip reuses classes the package already
|
|
1311
|
+
emitted instead of introducing one.
|
|
1312
|
+
|
|
1313
|
+
**Real-browser metric proof** (headless Chromium, throwaway Vite harness):
|
|
1314
|
+
typing `Nice catch @ma`, picking Marcus and continuing to type, the chip's
|
|
1315
|
+
bounding rect and the textarea's own text run for that token agree to
|
|
1316
|
+
**0.000px** on both axes and in width — at 420px and 1200px, on a wrapped
|
|
1317
|
+
line (3 lines above it) and with the textarea scrolled (`scrollTop` 40) —
|
|
1318
|
+
and the caret x after the token is **0.016px** from the span's end.
|
|
1319
|
+
|
|
1320
|
+
Tests: `utils/composerTokens.test.ts` (16, DOM-free) and
|
|
1321
|
+
`components/CommentPopover.mentionChips.test.tsx` (11, DOM-gated, in the
|
|
1322
|
+
workflow's DOM_TESTS step).
|
|
1323
|
+
|
|
1171
1324
|
## Publishing & versioning
|
|
1172
1325
|
|
|
1326
|
+
- **ui 0.44.0 (mention token chips in the composer): `@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 over 0.43.2: the `@Label` tokens a `mentionSource` composer inserted render as chips in the composer's existing highlight overlay, `MentionSource.tokenClassName?` lets a host restyle them (under the metric rule), `useMentionAutocomplete` also returns the surviving `mentions`, and `utils/composerTokens` joins the supported-import list. Nothing is removed, no export-, share- or archive-visible change, and Plannotator passes none of it — with neither `mentionSource` nor `skillReferences` the composer is byte-identical to 0.43.2. See "Mention token chips in the composer (0.44.0)".
|
|
1173
1327
|
- **ui 0.43.1 (`mentionSource` on the viewers): `@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 over 0.43.0: `mentionSource` on `Viewer` and `HtmlViewer` (forwarded to every comment composer each mounts) and the optional `Annotation.mentions` field the picked ids land on, set only when a source was supplied and a token survived. Nothing is removed, no new modules, no export-, share- or archive-visible change, and Plannotator passes none of it. See "`mentionSource` on the viewers (0.43.1)".
|
|
1174
1328
|
- **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)".
|
|
1175
1329
|
- **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.
|
package/README.md
CHANGED
|
@@ -245,7 +245,9 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
|
245
245
|
- **`AnnotationToolbar` `quickLabels`** (default `true`): `false` hides the Zap
|
|
246
246
|
picker and makes the Alt+digit label shortcuts inert on that toolbar. The
|
|
247
247
|
one-click 👍 is unaffected, and mode state is still yours to clamp.
|
|
248
|
-
- **`CommentPopover` `mentionSource
|
|
248
|
+
- **`CommentPopover` `mentionSource`** (0.43.2 adds `heading?` above the list
|
|
249
|
+
and an optional `avatar?: { url?, initials?, tint? }` per person, drawn before
|
|
250
|
+
the label; both absent → the 0.43.1 rows): `{ people, emptyNotice?,
|
|
249
251
|
onMentionsChange?, onPickBlocked? }` over `MentionPerson`
|
|
250
252
|
(`{ id, kind, label, detail, canOpen }`). Typing `@` at a word boundary opens
|
|
251
253
|
a portaled picker measured from the textarea; picking inserts the readable
|
|
@@ -256,6 +258,24 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
|
256
258
|
no-access dialog) and inserts normally when you do not. Not wired to any
|
|
257
259
|
Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
|
|
258
260
|
render the composer.
|
|
261
|
+
- **`CommentPopover` mention chips** (0.44.0): with a `mentionSource`, the
|
|
262
|
+
`@Label` tokens the picker inserted render as chips in the composer's text —
|
|
263
|
+
the same mirrored overlay skill references use, never a second layer. Each
|
|
264
|
+
chip span carries `data-mention-token="<person id>"` and
|
|
265
|
+
`data-mention-kind`, which is `MentionPerson.kind` verbatim — today that is
|
|
266
|
+
always `user`, because the picker offers users only; `agent` is reserved
|
|
267
|
+
and matches nothing yet. `MentionSource.tokenClassName?` is appended to the
|
|
268
|
+
span for your own look. **Metric rule, yours to keep too:** a chip
|
|
269
|
+
may change color, background, border-radius, box-shadow and text-decoration
|
|
270
|
+
ONLY — padding, margin, border width, weight, tracking or size move a glyph
|
|
271
|
+
and drift the caret off the painted text (want a pill? add
|
|
272
|
+
`box-shadow: 0 0 0 Npx <background>`, which paints without taking space).
|
|
273
|
+
Only a person you supplied and the author picked is a chip, and editing a
|
|
274
|
+
byte of the token un-chips it in the same render that drops the id. Two
|
|
275
|
+
inherited limits: two labels that sanitize to the same token are one token
|
|
276
|
+
in a plain-text body, so the first person you list owns every occurrence of
|
|
277
|
+
it; and a restored draft has no chips (and reports no ids) until the author
|
|
278
|
+
picks again. No source → no overlay element at all.
|
|
259
279
|
- **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
|
|
260
280
|
viewers, forwarded to every comment composer each of them mounts, so a host
|
|
261
281
|
wires mentions once per surface instead of per composer. The ids the author
|
|
@@ -266,7 +286,10 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
|
266
286
|
|
|
267
287
|
See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
|
|
268
288
|
rules and the threading points, and § "mentionSource on the viewers (0.43.1)"
|
|
269
|
-
for the two viewer props and the annotation field.
|
|
289
|
+
for the two viewer props and the annotation field. § "Mention token chips in
|
|
290
|
+
the composer (0.44.0)" covers the chips, the metric rule, the merged-range
|
|
291
|
+
refactor behind them, and the three inherited limits (duplicate labels,
|
|
292
|
+
restored drafts, and a label that is a prefix of another label).
|
|
270
293
|
|
|
271
294
|
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
272
295
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useCallback, useId } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef, useCallback, useId } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { ImageAttachment } from '../types';
|
|
4
4
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
@@ -9,9 +9,15 @@ 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 {
|
|
13
|
+
mentionTokenRanges,
|
|
14
|
+
mergeTokenRanges,
|
|
15
|
+
skillTokenRanges,
|
|
16
|
+
type ComposerTokenRange,
|
|
17
|
+
} from '../utils/composerTokens';
|
|
12
18
|
import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
|
|
13
19
|
import { MentionPicker } from './MentionPicker';
|
|
14
|
-
import type { MentionSource } from '../utils/mentions';
|
|
20
|
+
import type { MentionPerson, MentionSource } from '../utils/mentions';
|
|
15
21
|
import {
|
|
16
22
|
hasPrimaryCoarsePointer,
|
|
17
23
|
shouldUseExpandedComposer,
|
|
@@ -474,6 +480,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
474
480
|
source: mentionSource,
|
|
475
481
|
});
|
|
476
482
|
|
|
483
|
+
// The mention half of the composer's highlight layer: present for the whole
|
|
484
|
+
// life of a mention composer (so the first pick never swaps the textarea
|
|
485
|
+
// element), null without a source (so the overlay does not exist at all).
|
|
486
|
+
const mentionChipPeople = mentionAc.mentions;
|
|
487
|
+
const mentionTokenClassName = mentionSource?.tokenClassName;
|
|
488
|
+
const mentionChips: ComposerMentionChips | null = useMemo(
|
|
489
|
+
() =>
|
|
490
|
+
mentionSource
|
|
491
|
+
? { people: mentionChipPeople, tokenClassName: mentionTokenClassName }
|
|
492
|
+
: null,
|
|
493
|
+
[mentionSource, mentionChipPeople, mentionTokenClassName],
|
|
494
|
+
);
|
|
495
|
+
|
|
477
496
|
const handleSubmit = useCallback(() => {
|
|
478
497
|
const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
|
|
479
498
|
if (hasUnsavedContent || canSubmitEmpty) {
|
|
@@ -684,6 +703,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
684
703
|
sizeClassName="min-h-32 max-h-full"
|
|
685
704
|
skillReferences={skillReferences}
|
|
686
705
|
tokens={skillAc.referenceTokens}
|
|
706
|
+
mentionChips={mentionChips}
|
|
687
707
|
listboxId={composerListboxId}
|
|
688
708
|
listboxOpen={composerListboxOpen}
|
|
689
709
|
activeOptionId={activeComposerOptionId}
|
|
@@ -694,6 +714,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
694
714
|
id={mentionListboxId}
|
|
695
715
|
people={mentionAc.menu.items}
|
|
696
716
|
emptyNotice={mentionAc.menu.emptyNotice}
|
|
717
|
+
heading={mentionAc.menu.heading}
|
|
697
718
|
active={mentionAc.menu.activeIndex}
|
|
698
719
|
anchor={mentionAc.menu.anchor}
|
|
699
720
|
onPick={(person) => {
|
|
@@ -848,6 +869,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
848
869
|
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
849
870
|
skillReferences={skillReferences}
|
|
850
871
|
tokens={skillAc.referenceTokens}
|
|
872
|
+
mentionChips={mentionChips}
|
|
851
873
|
listboxId={composerListboxId}
|
|
852
874
|
listboxOpen={composerListboxOpen}
|
|
853
875
|
activeOptionId={activeComposerOptionId}
|
|
@@ -858,6 +880,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
858
880
|
id={mentionListboxId}
|
|
859
881
|
people={mentionAc.menu.items}
|
|
860
882
|
emptyNotice={mentionAc.menu.emptyNotice}
|
|
883
|
+
heading={mentionAc.menu.heading}
|
|
861
884
|
active={mentionAc.menu.activeIndex}
|
|
862
885
|
anchor={mentionAc.menu.anchor}
|
|
863
886
|
onPick={(person) => {
|
|
@@ -991,6 +1014,78 @@ function syncOverlayGutter(
|
|
|
991
1014
|
state.applied = scrollbar;
|
|
992
1015
|
}
|
|
993
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* The default chip look: the theme's primary at a wash, one shade stronger
|
|
1019
|
+
* than a skill reference's so the two token kinds in one overlay read as
|
|
1020
|
+
* siblings rather than the same thing. Deliberately no ring: every class here
|
|
1021
|
+
* is one the package already emitted, so a host build's CSS (and the portable
|
|
1022
|
+
* guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
|
|
1023
|
+
* `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
|
|
1024
|
+
* layout, per the metric rule above.
|
|
1025
|
+
*/
|
|
1026
|
+
const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* One token's span in the highlight overlay.
|
|
1030
|
+
*
|
|
1031
|
+
* METRIC RULE, load-bearing for every token kind: a span may change COLOR,
|
|
1032
|
+
* BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
|
|
1033
|
+
* that moves a glyph — padding, margin, border width, font-weight,
|
|
1034
|
+
* letter-spacing, font-size — would shift the overlay's text off the
|
|
1035
|
+
* textarea's own layout and drift the caret away from the painted glyphs. A
|
|
1036
|
+
* pill's breathing room is faked with a paint-only `box-shadow` ring in the
|
|
1037
|
+
* chip's own background color.
|
|
1038
|
+
*/
|
|
1039
|
+
function renderTokenSpan(
|
|
1040
|
+
range: ComposerTokenRange,
|
|
1041
|
+
text: string,
|
|
1042
|
+
mentionTokenClassName?: string,
|
|
1043
|
+
): React.ReactNode {
|
|
1044
|
+
if (range.kind === 'mention') {
|
|
1045
|
+
// `data-mention-token` / `data-mention-kind` are the host's styling hook;
|
|
1046
|
+
// `tokenClassName` is appended verbatim and is the host's to keep
|
|
1047
|
+
// metric-safe (see the rule above).
|
|
1048
|
+
return (
|
|
1049
|
+
<span
|
|
1050
|
+
key={`mention-${range.start}`}
|
|
1051
|
+
data-mention-token={range.person.id}
|
|
1052
|
+
data-mention-kind={range.person.kind}
|
|
1053
|
+
className={`${MENTION_CHIP_CLASSES}${
|
|
1054
|
+
mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
|
|
1055
|
+
}`}
|
|
1056
|
+
>
|
|
1057
|
+
{text}
|
|
1058
|
+
</span>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
1062
|
+
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
1063
|
+
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
1064
|
+
// the textarea — this overlay is aria-hidden.
|
|
1065
|
+
return (
|
|
1066
|
+
<span
|
|
1067
|
+
key={`skill-${range.start}`}
|
|
1068
|
+
data-skill-ref-token={range.skill.entry.name}
|
|
1069
|
+
data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
|
|
1070
|
+
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
1071
|
+
range.skill.entry.humanOnly
|
|
1072
|
+
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
1073
|
+
: ''
|
|
1074
|
+
}`}
|
|
1075
|
+
>
|
|
1076
|
+
{text}
|
|
1077
|
+
</span>
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** The mention half of the highlight layer. Null → no mention source at all. */
|
|
1082
|
+
export interface ComposerMentionChips {
|
|
1083
|
+
/** The people whose `@Label` token still survives in the text. */
|
|
1084
|
+
readonly people: readonly MentionPerson[];
|
|
1085
|
+
/** Host class appended to each chip (see `MentionSource.tokenClassName`). */
|
|
1086
|
+
readonly tokenClassName?: string;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
994
1089
|
interface ComposerTextareaProps {
|
|
995
1090
|
value: string;
|
|
996
1091
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
@@ -1003,8 +1098,14 @@ interface ComposerTextareaProps {
|
|
|
1003
1098
|
textareaRef: (el: HTMLTextAreaElement | null) => void;
|
|
1004
1099
|
/** Positioned skill-reference occurrences to highlight. */
|
|
1005
1100
|
tokens: SkillReferenceToken[];
|
|
1006
|
-
/** Off →
|
|
1101
|
+
/** Off → no skill-reference contribution to the highlight layer. */
|
|
1007
1102
|
skillReferences: boolean;
|
|
1103
|
+
/**
|
|
1104
|
+
* The mention contribution, or null with no `mentionSource`. Present (even
|
|
1105
|
+
* with nobody tagged yet) it turns the overlay on, so the first pick paints
|
|
1106
|
+
* a chip without swapping the textarea for a different element.
|
|
1107
|
+
*/
|
|
1108
|
+
mentionChips: ComposerMentionChips | null;
|
|
1008
1109
|
/** ARIA relationship to the skill-reference listbox. */
|
|
1009
1110
|
listboxId: string;
|
|
1010
1111
|
listboxOpen: boolean;
|
|
@@ -1012,13 +1113,15 @@ interface ComposerTextareaProps {
|
|
|
1012
1113
|
}
|
|
1013
1114
|
|
|
1014
1115
|
/**
|
|
1015
|
-
* The composer's textarea. With
|
|
1016
|
-
* pre-feature `<textarea>`; with
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1116
|
+
* The composer's textarea. With NEITHER token source active this is exactly
|
|
1117
|
+
* the pre-feature `<textarea>`; with either one on, its tokens are painted by
|
|
1118
|
+
* a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
|
|
1119
|
+
* (a textarea cannot style substrings). ONE overlay serves both sources: two
|
|
1120
|
+
* mirrored layers could never stay pixel-aligned with each other, and only
|
|
1121
|
+
* one of them could own the scroll sync. The overlay shares the exact
|
|
1122
|
+
* font/padding/wrapping metrics and mirrors scroll position, and token spans
|
|
1123
|
+
* obey the metric rule on `renderTokenSpan` (paint only, never layout), so
|
|
1124
|
+
* the glyphs the browser lays out in the textarea and the glyphs the overlay
|
|
1022
1125
|
* paints coincide. During IME composition the overlay hides and the textarea
|
|
1023
1126
|
* text becomes visible again (`.pn-ref-composing`), keeping native
|
|
1024
1127
|
* composition rendering (underlines, candidate highlights) intact.
|
|
@@ -1033,6 +1136,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1033
1136
|
textareaRef,
|
|
1034
1137
|
tokens,
|
|
1035
1138
|
skillReferences,
|
|
1139
|
+
mentionChips,
|
|
1036
1140
|
listboxId,
|
|
1037
1141
|
listboxOpen,
|
|
1038
1142
|
activeOptionId,
|
|
@@ -1061,6 +1165,23 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1061
1165
|
[textareaRef],
|
|
1062
1166
|
);
|
|
1063
1167
|
|
|
1168
|
+
// The overlay exists while EITHER source is active. `mentionChips` turns it
|
|
1169
|
+
// on for the whole life of a mention composer, not only once somebody is
|
|
1170
|
+
// tagged, so a pick never swaps the textarea element under the caret.
|
|
1171
|
+
const mentionPeople = mentionChips?.people;
|
|
1172
|
+
const highlight = skillReferences || mentionChips !== null;
|
|
1173
|
+
|
|
1174
|
+
// The one list of spans the overlay paints, from every active source.
|
|
1175
|
+
// Skill references are the earlier group, so they win a byte both claim.
|
|
1176
|
+
const ranges = useMemo(
|
|
1177
|
+
() =>
|
|
1178
|
+
mergeTokenRanges(value, [
|
|
1179
|
+
skillReferences ? skillTokenRanges(tokens) : [],
|
|
1180
|
+
mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
|
|
1181
|
+
]),
|
|
1182
|
+
[value, tokens, skillReferences, mentionPeople],
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1064
1185
|
// Keep the mirror aligned when the value changes without a scroll event
|
|
1065
1186
|
// (e.g. programmatic insertion moving the caret into a scrolled region).
|
|
1066
1187
|
useEffect(() => {
|
|
@@ -1078,9 +1199,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1078
1199
|
});
|
|
1079
1200
|
observer.observe(el);
|
|
1080
1201
|
return () => observer.disconnect();
|
|
1081
|
-
}, [
|
|
1202
|
+
}, [highlight, syncScroll]);
|
|
1082
1203
|
|
|
1083
|
-
if (!
|
|
1204
|
+
if (!highlight) {
|
|
1084
1205
|
return (
|
|
1085
1206
|
<textarea
|
|
1086
1207
|
data-pn-mobile-editable="true"
|
|
@@ -1103,29 +1224,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1103
1224
|
|
|
1104
1225
|
const segments: React.ReactNode[] = [];
|
|
1105
1226
|
let pos = 0;
|
|
1106
|
-
|
|
1107
|
-
if (
|
|
1108
|
-
if (token.start > pos) segments.push(value.slice(pos, token.start));
|
|
1109
|
-
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
1110
|
-
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
1111
|
-
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
1112
|
-
// the textarea — this overlay is aria-hidden.
|
|
1227
|
+
for (const range of ranges) {
|
|
1228
|
+
if (range.start > pos) segments.push(value.slice(pos, range.start));
|
|
1113
1229
|
segments.push(
|
|
1114
|
-
|
|
1115
|
-
key={`${token.start}-${i}`}
|
|
1116
|
-
data-skill-ref-token={token.entry.name}
|
|
1117
|
-
data-skill-ref-human-only={token.entry.humanOnly ? 'true' : undefined}
|
|
1118
|
-
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
1119
|
-
token.entry.humanOnly
|
|
1120
|
-
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
1121
|
-
: ''
|
|
1122
|
-
}`}
|
|
1123
|
-
>
|
|
1124
|
-
{value.slice(token.start, token.end)}
|
|
1125
|
-
</span>,
|
|
1230
|
+
renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
|
|
1126
1231
|
);
|
|
1127
|
-
pos =
|
|
1128
|
-
}
|
|
1232
|
+
pos = range.end;
|
|
1233
|
+
}
|
|
1129
1234
|
segments.push(value.slice(pos));
|
|
1130
1235
|
|
|
1131
1236
|
return (
|
|
@@ -1133,7 +1238,10 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1133
1238
|
<div
|
|
1134
1239
|
ref={overlayRef}
|
|
1135
1240
|
aria-hidden="true"
|
|
1136
|
-
|
|
1241
|
+
// Names the SKILL-reference layer, so a skill composer's overlay is
|
|
1242
|
+
// byte-identical to the one it rendered before mentions existed; a
|
|
1243
|
+
// mentions-only overlay is found by its mirror attribute below.
|
|
1244
|
+
data-skill-ref-overlay={skillReferences ? 'true' : undefined}
|
|
1137
1245
|
data-pn-mobile-editable-mirror="true"
|
|
1138
1246
|
className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
|
|
1139
1247
|
style={composing ? { visibility: 'hidden' } : undefined}
|
|
@@ -34,10 +34,45 @@ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | nu
|
|
|
34
34
|
return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The optional avatar column: an image, else initials on a tinted disc. */
|
|
38
|
+
function MentionAvatar({
|
|
39
|
+
avatar,
|
|
40
|
+
label,
|
|
41
|
+
}: {
|
|
42
|
+
readonly avatar: NonNullable<MentionPerson['avatar']>;
|
|
43
|
+
readonly label: string;
|
|
44
|
+
}) {
|
|
45
|
+
if (avatar.url) {
|
|
46
|
+
return (
|
|
47
|
+
<img
|
|
48
|
+
data-mention-avatar="image"
|
|
49
|
+
src={avatar.url}
|
|
50
|
+
alt=""
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
className="h-4 w-4 shrink-0 rounded-full object-cover"
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const initials = (avatar.initials ?? label.trim().charAt(0)).slice(0, 2).toUpperCase();
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
data-mention-avatar="initials"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
style={avatar.tint ? { backgroundColor: avatar.tint } : undefined}
|
|
62
|
+
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[8px] font-semibold leading-none ${
|
|
63
|
+
avatar.tint ? 'text-white' : 'bg-muted text-foreground/80'
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{initials}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
export function MentionPicker({
|
|
38
72
|
id,
|
|
39
73
|
people,
|
|
40
74
|
emptyNotice,
|
|
75
|
+
heading,
|
|
41
76
|
active,
|
|
42
77
|
anchor,
|
|
43
78
|
onPick,
|
|
@@ -48,6 +83,8 @@ export function MentionPicker({
|
|
|
48
83
|
readonly people: readonly MentionPerson[];
|
|
49
84
|
/** Shown as one non-selectable row when `people` is empty. */
|
|
50
85
|
readonly emptyNotice?: string | null;
|
|
86
|
+
/** Optional heading above the list. Absent → nothing rendered. */
|
|
87
|
+
readonly heading?: string | null;
|
|
51
88
|
/** Index of the arrow-focused row, or null for "nothing preselected". */
|
|
52
89
|
readonly active: number | null;
|
|
53
90
|
readonly anchor: MentionAnchor | null;
|
|
@@ -70,6 +107,11 @@ export function MentionPicker({
|
|
|
70
107
|
style={{ position: 'fixed', left: anchor.left, width: anchor.width, ...style }}
|
|
71
108
|
className="z-[120] max-h-48 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-xl"
|
|
72
109
|
>
|
|
110
|
+
{heading && (
|
|
111
|
+
<p data-mention-heading className="px-2 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
112
|
+
{heading}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
73
115
|
{people.length === 0 ? (
|
|
74
116
|
<p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
|
|
75
117
|
{emptyNotice}
|
|
@@ -94,6 +136,7 @@ export function MentionPicker({
|
|
|
94
136
|
active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
|
|
95
137
|
}`}
|
|
96
138
|
>
|
|
139
|
+
{person.avatar && <MentionAvatar avatar={person.avatar} label={person.label} />}
|
|
97
140
|
<span className="min-w-0 flex-1 truncate">{person.label}</span>
|
|
98
141
|
{person.detail && (
|
|
99
142
|
// The NAME is what the person reads; the detail gives way first.
|
|
@@ -15,6 +15,7 @@ export interface MentionMenuState {
|
|
|
15
15
|
items: readonly MentionPerson[];
|
|
16
16
|
/** Shown as one non-selectable row when `items` is empty. */
|
|
17
17
|
emptyNotice: string | null;
|
|
18
|
+
heading: string | null;
|
|
18
19
|
/** Explicitly activated row, or null — the menu opens with NOTHING active. */
|
|
19
20
|
activeIndex: number | null;
|
|
20
21
|
/** Measured textarea rect the portaled picker is placed from. */
|
|
@@ -33,6 +34,12 @@ export interface UseMentionAutocompleteResult {
|
|
|
33
34
|
select: (index: number) => void;
|
|
34
35
|
/** The ids whose readable token still survives in the text. */
|
|
35
36
|
mentionIds: readonly string[];
|
|
37
|
+
/**
|
|
38
|
+
* The same survivors as people, for a caller that needs their labels — the
|
|
39
|
+
* composer paints each surviving token as a chip. Frozen-empty with no
|
|
40
|
+
* source, the same treatment `mentionIds` gets.
|
|
41
|
+
*/
|
|
42
|
+
mentions: readonly MentionPerson[];
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -92,6 +99,7 @@ export function useMentionAutocomplete(options: {
|
|
|
92
99
|
() => (enabled ? survivors.map((p) => p.id) : NO_IDS),
|
|
93
100
|
[enabled, survivors],
|
|
94
101
|
);
|
|
102
|
+
const mentions = enabled ? survivors : NO_PEOPLE;
|
|
95
103
|
|
|
96
104
|
const onMentionsChange = source?.onMentionsChange;
|
|
97
105
|
const mentionsKey = mentionIds.join('\u0000');
|
|
@@ -112,6 +120,7 @@ export function useMentionAutocomplete(options: {
|
|
|
112
120
|
);
|
|
113
121
|
|
|
114
122
|
const emptyNotice = source?.emptyNotice ?? null;
|
|
123
|
+
const heading = source?.heading ?? null;
|
|
115
124
|
const open =
|
|
116
125
|
trigger !== null
|
|
117
126
|
&& trigger.from !== dismissedStart
|
|
@@ -236,11 +245,12 @@ export function useMentionAutocomplete(options: {
|
|
|
236
245
|
|
|
237
246
|
return {
|
|
238
247
|
menu: open && trigger
|
|
239
|
-
? { items, emptyNotice, activeIndex: boundedActive, anchor, query: trigger.query }
|
|
248
|
+
? { items, emptyNotice, heading, activeIndex: boundedActive, anchor, query: trigger.query }
|
|
240
249
|
: null,
|
|
241
250
|
onKeyDown,
|
|
242
251
|
onSelect: readCaret,
|
|
243
252
|
select,
|
|
244
253
|
mentionIds,
|
|
254
|
+
mentions,
|
|
245
255
|
};
|
|
246
256
|
}
|