@plannotator/ui 0.43.2 → 0.45.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 +323 -0
- package/README.md +63 -1
- package/components/AnnotationPanel.tsx +141 -40
- package/components/CommentPopover.tsx +152 -69
- package/components/MentionAutocomplete.tsx +55 -0
- package/hooks/useMentionAutocomplete.ts +8 -0
- package/package.json +1 -1
- package/utils/composerTokens.ts +138 -0
- package/utils/mentions.ts +14 -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". |
|
|
@@ -1171,8 +1172,330 @@ global composer through its button — and
|
|
|
1171
1172
|
HtmlViewer through a bridge pinpoint message and its global button. Plus the two DOM-free pins in
|
|
1172
1173
|
§3 above.
|
|
1173
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
|
+
|
|
1324
|
+
## Annotation card header slot and mentions on the edit box (0.45.0)
|
|
1325
|
+
|
|
1326
|
+
0.43.1 closed with an open question: "Editing an existing comment happens in
|
|
1327
|
+
`AnnotationPanel`'s card, a plain textarea that has never had an `@` picker…
|
|
1328
|
+
If you want the panel editor to pick people too, that is a separate prop on
|
|
1329
|
+
`AnnotationPanel` and worth asking for." It was asked for, so 0.45.0 adds it —
|
|
1330
|
+
together with the header twin of the panel's existing `renderCardFooter`.
|
|
1331
|
+
Same ruling as the four releases before it: opt-in host capabilities that
|
|
1332
|
+
change nothing for Plannotator's own users when they are not supplied.
|
|
1333
|
+
`packages/editor` and `packages/review-editor` have ZERO source diff in this
|
|
1334
|
+
release; core is UNCHANGED at `0.25.5`, so **ui 0.45.0 publishes alone**.
|
|
1335
|
+
|
|
1336
|
+
### 1. `renderCardHeader` — the twin of `renderCardFooter`
|
|
1337
|
+
|
|
1338
|
+
```ts
|
|
1339
|
+
renderCardHeader?: (annotation: Annotation) => React.ReactNode;
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
Rendered inside each plan-annotation card's HEADER row — the row carrying the
|
|
1343
|
+
type word, the `diff` / page / `Unanchored` chips and `author · time` — after
|
|
1344
|
+
the timestamp and before the built-in edit/delete cluster, which keeps the
|
|
1345
|
+
right edge on its `ml-auto`. That is the slot for a status stamp (resolved,
|
|
1346
|
+
needs reply, a reviewer badge); the footer remains the slot for reply and
|
|
1347
|
+
resolve UI.
|
|
1348
|
+
|
|
1349
|
+
It follows the footer's contract line for line:
|
|
1350
|
+
|
|
1351
|
+
- The wrapper is `[data-annotation-card-header="true"]` (the footer's
|
|
1352
|
+
`[data-annotation-card-footer="true"]` spelled for the header — the attribute
|
|
1353
|
+
the host queries and styles), and it stops `click` and `keydown`
|
|
1354
|
+
propagation, so interacting with your stamp never selects the card.
|
|
1355
|
+
- **It renders under `readOnly`**, exactly as the footer does and for the same
|
|
1356
|
+
reason: the contents are host-owned and a stamp is a read affordance. The
|
|
1357
|
+
built-in mutation affordances stay hidden.
|
|
1358
|
+
- In the All-files grouped view it rides the OPEN document's cards only
|
|
1359
|
+
(`group.isCurrent`), the footer's rule — a slot built from the open
|
|
1360
|
+
document's state has nothing to say about another document's card.
|
|
1361
|
+
- Returning `null` / `undefined` / `false` for a card renders no wrapper for
|
|
1362
|
+
that card. Omitting the prop renders no wrapper anywhere: there is no empty
|
|
1363
|
+
container to lay out or style around.
|
|
1364
|
+
- The header row is ONE non-wrapping flex line shared with the type word, the
|
|
1365
|
+
`diff` / page / `Unanchored` chips and the timestamp. The wrapper is
|
|
1366
|
+
`min-w-0` and shrinks, but a host node that cannot shrink will overflow
|
|
1367
|
+
toward the built-in actions rather than wrap — keep the stamp compact, or
|
|
1368
|
+
give it its own truncation.
|
|
1369
|
+
|
|
1370
|
+
**Not on `CodeAnnotation` cards.** `CodeAnnotationCard` (the review-editor
|
|
1371
|
+
shape) takes no `renderCardFooter` either, so neither new prop was threaded
|
|
1372
|
+
into it; mirroring the footer is the rule. Ask if a host needs both there.
|
|
1373
|
+
|
|
1374
|
+
### 2. `mentionSource` — the `@` picker on the card's edit box
|
|
1375
|
+
|
|
1376
|
+
```ts
|
|
1377
|
+
mentionSource?: MentionSource; // the same type, unchanged, from 0.43.0
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
Supplied on the panel and threaded to every plan-annotation card, it gives the
|
|
1381
|
+
card's EDIT textarea the same `@` machinery `CommentPopover` has:
|
|
1382
|
+
`useMentionAutocomplete` over the host's `people`, the portaled `MentionPicker`,
|
|
1383
|
+
the same grammar (`@` at a word boundary, never inside `a@b.com`), the same
|
|
1384
|
+
no-preselection keyboard rule (Enter is a newline until an arrow engages a
|
|
1385
|
+
row), the same `onMentionsChange`, and the same `onPickBlocked` no-access
|
|
1386
|
+
behavior (a blocked pick inserts NOTHING and the host shows its own dialog).
|
|
1387
|
+
|
|
1388
|
+
**Key order at the textarea.** The menu is offered the key FIRST, then the
|
|
1389
|
+
card's own handlers run only if it did not consume the event:
|
|
1390
|
+
|
|
1391
|
+
- `Escape` while the menu is open closes the MENU (the hook consumes it and
|
|
1392
|
+
stops propagation). A second `Escape` cancels the edit, as it always did.
|
|
1393
|
+
- `Enter` with a row arrowed to inserts that person. `Enter` with nothing
|
|
1394
|
+
active is untouched — still a newline.
|
|
1395
|
+
- `Mod+Enter` is never consumed by the menu (the hook declines any event
|
|
1396
|
+
carrying a modifier), so save still saves.
|
|
1397
|
+
|
|
1398
|
+
ARIA follows `CommentPopover`: `aria-autocomplete="list"` and
|
|
1399
|
+
`aria-haspopup="listbox"` exist only when a source is supplied,
|
|
1400
|
+
`aria-controls` / `aria-owns` only while the menu is open, and
|
|
1401
|
+
`aria-activedescendant` only while a row is active. With no source all five
|
|
1402
|
+
resolve to `undefined`, so the rendered attribute list is the one the edit box
|
|
1403
|
+
has always had.
|
|
1404
|
+
|
|
1405
|
+
### 3. The save rule
|
|
1406
|
+
|
|
1407
|
+
`handleSaveEdit` called `onEdit({ text })`. It now calls:
|
|
1408
|
+
|
|
1409
|
+
```ts
|
|
1410
|
+
if (mentionSource && mentions.length > 0) onEdit({ text: editText, mentions });
|
|
1411
|
+
else onEdit({ text: editText });
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
which is the presence rule the creation composers already keep, one level
|
|
1415
|
+
down: **the key exists only when a source was supplied AND at least one id
|
|
1416
|
+
survived to save.** Never `mentions: []`, never the key with an `undefined`
|
|
1417
|
+
value — an untouched or pick-less edit calls `onEdit({ text })` byte for byte
|
|
1418
|
+
as it did in 0.44.0, so it can never wipe tags the annotation already carries.
|
|
1419
|
+
`source.onMentionsChange` fires from the hook exactly as it does in the
|
|
1420
|
+
composer, which on this surface means it reports `[]` once when the editor
|
|
1421
|
+
OPENS, before any pick: it is the live state of this edit session, not the
|
|
1422
|
+
annotation's stored `mentions`. Only `onEdit` is authoritative — a host that
|
|
1423
|
+
mirrors `onMentionsChange` into its own record must not treat that opening
|
|
1424
|
+
`[]` as a clear. The Save BUTTON and `Mod+Enter` go through the same call.
|
|
1425
|
+
|
|
1426
|
+
**"This edit session" is literal.** The edit box was extracted into
|
|
1427
|
+
`AnnotationEditComposer`, mounted only while a card is in edit mode, so the
|
|
1428
|
+
hook's tagged-people state lives and dies with one session: reopen the editor
|
|
1429
|
+
and nobody is picked, and a save with no new pick is `{ text }` again — even
|
|
1430
|
+
if the previous session's `@Label` token is still sitting in the body. That is
|
|
1431
|
+
the conservative direction (the host owns `mentions` from then on, 0.43.1 §4),
|
|
1432
|
+
and it is the same reason a restored draft starts with nobody tagged.
|
|
1433
|
+
|
|
1434
|
+
### 4. No chips in the edit box (known difference, and the follow-up)
|
|
1435
|
+
|
|
1436
|
+
A picked token renders as a CHIP in `CommentPopover` (0.44.0) and as plain
|
|
1437
|
+
text here. The chip layer is `ComposerTextarea`'s mirrored, aria-hidden
|
|
1438
|
+
overlay behind a transparent-text textarea, with scroll mirroring and IME
|
|
1439
|
+
handling; the card's edit box is a plain `<textarea>` with its own sizing and
|
|
1440
|
+
classes. Duplicating that overlay for one more textarea is exactly the "two
|
|
1441
|
+
mirrored layers" mistake 0.44.0 avoided.
|
|
1442
|
+
|
|
1443
|
+
**Named follow-up: move the card's edit box onto `ComposerTextarea`.** That is
|
|
1444
|
+
the one change that gets chips here without a second overlay, and it is a
|
|
1445
|
+
visible change to a surface Plannotator itself renders — a separate PR with
|
|
1446
|
+
its own no-op argument, not a rider on a seam release.
|
|
1447
|
+
|
|
1448
|
+
Everything else about mentions is inherited unchanged and documented in the
|
|
1449
|
+
0.43.x / 0.44.0 sections above, including the three limits: two labels that
|
|
1450
|
+
sanitize to the same token, a restored draft reporting no ids, and a label
|
|
1451
|
+
that is a prefix of another label.
|
|
1452
|
+
|
|
1453
|
+
### 5. One internal module, not a new seam
|
|
1454
|
+
|
|
1455
|
+
`components/MentionAutocomplete.tsx` (`MentionAutocompleteMenu` +
|
|
1456
|
+
`mentionActiveOptionId`) is the glue between a `useMentionAutocomplete` result
|
|
1457
|
+
and `MentionPicker` — the id→index lookup, the no-op hover and the
|
|
1458
|
+
`aria-activedescendant` string. It exists so the third mount did not become a
|
|
1459
|
+
third verbatim copy of the same fifteen lines; `CommentPopover`'s two mounts
|
|
1460
|
+
were moved onto it in the same change, with no DOM difference (proven below).
|
|
1461
|
+
It is **internal**: it is not on the supported-import list and hosts never
|
|
1462
|
+
touch it — they pass `mentionSource`.
|
|
1463
|
+
|
|
1464
|
+
### The no-op guarantee, and how it is pinned
|
|
1465
|
+
|
|
1466
|
+
The same components were mounted on `origin/main` and on this branch in one
|
|
1467
|
+
harness and their `outerHTML` diffed, with `addEventListener` and `setTimeout`
|
|
1468
|
+
counts taken across each mount. With NEITHER new prop supplied, all six are
|
|
1469
|
+
byte-identical with identical counts:
|
|
1470
|
+
|
|
1471
|
+
| scenario | bytes | listeners | timers |
|
|
1472
|
+
| --- | --- | --- | --- |
|
|
1473
|
+
| empty panel | 733 | 140 | 0 |
|
|
1474
|
+
| nine cards (comment, deletion, global, quick label, external `source`, unanchored, `inReplyTo` reply, a card with `renderCardFooter`, a `diffContext` card) | 17543 | 141 | 0 |
|
|
1475
|
+
| the same panel `readOnly` | 7854 | 141 | 0 |
|
|
1476
|
+
| a card in EDIT mode | 18586 | 142 | 0 |
|
|
1477
|
+
| the All-files grouped view (two document groups) | 19629 | 141 | 0 |
|
|
1478
|
+
| `CommentPopover` (the module the glue refactor touched) | 3130 | 286 | 1 |
|
|
1479
|
+
|
|
1480
|
+
The edit-mode row is the one that matters for the hook: mounted with
|
|
1481
|
+
`source: undefined`, `useMentionAutocomplete` registers no listener, opens no
|
|
1482
|
+
menu state, returns the frozen empty id array, and the picker renders nothing —
|
|
1483
|
+
one extra listener would have shown up as 143.
|
|
1484
|
+
|
|
1485
|
+
On top of the diff, committed tests pin the structure rather than a snapshot:
|
|
1486
|
+
with neither prop there is no `[data-annotation-card-header]` on any card, the
|
|
1487
|
+
edit textarea carries none of the five mention ARIA attributes, typing `@`
|
|
1488
|
+
opens nothing, and `onEdit` is called with an updates object whose key list is
|
|
1489
|
+
exactly `['text']`.
|
|
1490
|
+
|
|
1491
|
+
Tests (both DOM-gated, both added to the workflow's DOM_TESTS step):
|
|
1492
|
+
`components/AnnotationPanel.cardHeader.test.tsx` (6) and
|
|
1493
|
+
`components/AnnotationPanel.editMentions.test.tsx` (10).
|
|
1494
|
+
|
|
1174
1495
|
## Publishing & versioning
|
|
1175
1496
|
|
|
1497
|
+
- **ui 0.45.0 (annotation card header slot + mentions on the card's edit box): `@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.44.0, both props on `AnnotationPanel`: `renderCardHeader` (the header-row twin of `renderCardFooter`, wrapper `[data-annotation-card-header]`, renders under `readOnly`, open-document cards only in the All-files view) and `mentionSource` (the 0.43.0 type, applied to the card's EDIT box, saving `onEdit(id, { text, mentions })` only when a source was supplied and a pick survived). Nothing is removed, no new supported imports (`components/MentionAutocomplete` is internal glue), no export-, share- or archive-visible change, and Plannotator passes neither — `packages/editor` and `packages/review-editor` have zero source diff, and the panel is byte-identical to 0.44.0. Known difference from `CommentPopover`: no chips in the card's edit box (follow-up named in the section). See "Annotation card header slot and mentions on the edit box (0.45.0)".
|
|
1498
|
+
- **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)".
|
|
1176
1499
|
- **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)".
|
|
1177
1500
|
- **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)".
|
|
1178
1501
|
- **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
|
@@ -258,6 +258,24 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
|
258
258
|
no-access dialog) and inserts normally when you do not. Not wired to any
|
|
259
259
|
Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
|
|
260
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.
|
|
261
279
|
- **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
|
|
262
280
|
viewers, forwarded to every comment composer each of them mounts, so a host
|
|
263
281
|
wires mentions once per surface instead of per composer. The ids the author
|
|
@@ -268,7 +286,51 @@ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
|
|
|
268
286
|
|
|
269
287
|
See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
|
|
270
288
|
rules and the threading points, and § "mentionSource on the viewers (0.43.1)"
|
|
271
|
-
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).
|
|
293
|
+
|
|
294
|
+
### Annotation card seams (`AnnotationPanel`; 0.45.0)
|
|
295
|
+
|
|
296
|
+
Two more opt-in props, both on the panel and both defaulting to today's
|
|
297
|
+
behavior — pass neither and the panel renders byte-for-byte what it rendered
|
|
298
|
+
in 0.44.0 (proven by diffing mounted `outerHTML` against the base commit for
|
|
299
|
+
an empty panel, every card kind, a card in edit mode, `readOnly` and the
|
|
300
|
+
All-files view).
|
|
301
|
+
|
|
302
|
+
- **`renderCardHeader?: (annotation) => ReactNode`** — the twin of
|
|
303
|
+
`renderCardFooter`, rendered inside each plan-annotation card's header row
|
|
304
|
+
after `author · time` and before the built-in edit/delete actions: the place
|
|
305
|
+
for a status stamp (resolved, needs reply, a reviewer badge). Its wrapper is
|
|
306
|
+
`[data-annotation-card-header]` and swallows clicks and keydowns, so
|
|
307
|
+
interacting with your stamp never selects the card. It renders under
|
|
308
|
+
`readOnly` for the same reason the footer does — a stamp is a read
|
|
309
|
+
affordance — and, like the footer, only on the OPEN document's cards in the
|
|
310
|
+
All-files grouped view. Return `null` for a card and no wrapper exists for
|
|
311
|
+
it; omit the prop and no wrapper exists at all. The header row is one
|
|
312
|
+
non-wrapping flex line, so keep the stamp compact: the wrapper shrinks but
|
|
313
|
+
a node that cannot will overflow toward the built-in actions.
|
|
314
|
+
- **`mentionSource?: MentionSource`** — the same source `Viewer` and
|
|
315
|
+
`HtmlViewer` take (0.43.1), applied to the card's EDIT box, so a comment can
|
|
316
|
+
be re-tagged after it was written. The grammar, the picker, the keyboard
|
|
317
|
+
rules and `onPickBlocked` are the ones documented above. Saving an edit
|
|
318
|
+
calls `onEdit(id, { text, mentions })` **only** when a source is supplied
|
|
319
|
+
AND at least one person was picked in that edit session whose token
|
|
320
|
+
survives; otherwise it is the `onEdit(id, { text })` it always was, so an
|
|
321
|
+
untouched or pick-less edit can never wipe tags the annotation already
|
|
322
|
+
carries. Reopening the editor starts a fresh session with nobody picked, and
|
|
323
|
+
`onMentionsChange` follows that session — it reports `[]` once when the
|
|
324
|
+
editor opens, so treat it as the live picker state, never as the
|
|
325
|
+
annotation's stored tags.
|
|
326
|
+
**One difference from `CommentPopover`: no chips** — the token stays plain
|
|
327
|
+
text, because the chip layer lives in the composer's mirrored overlay and is
|
|
328
|
+
not worth duplicating; the follow-up is to move the card's edit box onto
|
|
329
|
+
`ComposerTextarea`.
|
|
330
|
+
|
|
331
|
+
Neither prop reaches `CodeAnnotation` cards (the review-editor shape), which
|
|
332
|
+
take no `renderCardFooter` either. See HANDOFF.md § "Annotation card header
|
|
333
|
+
slot and mentions on the edit box (0.45.0)".
|
|
272
334
|
|
|
273
335
|
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
274
336
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect, useId } from 'react';
|
|
2
2
|
import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
|
|
3
|
+
import type { MentionSource } from '../utils/mentions';
|
|
4
|
+
import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
|
|
5
|
+
import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
|
|
3
6
|
import { isCurrentUser } from '../utils/identity';
|
|
4
7
|
import { ImageThumbnail } from './ImageThumbnail';
|
|
5
8
|
import { EditorAnnotationCard } from './EditorAnnotationCard';
|
|
@@ -126,6 +129,21 @@ interface PanelProps {
|
|
|
126
129
|
* resolve UI). The panel stays presentation-only; clicks inside the slot
|
|
127
130
|
* do not select the card. Default: nothing rendered. */
|
|
128
131
|
renderCardFooter?: (annotation: Annotation) => React.ReactNode;
|
|
132
|
+
/** Host slot rendered inside the header row of each plan-annotation card,
|
|
133
|
+
* after `author · time` and before the built-in edit/delete actions — the
|
|
134
|
+
* place for a status stamp (resolved, needs reply, a reviewer badge). Twin
|
|
135
|
+
* of `renderCardFooter`: the panel stays presentation-only, clicks inside
|
|
136
|
+
* the slot do not select the card, and it renders under `readOnly` too
|
|
137
|
+
* (a stamp is a read affordance). Default: nothing rendered, and no
|
|
138
|
+
* wrapper element exists at all. */
|
|
139
|
+
renderCardHeader?: (annotation: Annotation) => React.ReactNode;
|
|
140
|
+
/** Opt-in host capability: an `@` mention source for the EDIT box of each
|
|
141
|
+
* plan-annotation card, the same `MentionSource` `Viewer` and `HtmlViewer`
|
|
142
|
+
* take for their creation composers. Supplied → the edit textarea grows
|
|
143
|
+
* the `@` picker and a save that follows a pick carries the surviving ids
|
|
144
|
+
* as `Annotation.mentions`. Absent → the edit box is exactly what it was:
|
|
145
|
+
* no listener, no picker, no `mentions` key. */
|
|
146
|
+
mentionSource?: MentionSource;
|
|
129
147
|
/** Hide every built-in mutation affordance (delete/edit, direct-edit
|
|
130
148
|
* discard). The host footer slot still renders: its contents are
|
|
131
149
|
* host-owned and may be read affordances (replies, links), so the host
|
|
@@ -183,6 +201,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
183
201
|
onOtherFileAnnotationsClick,
|
|
184
202
|
directEdits = null,
|
|
185
203
|
renderCardFooter,
|
|
204
|
+
renderCardHeader,
|
|
205
|
+
mentionSource,
|
|
186
206
|
readOnly = false,
|
|
187
207
|
presentation = 'panel',
|
|
188
208
|
unanchoredIds,
|
|
@@ -421,6 +441,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
421
441
|
: (onEditInDocument ? (updates: Partial<Annotation>) => onEditInDocument(group.path, annotation.id, updates) : undefined)}
|
|
422
442
|
readOnly={readOnly}
|
|
423
443
|
footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
|
|
444
|
+
header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
|
|
445
|
+
mentionSource={mentionSource}
|
|
424
446
|
unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
|
|
425
447
|
/>
|
|
426
448
|
);
|
|
@@ -528,6 +550,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
528
550
|
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
529
551
|
readOnly={readOnly}
|
|
530
552
|
footer={renderCardFooter?.(entry.annotation)}
|
|
553
|
+
header={renderCardHeader?.(entry.annotation)}
|
|
554
|
+
mentionSource={mentionSource}
|
|
531
555
|
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
532
556
|
/>
|
|
533
557
|
</div>
|
|
@@ -542,6 +566,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
542
566
|
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
543
567
|
readOnly={readOnly}
|
|
544
568
|
footer={renderCardFooter?.(entry.annotation)}
|
|
569
|
+
header={renderCardHeader?.(entry.annotation)}
|
|
570
|
+
mentionSource={mentionSource}
|
|
545
571
|
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
546
572
|
/>
|
|
547
573
|
)
|
|
@@ -762,6 +788,87 @@ const DirectEditsCard: React.FC<{
|
|
|
762
788
|
);
|
|
763
789
|
};
|
|
764
790
|
|
|
791
|
+
/**
|
|
792
|
+
* The plan-annotation card's edit box.
|
|
793
|
+
*
|
|
794
|
+
* Mounted only while a card is in edit mode, which is also what scopes an
|
|
795
|
+
* `@` pick to ONE edit session: `useMentionAutocomplete`'s tagged-people
|
|
796
|
+
* state lives and dies with this component, so reopening the editor starts
|
|
797
|
+
* with nobody picked and a pick-less save is `{ text }` again.
|
|
798
|
+
*
|
|
799
|
+
* Without a `mentionSource` every mention path here is inert — no listener,
|
|
800
|
+
* no menu state, no picker DOM, and the textarea's attributes are exactly
|
|
801
|
+
* the ones it rendered before the prop existed (all five mention-related
|
|
802
|
+
* ARIA attributes resolve to `undefined`).
|
|
803
|
+
*
|
|
804
|
+
* KNOWN DIFFERENCE from `CommentPopover`: no chip overlay. The token stays
|
|
805
|
+
* plain text here. Chips live in `ComposerTextarea` (mirrored overlay behind
|
|
806
|
+
* a transparent-text textarea) and duplicating that layer is not the way to
|
|
807
|
+
* get them — the follow-up is to make this box use `ComposerTextarea`.
|
|
808
|
+
*/
|
|
809
|
+
const AnnotationEditComposer: React.FC<{
|
|
810
|
+
value: string;
|
|
811
|
+
onChange: (text: string) => void;
|
|
812
|
+
/** Save with the mention ids the body still tags (empty without a source). */
|
|
813
|
+
onSave: (mentions: readonly string[]) => void;
|
|
814
|
+
onCancel: () => void;
|
|
815
|
+
mentionSource?: MentionSource;
|
|
816
|
+
}> = ({ value, onChange, onSave, onCancel, mentionSource }) => {
|
|
817
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
818
|
+
const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource });
|
|
819
|
+
const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`;
|
|
820
|
+
const menuOpen = mention.menu !== null;
|
|
821
|
+
|
|
822
|
+
// Focus-on-open, unchanged: this component mounts exactly when editing starts.
|
|
823
|
+
useEffect(() => {
|
|
824
|
+
if (textareaRef.current) {
|
|
825
|
+
textareaRef.current.focus();
|
|
826
|
+
textareaRef.current.select();
|
|
827
|
+
}
|
|
828
|
+
}, []);
|
|
829
|
+
|
|
830
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
831
|
+
// The menu is offered the key FIRST: while it is open Escape closes it
|
|
832
|
+
// (and only a second Escape cancels the edit), and an arrowed-to row
|
|
833
|
+
// takes Enter. Mod+Enter is never consumed by the menu.
|
|
834
|
+
if (mention.onKeyDown(e)) return;
|
|
835
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
onSave(mention.mentionIds);
|
|
838
|
+
} else if (e.key === 'Escape') {
|
|
839
|
+
e.preventDefault();
|
|
840
|
+
onCancel();
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
846
|
+
<textarea
|
|
847
|
+
data-pn-mobile-editable="true"
|
|
848
|
+
ref={textareaRef}
|
|
849
|
+
value={value}
|
|
850
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => { onChange(e.target.value); mention.onSelect(); }}
|
|
851
|
+
onSelect={mention.onSelect}
|
|
852
|
+
onKeyDown={handleKeyDown}
|
|
853
|
+
placeholder="Add your comment..."
|
|
854
|
+
aria-label="Annotation comment"
|
|
855
|
+
aria-autocomplete={mentionSource ? 'list' : undefined}
|
|
856
|
+
aria-haspopup={mentionSource ? 'listbox' : undefined}
|
|
857
|
+
aria-controls={menuOpen ? listboxId : undefined}
|
|
858
|
+
aria-owns={menuOpen ? listboxId : undefined}
|
|
859
|
+
aria-activedescendant={mentionActiveOptionId(listboxId, mention.menu)}
|
|
860
|
+
className="w-full resize-none rounded-lg border border-border/50 bg-card px-2.5 py-2 text-base leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20"
|
|
861
|
+
style={{ fieldSizing: 'content', minHeight: 44 } as React.CSSProperties}
|
|
862
|
+
/>
|
|
863
|
+
<div className="mt-1.5 flex justify-end gap-1.5">
|
|
864
|
+
<Button variant="ghost" size="xxs" onClick={onCancel}>Cancel</Button>
|
|
865
|
+
<Button size="xxs" disabled={!value.trim()} onClick={() => onSave(mention.mentionIds)}>Save</Button>
|
|
866
|
+
</div>
|
|
867
|
+
<MentionAutocompleteMenu id={listboxId} menu={mention.menu} onSelect={mention.select} />
|
|
868
|
+
</div>
|
|
869
|
+
);
|
|
870
|
+
};
|
|
871
|
+
|
|
765
872
|
const AnnotationCard: React.FC<{
|
|
766
873
|
annotation: Annotation;
|
|
767
874
|
isSelected: boolean;
|
|
@@ -771,19 +878,15 @@ const AnnotationCard: React.FC<{
|
|
|
771
878
|
onEdit?: (updates: Partial<Annotation>) => void;
|
|
772
879
|
readOnly?: boolean;
|
|
773
880
|
footer?: React.ReactNode;
|
|
881
|
+
/** Host slot in the card's header row (see `renderCardHeader`). */
|
|
882
|
+
header?: React.ReactNode;
|
|
883
|
+
/** An `@` mention source for the edit box (see `mentionSource`). */
|
|
884
|
+
mentionSource?: MentionSource;
|
|
774
885
|
/** The annotation has no live location in the document (host-reported). */
|
|
775
886
|
unanchored?: boolean;
|
|
776
|
-
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, unanchored = false }) => {
|
|
887
|
+
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false }) => {
|
|
777
888
|
const [isEditing, setIsEditing] = useState(false);
|
|
778
889
|
const [editText, setEditText] = useState(annotation.text || '');
|
|
779
|
-
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
780
|
-
|
|
781
|
-
useEffect(() => {
|
|
782
|
-
if (isEditing && textareaRef.current) {
|
|
783
|
-
textareaRef.current.focus();
|
|
784
|
-
textareaRef.current.select();
|
|
785
|
-
}
|
|
786
|
-
}, [isEditing]);
|
|
787
890
|
|
|
788
891
|
// Update editText when annotation.text changes
|
|
789
892
|
useEffect(() => {
|
|
@@ -798,9 +901,14 @@ const AnnotationCard: React.FC<{
|
|
|
798
901
|
setIsEditing(true);
|
|
799
902
|
};
|
|
800
903
|
|
|
801
|
-
const handleSaveEdit = () => {
|
|
904
|
+
const handleSaveEdit = (mentions: readonly string[]) => {
|
|
802
905
|
if (onEdit) {
|
|
803
|
-
|
|
906
|
+
// The presence rule, same as the creation composers': the key exists
|
|
907
|
+
// only when a source was supplied AND at least one id survived to save,
|
|
908
|
+
// so an untouched or pick-less edit can never wipe tags the annotation
|
|
909
|
+
// already carries.
|
|
910
|
+
if (mentionSource && mentions.length > 0) onEdit({ text: editText, mentions });
|
|
911
|
+
else onEdit({ text: editText });
|
|
804
912
|
}
|
|
805
913
|
setIsEditing(false);
|
|
806
914
|
};
|
|
@@ -810,39 +918,19 @@ const AnnotationCard: React.FC<{
|
|
|
810
918
|
setIsEditing(false);
|
|
811
919
|
};
|
|
812
920
|
|
|
813
|
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
814
|
-
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
|
|
815
|
-
e.preventDefault();
|
|
816
|
-
handleSaveEdit();
|
|
817
|
-
} else if (e.key === 'Escape') {
|
|
818
|
-
e.preventDefault();
|
|
819
|
-
handleCancelEdit();
|
|
820
|
-
}
|
|
821
|
-
};
|
|
822
|
-
|
|
823
921
|
const typeColor = TYPE_COLOR[annotation.type] ?? 'text-muted-foreground';
|
|
824
922
|
const typeLabel = TYPE_LABEL[annotation.type] ?? 'Note';
|
|
825
923
|
const isGlobal = annotation.type === AnnotationType.GLOBAL_COMMENT;
|
|
826
924
|
|
|
827
|
-
//
|
|
925
|
+
// The edit box, mounted only while editing (see AnnotationEditComposer).
|
|
828
926
|
const editComposer = (
|
|
829
|
-
<
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
placeholder="Add your comment..."
|
|
837
|
-
aria-label="Annotation comment"
|
|
838
|
-
className="w-full resize-none rounded-lg border border-border/50 bg-card px-2.5 py-2 text-base leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20"
|
|
839
|
-
style={{ fieldSizing: 'content', minHeight: 44 } as React.CSSProperties}
|
|
840
|
-
/>
|
|
841
|
-
<div className="mt-1.5 flex justify-end gap-1.5">
|
|
842
|
-
<Button variant="ghost" size="xxs" onClick={handleCancelEdit}>Cancel</Button>
|
|
843
|
-
<Button size="xxs" disabled={!editText.trim()} onClick={handleSaveEdit}>Save</Button>
|
|
844
|
-
</div>
|
|
845
|
-
</div>
|
|
927
|
+
<AnnotationEditComposer
|
|
928
|
+
value={editText}
|
|
929
|
+
onChange={setEditText}
|
|
930
|
+
onSave={handleSaveEdit}
|
|
931
|
+
onCancel={handleCancelEdit}
|
|
932
|
+
mentionSource={mentionSource}
|
|
933
|
+
/>
|
|
846
934
|
);
|
|
847
935
|
|
|
848
936
|
return (
|
|
@@ -890,6 +978,19 @@ const AnnotationCard: React.FC<{
|
|
|
890
978
|
<span className="text-[10px] text-muted-foreground/50 truncate">
|
|
891
979
|
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
|
|
892
980
|
</span>
|
|
981
|
+
{/* Host header slot (a status stamp etc.) — interactions inside it
|
|
982
|
+
must not toggle card selection, exactly like the footer slot.
|
|
983
|
+
Rendered under readOnly too: its contents are host-owned. */}
|
|
984
|
+
{header != null && header !== false && (
|
|
985
|
+
<div
|
|
986
|
+
data-annotation-card-header="true"
|
|
987
|
+
className="flex min-w-0 items-center"
|
|
988
|
+
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
989
|
+
onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()}
|
|
990
|
+
>
|
|
991
|
+
{header}
|
|
992
|
+
</div>
|
|
993
|
+
)}
|
|
893
994
|
{!readOnly && (
|
|
894
995
|
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
|
895
996
|
{onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
|
|
@@ -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
|
-
import {
|
|
14
|
-
import type { MentionSource } from '../utils/mentions';
|
|
19
|
+
import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
|
|
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) {
|
|
@@ -532,10 +551,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
532
551
|
// the textarea's ARIA relationship points at whichever one is. All three are
|
|
533
552
|
// `undefined`/false with no menu open, which is every render Plannotator
|
|
534
553
|
// makes without a `mentionSource`.
|
|
535
|
-
const activeMentionOptionId =
|
|
536
|
-
mentionAc.menu === null || mentionAc.menu.activeIndex === null
|
|
537
|
-
? undefined
|
|
538
|
-
: `${mentionListboxId}-option-${mentionAc.menu.activeIndex}`;
|
|
554
|
+
const activeMentionOptionId = mentionActiveOptionId(mentionListboxId, mentionAc.menu);
|
|
539
555
|
const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId;
|
|
540
556
|
const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
|
|
541
557
|
const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
|
|
@@ -684,26 +700,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
684
700
|
sizeClassName="min-h-32 max-h-full"
|
|
685
701
|
skillReferences={skillReferences}
|
|
686
702
|
tokens={skillAc.referenceTokens}
|
|
703
|
+
mentionChips={mentionChips}
|
|
687
704
|
listboxId={composerListboxId}
|
|
688
705
|
listboxOpen={composerListboxOpen}
|
|
689
706
|
activeOptionId={activeComposerOptionId}
|
|
690
707
|
/>
|
|
691
708
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
heading={mentionAc.menu.heading}
|
|
698
|
-
active={mentionAc.menu.activeIndex}
|
|
699
|
-
anchor={mentionAc.menu.anchor}
|
|
700
|
-
onPick={(person) => {
|
|
701
|
-
const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
|
|
702
|
-
if (index >= 0) mentionAc.select(index);
|
|
703
|
-
}}
|
|
704
|
-
onHover={() => {}}
|
|
705
|
-
/>
|
|
706
|
-
)}
|
|
709
|
+
<MentionAutocompleteMenu
|
|
710
|
+
id={mentionListboxId}
|
|
711
|
+
menu={mentionAc.menu}
|
|
712
|
+
onSelect={mentionAc.select}
|
|
713
|
+
/>
|
|
707
714
|
</div>
|
|
708
715
|
|
|
709
716
|
{/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
|
|
@@ -849,26 +856,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
849
856
|
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
850
857
|
skillReferences={skillReferences}
|
|
851
858
|
tokens={skillAc.referenceTokens}
|
|
859
|
+
mentionChips={mentionChips}
|
|
852
860
|
listboxId={composerListboxId}
|
|
853
861
|
listboxOpen={composerListboxOpen}
|
|
854
862
|
activeOptionId={activeComposerOptionId}
|
|
855
863
|
/>
|
|
856
864
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
heading={mentionAc.menu.heading}
|
|
863
|
-
active={mentionAc.menu.activeIndex}
|
|
864
|
-
anchor={mentionAc.menu.anchor}
|
|
865
|
-
onPick={(person) => {
|
|
866
|
-
const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
|
|
867
|
-
if (index >= 0) mentionAc.select(index);
|
|
868
|
-
}}
|
|
869
|
-
onHover={() => {}}
|
|
870
|
-
/>
|
|
871
|
-
)}
|
|
865
|
+
<MentionAutocompleteMenu
|
|
866
|
+
id={mentionListboxId}
|
|
867
|
+
menu={mentionAc.menu}
|
|
868
|
+
onSelect={mentionAc.select}
|
|
869
|
+
/>
|
|
872
870
|
</div>
|
|
873
871
|
|
|
874
872
|
{/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
|
|
@@ -993,6 +991,78 @@ function syncOverlayGutter(
|
|
|
993
991
|
state.applied = scrollbar;
|
|
994
992
|
}
|
|
995
993
|
|
|
994
|
+
/**
|
|
995
|
+
* The default chip look: the theme's primary at a wash, one shade stronger
|
|
996
|
+
* than a skill reference's so the two token kinds in one overlay read as
|
|
997
|
+
* siblings rather than the same thing. Deliberately no ring: every class here
|
|
998
|
+
* is one the package already emitted, so a host build's CSS (and the portable
|
|
999
|
+
* guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
|
|
1000
|
+
* `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
|
|
1001
|
+
* layout, per the metric rule above.
|
|
1002
|
+
*/
|
|
1003
|
+
const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* One token's span in the highlight overlay.
|
|
1007
|
+
*
|
|
1008
|
+
* METRIC RULE, load-bearing for every token kind: a span may change COLOR,
|
|
1009
|
+
* BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
|
|
1010
|
+
* that moves a glyph — padding, margin, border width, font-weight,
|
|
1011
|
+
* letter-spacing, font-size — would shift the overlay's text off the
|
|
1012
|
+
* textarea's own layout and drift the caret away from the painted glyphs. A
|
|
1013
|
+
* pill's breathing room is faked with a paint-only `box-shadow` ring in the
|
|
1014
|
+
* chip's own background color.
|
|
1015
|
+
*/
|
|
1016
|
+
function renderTokenSpan(
|
|
1017
|
+
range: ComposerTokenRange,
|
|
1018
|
+
text: string,
|
|
1019
|
+
mentionTokenClassName?: string,
|
|
1020
|
+
): React.ReactNode {
|
|
1021
|
+
if (range.kind === 'mention') {
|
|
1022
|
+
// `data-mention-token` / `data-mention-kind` are the host's styling hook;
|
|
1023
|
+
// `tokenClassName` is appended verbatim and is the host's to keep
|
|
1024
|
+
// metric-safe (see the rule above).
|
|
1025
|
+
return (
|
|
1026
|
+
<span
|
|
1027
|
+
key={`mention-${range.start}`}
|
|
1028
|
+
data-mention-token={range.person.id}
|
|
1029
|
+
data-mention-kind={range.person.kind}
|
|
1030
|
+
className={`${MENTION_CHIP_CLASSES}${
|
|
1031
|
+
mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
|
|
1032
|
+
}`}
|
|
1033
|
+
>
|
|
1034
|
+
{text}
|
|
1035
|
+
</span>
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
1039
|
+
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
1040
|
+
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
1041
|
+
// the textarea — this overlay is aria-hidden.
|
|
1042
|
+
return (
|
|
1043
|
+
<span
|
|
1044
|
+
key={`skill-${range.start}`}
|
|
1045
|
+
data-skill-ref-token={range.skill.entry.name}
|
|
1046
|
+
data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
|
|
1047
|
+
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
1048
|
+
range.skill.entry.humanOnly
|
|
1049
|
+
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
1050
|
+
: ''
|
|
1051
|
+
}`}
|
|
1052
|
+
>
|
|
1053
|
+
{text}
|
|
1054
|
+
</span>
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** The mention half of the highlight layer. Null → no mention source at all. */
|
|
1059
|
+
export interface ComposerMentionChips {
|
|
1060
|
+
/** The people whose `@Label` token still survives in the text. */
|
|
1061
|
+
readonly people: readonly MentionPerson[];
|
|
1062
|
+
/** Host class appended to each chip (see `MentionSource.tokenClassName`). */
|
|
1063
|
+
readonly tokenClassName?: string;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
996
1066
|
interface ComposerTextareaProps {
|
|
997
1067
|
value: string;
|
|
998
1068
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
@@ -1005,8 +1075,14 @@ interface ComposerTextareaProps {
|
|
|
1005
1075
|
textareaRef: (el: HTMLTextAreaElement | null) => void;
|
|
1006
1076
|
/** Positioned skill-reference occurrences to highlight. */
|
|
1007
1077
|
tokens: SkillReferenceToken[];
|
|
1008
|
-
/** Off →
|
|
1078
|
+
/** Off → no skill-reference contribution to the highlight layer. */
|
|
1009
1079
|
skillReferences: boolean;
|
|
1080
|
+
/**
|
|
1081
|
+
* The mention contribution, or null with no `mentionSource`. Present (even
|
|
1082
|
+
* with nobody tagged yet) it turns the overlay on, so the first pick paints
|
|
1083
|
+
* a chip without swapping the textarea for a different element.
|
|
1084
|
+
*/
|
|
1085
|
+
mentionChips: ComposerMentionChips | null;
|
|
1010
1086
|
/** ARIA relationship to the skill-reference listbox. */
|
|
1011
1087
|
listboxId: string;
|
|
1012
1088
|
listboxOpen: boolean;
|
|
@@ -1014,13 +1090,15 @@ interface ComposerTextareaProps {
|
|
|
1014
1090
|
}
|
|
1015
1091
|
|
|
1016
1092
|
/**
|
|
1017
|
-
* The composer's textarea. With
|
|
1018
|
-
* pre-feature `<textarea>`; with
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1093
|
+
* The composer's textarea. With NEITHER token source active this is exactly
|
|
1094
|
+
* the pre-feature `<textarea>`; with either one on, its tokens are painted by
|
|
1095
|
+
* a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
|
|
1096
|
+
* (a textarea cannot style substrings). ONE overlay serves both sources: two
|
|
1097
|
+
* mirrored layers could never stay pixel-aligned with each other, and only
|
|
1098
|
+
* one of them could own the scroll sync. The overlay shares the exact
|
|
1099
|
+
* font/padding/wrapping metrics and mirrors scroll position, and token spans
|
|
1100
|
+
* obey the metric rule on `renderTokenSpan` (paint only, never layout), so
|
|
1101
|
+
* the glyphs the browser lays out in the textarea and the glyphs the overlay
|
|
1024
1102
|
* paints coincide. During IME composition the overlay hides and the textarea
|
|
1025
1103
|
* text becomes visible again (`.pn-ref-composing`), keeping native
|
|
1026
1104
|
* composition rendering (underlines, candidate highlights) intact.
|
|
@@ -1035,6 +1113,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1035
1113
|
textareaRef,
|
|
1036
1114
|
tokens,
|
|
1037
1115
|
skillReferences,
|
|
1116
|
+
mentionChips,
|
|
1038
1117
|
listboxId,
|
|
1039
1118
|
listboxOpen,
|
|
1040
1119
|
activeOptionId,
|
|
@@ -1063,6 +1142,23 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1063
1142
|
[textareaRef],
|
|
1064
1143
|
);
|
|
1065
1144
|
|
|
1145
|
+
// The overlay exists while EITHER source is active. `mentionChips` turns it
|
|
1146
|
+
// on for the whole life of a mention composer, not only once somebody is
|
|
1147
|
+
// tagged, so a pick never swaps the textarea element under the caret.
|
|
1148
|
+
const mentionPeople = mentionChips?.people;
|
|
1149
|
+
const highlight = skillReferences || mentionChips !== null;
|
|
1150
|
+
|
|
1151
|
+
// The one list of spans the overlay paints, from every active source.
|
|
1152
|
+
// Skill references are the earlier group, so they win a byte both claim.
|
|
1153
|
+
const ranges = useMemo(
|
|
1154
|
+
() =>
|
|
1155
|
+
mergeTokenRanges(value, [
|
|
1156
|
+
skillReferences ? skillTokenRanges(tokens) : [],
|
|
1157
|
+
mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
|
|
1158
|
+
]),
|
|
1159
|
+
[value, tokens, skillReferences, mentionPeople],
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1066
1162
|
// Keep the mirror aligned when the value changes without a scroll event
|
|
1067
1163
|
// (e.g. programmatic insertion moving the caret into a scrolled region).
|
|
1068
1164
|
useEffect(() => {
|
|
@@ -1080,9 +1176,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1080
1176
|
});
|
|
1081
1177
|
observer.observe(el);
|
|
1082
1178
|
return () => observer.disconnect();
|
|
1083
|
-
}, [
|
|
1179
|
+
}, [highlight, syncScroll]);
|
|
1084
1180
|
|
|
1085
|
-
if (!
|
|
1181
|
+
if (!highlight) {
|
|
1086
1182
|
return (
|
|
1087
1183
|
<textarea
|
|
1088
1184
|
data-pn-mobile-editable="true"
|
|
@@ -1105,29 +1201,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1105
1201
|
|
|
1106
1202
|
const segments: React.ReactNode[] = [];
|
|
1107
1203
|
let pos = 0;
|
|
1108
|
-
|
|
1109
|
-
if (
|
|
1110
|
-
if (token.start > pos) segments.push(value.slice(pos, token.start));
|
|
1111
|
-
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
1112
|
-
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
1113
|
-
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
1114
|
-
// the textarea — this overlay is aria-hidden.
|
|
1204
|
+
for (const range of ranges) {
|
|
1205
|
+
if (range.start > pos) segments.push(value.slice(pos, range.start));
|
|
1115
1206
|
segments.push(
|
|
1116
|
-
|
|
1117
|
-
key={`${token.start}-${i}`}
|
|
1118
|
-
data-skill-ref-token={token.entry.name}
|
|
1119
|
-
data-skill-ref-human-only={token.entry.humanOnly ? 'true' : undefined}
|
|
1120
|
-
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
1121
|
-
token.entry.humanOnly
|
|
1122
|
-
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
1123
|
-
: ''
|
|
1124
|
-
}`}
|
|
1125
|
-
>
|
|
1126
|
-
{value.slice(token.start, token.end)}
|
|
1127
|
-
</span>,
|
|
1207
|
+
renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
|
|
1128
1208
|
);
|
|
1129
|
-
pos =
|
|
1130
|
-
}
|
|
1209
|
+
pos = range.end;
|
|
1210
|
+
}
|
|
1131
1211
|
segments.push(value.slice(pos));
|
|
1132
1212
|
|
|
1133
1213
|
return (
|
|
@@ -1135,7 +1215,10 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
1135
1215
|
<div
|
|
1136
1216
|
ref={overlayRef}
|
|
1137
1217
|
aria-hidden="true"
|
|
1138
|
-
|
|
1218
|
+
// Names the SKILL-reference layer, so a skill composer's overlay is
|
|
1219
|
+
// byte-identical to the one it rendered before mentions existed; a
|
|
1220
|
+
// mentions-only overlay is found by its mirror attribute below.
|
|
1221
|
+
data-skill-ref-overlay={skillReferences ? 'true' : undefined}
|
|
1139
1222
|
data-pn-mobile-editable-mirror="true"
|
|
1140
1223
|
className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
|
|
1141
1224
|
style={composing ? { visibility: 'hidden' } : undefined}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { MentionPicker } from './MentionPicker';
|
|
3
|
+
import type { MentionMenuState } from '../hooks/useMentionAutocomplete';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal glue between `useMentionAutocomplete` and `MentionPicker`: the
|
|
7
|
+
* id→index lookup, the no-op hover and the `aria-activedescendant` string
|
|
8
|
+
* that every composer mounting an `@` menu would otherwise repeat verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Not a host seam — hosts pass `mentionSource` and never see this. It exists
|
|
11
|
+
* so the third mount (the annotation card's edit box) did not become a third
|
|
12
|
+
* copy of the same fifteen lines.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The picker for a `useMentionAutocomplete` result. Renders nothing while no
|
|
17
|
+
* menu is open — which, with no `MentionSource`, is always.
|
|
18
|
+
*/
|
|
19
|
+
export const MentionAutocompleteMenu: React.FC<{
|
|
20
|
+
/** Listbox id the driving textarea points `aria-controls` at. */
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly menu: MentionMenuState | null;
|
|
23
|
+
/** `useMentionAutocomplete`'s `select`. */
|
|
24
|
+
readonly onSelect: (index: number) => void;
|
|
25
|
+
}> = ({ id, menu, onSelect }) => {
|
|
26
|
+
if (menu === null) return null;
|
|
27
|
+
return (
|
|
28
|
+
<MentionPicker
|
|
29
|
+
id={id}
|
|
30
|
+
people={menu.items}
|
|
31
|
+
emptyNotice={menu.emptyNotice}
|
|
32
|
+
heading={menu.heading}
|
|
33
|
+
active={menu.activeIndex}
|
|
34
|
+
anchor={menu.anchor}
|
|
35
|
+
onPick={(person) => {
|
|
36
|
+
const index = menu.items.findIndex((p) => p.id === person.id);
|
|
37
|
+
if (index >= 0) onSelect(index);
|
|
38
|
+
}}
|
|
39
|
+
onHover={() => {}}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `aria-activedescendant` for the textarea driving that menu: the id of the
|
|
46
|
+
* arrow-focused row, or `undefined` while nothing is active (the menu opens
|
|
47
|
+
* with nothing preselected) and while no menu is open at all.
|
|
48
|
+
*/
|
|
49
|
+
export function mentionActiveOptionId(
|
|
50
|
+
listboxId: string,
|
|
51
|
+
menu: MentionMenuState | null,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
if (menu === null || menu.activeIndex === null) return undefined;
|
|
54
|
+
return `${listboxId}-option-${menu.activeIndex}`;
|
|
55
|
+
}
|
|
@@ -34,6 +34,12 @@ export interface UseMentionAutocompleteResult {
|
|
|
34
34
|
select: (index: number) => void;
|
|
35
35
|
/** The ids whose readable token still survives in the text. */
|
|
36
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[];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
@@ -93,6 +99,7 @@ export function useMentionAutocomplete(options: {
|
|
|
93
99
|
() => (enabled ? survivors.map((p) => p.id) : NO_IDS),
|
|
94
100
|
[enabled, survivors],
|
|
95
101
|
);
|
|
102
|
+
const mentions = enabled ? survivors : NO_PEOPLE;
|
|
96
103
|
|
|
97
104
|
const onMentionsChange = source?.onMentionsChange;
|
|
98
105
|
const mentionsKey = mentionIds.join('\u0000');
|
|
@@ -244,5 +251,6 @@ export function useMentionAutocomplete(options: {
|
|
|
244
251
|
onSelect: readCaret,
|
|
245
252
|
select,
|
|
246
253
|
mentionIds,
|
|
254
|
+
mentions,
|
|
247
255
|
};
|
|
248
256
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment composer's TOKEN HIGHLIGHT LAYER, as pure ranges.
|
|
3
|
+
*
|
|
4
|
+
* A textarea cannot style substrings, so `CommentPopover` paints its text
|
|
5
|
+
* through one mirrored, aria-hidden overlay rendered behind a
|
|
6
|
+
* transparent-text textarea. That overlay used to know about exactly one kind
|
|
7
|
+
* of token (skill references). It now paints a MERGED list of ranges produced
|
|
8
|
+
* by one or more sources, so a second source (host `@` mentions) needs no
|
|
9
|
+
* second overlay — two mirrored layers could never stay pixel-aligned with
|
|
10
|
+
* each other, and only one of them could own the scroll sync.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure: no DOM, no styling, no React. The component maps a
|
|
13
|
+
* range to its span (that is where Tailwind classes and `data-*` attributes
|
|
14
|
+
* live, so the class scanner still sees them); this module only decides WHICH
|
|
15
|
+
* bytes are a token and which token wins when two sources claim the same
|
|
16
|
+
* ones.
|
|
17
|
+
*/
|
|
18
|
+
import { mentionToken, type MentionPerson } from './mentions';
|
|
19
|
+
import type { SkillReferenceToken } from './skillReferences';
|
|
20
|
+
|
|
21
|
+
/** One highlighted span of the composer's text, half-open `[start, end)`. */
|
|
22
|
+
export type ComposerTokenRange =
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'skill';
|
|
25
|
+
readonly start: number;
|
|
26
|
+
readonly end: number;
|
|
27
|
+
readonly skill: SkillReferenceToken;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'mention';
|
|
31
|
+
readonly start: number;
|
|
32
|
+
readonly end: number;
|
|
33
|
+
readonly person: MentionPerson;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The skill-reference source: the positioned occurrences the autocomplete
|
|
38
|
+
* already found, unchanged. Order, spans and duplicates are preserved — a
|
|
39
|
+
* name referenced twice highlights twice.
|
|
40
|
+
*/
|
|
41
|
+
export function skillTokenRanges(
|
|
42
|
+
tokens: readonly SkillReferenceToken[],
|
|
43
|
+
): ComposerTokenRange[] {
|
|
44
|
+
return tokens.map((skill) => ({
|
|
45
|
+
kind: 'skill',
|
|
46
|
+
start: skill.start,
|
|
47
|
+
end: skill.end,
|
|
48
|
+
skill,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The mention source: every occurrence of each tagged person's readable
|
|
54
|
+
* `@Label` token in the text.
|
|
55
|
+
*
|
|
56
|
+
* Driven by the mention ID MODEL, never by a regex over arbitrary `@words`:
|
|
57
|
+
* the people passed in are the ones the author actually picked and whose
|
|
58
|
+
* token still survives (`survivingMentions`), so editing a byte of a token
|
|
59
|
+
* un-chips it in the same breath as it untags the person: a chip follows the
|
|
60
|
+
* body, never a stale pick. (The reported IDS can lag in one inherited case —
|
|
61
|
+
* a label that is a prefix of another label — see HANDOFF § "Mention token
|
|
62
|
+
* chips in the composer".)
|
|
63
|
+
*
|
|
64
|
+
* KNOWN, INHERITED LIMITATION: two people whose labels sanitize to the same
|
|
65
|
+
* token are indistinguishable in a plain-text body, so the FIRST of them
|
|
66
|
+
* listed owns every occurrence of it. That is the same first-match rule
|
|
67
|
+
* `survivingMentions` applies to the ids; it renders a chip either way and
|
|
68
|
+
* never throws.
|
|
69
|
+
*/
|
|
70
|
+
export function mentionTokenRanges(
|
|
71
|
+
text: string,
|
|
72
|
+
people: readonly MentionPerson[],
|
|
73
|
+
): ComposerTokenRange[] {
|
|
74
|
+
const ranges: ComposerTokenRange[] = [];
|
|
75
|
+
const claimed = new Set<string>();
|
|
76
|
+
for (const person of people) {
|
|
77
|
+
const token = mentionToken(person);
|
|
78
|
+
// A person whose label sanitizes to nothing would make every bare `@` a
|
|
79
|
+
// chip; and a token already claimed belongs to the person listed first.
|
|
80
|
+
if (token.length <= 1 || claimed.has(token)) continue;
|
|
81
|
+
claimed.add(token);
|
|
82
|
+
let from = text.indexOf(token);
|
|
83
|
+
while (from !== -1) {
|
|
84
|
+
ranges.push({ kind: 'mention', start: from, end: from + token.length, person });
|
|
85
|
+
from = text.indexOf(token, from + token.length);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return ranges;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The one list the overlay paints: every source's ranges, in document order,
|
|
93
|
+
* with overlaps resolved DETERMINISTICALLY and stale ranges dropped.
|
|
94
|
+
*
|
|
95
|
+
* `groups` is in priority order (earlier wins a tie). The rules, applied in
|
|
96
|
+
* this order at each position:
|
|
97
|
+
*
|
|
98
|
+
* 1. a range outside `[0, text.length)`, or empty/inverted, is dropped —
|
|
99
|
+
* these are ranges computed for a text the composer has since changed;
|
|
100
|
+
* 2. earlier `start` wins;
|
|
101
|
+
* 3. at the same start, the LONGER range wins (so `@Marcus Chen` beats a
|
|
102
|
+
* `@Marcus` that is also tagged, rather than chipping half of it);
|
|
103
|
+
* 4. at the same start and length, the earlier group wins;
|
|
104
|
+
* 5. a range that begins inside one already kept is dropped outright — the
|
|
105
|
+
* overlay is a sequence of non-overlapping spans and nothing may nest.
|
|
106
|
+
*
|
|
107
|
+
* With a single skill source this reproduces the pre-refactor loop exactly
|
|
108
|
+
* (which dropped a token whose `start` fell behind the cursor or whose `end`
|
|
109
|
+
* ran past the text); its ranges arrive sorted and non-overlapping, so rules
|
|
110
|
+
* 2-5 never fire.
|
|
111
|
+
*/
|
|
112
|
+
export function mergeTokenRanges(
|
|
113
|
+
text: string,
|
|
114
|
+
groups: readonly (readonly ComposerTokenRange[])[],
|
|
115
|
+
): ComposerTokenRange[] {
|
|
116
|
+
const candidates: { range: ComposerTokenRange; priority: number }[] = [];
|
|
117
|
+
groups.forEach((group, priority) => {
|
|
118
|
+
for (const range of group) {
|
|
119
|
+
if (!Number.isInteger(range.start) || !Number.isInteger(range.end)) continue;
|
|
120
|
+
if (range.start < 0 || range.end > text.length || range.end <= range.start) continue;
|
|
121
|
+
candidates.push({ range, priority });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
candidates.sort(
|
|
125
|
+
(a, b) =>
|
|
126
|
+
a.range.start - b.range.start ||
|
|
127
|
+
b.range.end - a.range.end ||
|
|
128
|
+
a.priority - b.priority,
|
|
129
|
+
);
|
|
130
|
+
const merged: ComposerTokenRange[] = [];
|
|
131
|
+
let pos = 0;
|
|
132
|
+
for (const { range } of candidates) {
|
|
133
|
+
if (range.start < pos) continue;
|
|
134
|
+
merged.push(range);
|
|
135
|
+
pos = range.end;
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
package/utils/mentions.ts
CHANGED
|
@@ -45,6 +45,20 @@ export interface MentionSource {
|
|
|
45
45
|
readonly emptyNotice?: string | null;
|
|
46
46
|
/** Optional heading drawn above the list ("People in this workspace"). Absent → no heading row. */
|
|
47
47
|
readonly heading?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Optional class appended to each `@Label` chip the composer paints in its
|
|
50
|
+
* text (0.44.0), for a host that wants its own chip look.
|
|
51
|
+
*
|
|
52
|
+
* THE METRIC RULE IS THE HOST'S TO KEEP: the chip is painted by an overlay
|
|
53
|
+
* mirrored behind a transparent-text textarea, so it may change COLOR,
|
|
54
|
+
* BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
|
|
55
|
+
* that moves a glyph — padding, margin, border width, font-weight,
|
|
56
|
+
* letter-spacing, font-size — drifts the painted text off the textarea's
|
|
57
|
+
* own layout and takes the caret with it. Fake a pill's breathing room
|
|
58
|
+
* with `box-shadow: 0 0 0 Npx <background>`, which paints without
|
|
59
|
+
* occupying space.
|
|
60
|
+
*/
|
|
61
|
+
readonly tokenClassName?: string;
|
|
48
62
|
/** Fires on every text change with the ids whose token still survives in the body. */
|
|
49
63
|
readonly onMentionsChange?: (ids: readonly string[]) => void;
|
|
50
64
|
/**
|