@plannotator/ui 0.44.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDOFF.md CHANGED
@@ -450,6 +450,8 @@ The package now owns the reusable two-stage `/embed` authoring flow. The host st
450
450
 
451
451
  4. **Grammar belongs to the host; splicing belongs to the package.** `buildInsertLine(target)` returns the exact line the host wants stored. `planEmbedInsert()` then normalizes that line into its own blank-line-delimited paragraph and places the caret on the following line. Host-specific path resolution, label escaping, and embed-fragment grammar stay outside the package.
452
452
 
453
+ **Host labels (0.45.1).** `EmbedPickerConfig.labels?: { upload?: string; empty?: string; noMatch?: (query: string) => string }` replaces the three rows that name the target type ("Upload HTML...", "No HTML files in this workspace", "No HTML files match “<query>”") for a host that feeds the picker more than HTML files. Each absent key keeps the built-in text, so a host passing nothing (Plannotator) renders exactly the menu it always did. The "Uploading..." row and the notice row are unchanged.
454
+
453
455
  5. **Upload is optional and single-flight.** When `uploadTarget` is absent, no upload row is rendered. When present, every picker state includes `Upload HTML...`. While its promise is pending, the typed `/embed` text stays visible and a reopened picker shows an inert `Uploading...` row. Resolving with a target inserts it through `buildInsertLine` and the same splice as an existing target; resolving `null` or rejecting leaves the typed command untouched. The package maps the anchor through CodeMirror transactions and silently drops the insert if the command was edited away. The host owns all failure UI.
454
456
 
455
457
  6. **One CodeMirror dependency graph.** The picker imports `@codemirror/autocomplete`, `@codemirror/state`, and `@codemirror/view` from `@plannotator/ui`'s declared dependencies. `@plannotator/atomic-editor` declares these as peers, so a consumer must resolve one shared copy. A second live copy of `@codemirror/state` breaks extensions just as it does for `wikiLinks`.
@@ -1321,8 +1323,180 @@ Tests: `utils/composerTokens.test.ts` (16, DOM-free) and
1321
1323
  `components/CommentPopover.mentionChips.test.tsx` (11, DOM-gated, in the
1322
1324
  workflow's DOM_TESTS step).
1323
1325
 
1326
+ ## Annotation card header slot and mentions on the edit box (0.45.0)
1327
+
1328
+ 0.43.1 closed with an open question: "Editing an existing comment happens in
1329
+ `AnnotationPanel`'s card, a plain textarea that has never had an `@` picker…
1330
+ If you want the panel editor to pick people too, that is a separate prop on
1331
+ `AnnotationPanel` and worth asking for." It was asked for, so 0.45.0 adds it —
1332
+ together with the header twin of the panel's existing `renderCardFooter`.
1333
+ Same ruling as the four releases before it: opt-in host capabilities that
1334
+ change nothing for Plannotator's own users when they are not supplied.
1335
+ `packages/editor` and `packages/review-editor` have ZERO source diff in this
1336
+ release; core is UNCHANGED at `0.25.5`, so **ui 0.45.0 publishes alone**.
1337
+
1338
+ ### 1. `renderCardHeader` — the twin of `renderCardFooter`
1339
+
1340
+ ```ts
1341
+ renderCardHeader?: (annotation: Annotation) => React.ReactNode;
1342
+ ```
1343
+
1344
+ Rendered inside each plan-annotation card's HEADER row — the row carrying the
1345
+ type word, the `diff` / page / `Unanchored` chips and `author · time` — after
1346
+ the timestamp and before the built-in edit/delete cluster, which keeps the
1347
+ right edge on its `ml-auto`. That is the slot for a status stamp (resolved,
1348
+ needs reply, a reviewer badge); the footer remains the slot for reply and
1349
+ resolve UI.
1350
+
1351
+ It follows the footer's contract line for line:
1352
+
1353
+ - The wrapper is `[data-annotation-card-header="true"]` (the footer's
1354
+ `[data-annotation-card-footer="true"]` spelled for the header — the attribute
1355
+ the host queries and styles), and it stops `click` and `keydown`
1356
+ propagation, so interacting with your stamp never selects the card.
1357
+ - **It renders under `readOnly`**, exactly as the footer does and for the same
1358
+ reason: the contents are host-owned and a stamp is a read affordance. The
1359
+ built-in mutation affordances stay hidden.
1360
+ - In the All-files grouped view it rides the OPEN document's cards only
1361
+ (`group.isCurrent`), the footer's rule — a slot built from the open
1362
+ document's state has nothing to say about another document's card.
1363
+ - Returning `null` / `undefined` / `false` for a card renders no wrapper for
1364
+ that card. Omitting the prop renders no wrapper anywhere: there is no empty
1365
+ container to lay out or style around.
1366
+ - The header row is ONE non-wrapping flex line shared with the type word, the
1367
+ `diff` / page / `Unanchored` chips and the timestamp. The wrapper is
1368
+ `min-w-0` and shrinks, but a host node that cannot shrink will overflow
1369
+ toward the built-in actions rather than wrap — keep the stamp compact, or
1370
+ give it its own truncation.
1371
+
1372
+ **Not on `CodeAnnotation` cards.** `CodeAnnotationCard` (the review-editor
1373
+ shape) takes no `renderCardFooter` either, so neither new prop was threaded
1374
+ into it; mirroring the footer is the rule. Ask if a host needs both there.
1375
+
1376
+ ### 2. `mentionSource` — the `@` picker on the card's edit box
1377
+
1378
+ ```ts
1379
+ mentionSource?: MentionSource; // the same type, unchanged, from 0.43.0
1380
+ ```
1381
+
1382
+ Supplied on the panel and threaded to every plan-annotation card, it gives the
1383
+ card's EDIT textarea the same `@` machinery `CommentPopover` has:
1384
+ `useMentionAutocomplete` over the host's `people`, the portaled `MentionPicker`,
1385
+ the same grammar (`@` at a word boundary, never inside `a@b.com`), the same
1386
+ no-preselection keyboard rule (Enter is a newline until an arrow engages a
1387
+ row), the same `onMentionsChange`, and the same `onPickBlocked` no-access
1388
+ behavior (a blocked pick inserts NOTHING and the host shows its own dialog).
1389
+
1390
+ **Key order at the textarea.** The menu is offered the key FIRST, then the
1391
+ card's own handlers run only if it did not consume the event:
1392
+
1393
+ - `Escape` while the menu is open closes the MENU (the hook consumes it and
1394
+ stops propagation). A second `Escape` cancels the edit, as it always did.
1395
+ - `Enter` with a row arrowed to inserts that person. `Enter` with nothing
1396
+ active is untouched — still a newline.
1397
+ - `Mod+Enter` is never consumed by the menu (the hook declines any event
1398
+ carrying a modifier), so save still saves.
1399
+
1400
+ ARIA follows `CommentPopover`: `aria-autocomplete="list"` and
1401
+ `aria-haspopup="listbox"` exist only when a source is supplied,
1402
+ `aria-controls` / `aria-owns` only while the menu is open, and
1403
+ `aria-activedescendant` only while a row is active. With no source all five
1404
+ resolve to `undefined`, so the rendered attribute list is the one the edit box
1405
+ has always had.
1406
+
1407
+ ### 3. The save rule
1408
+
1409
+ `handleSaveEdit` called `onEdit({ text })`. It now calls:
1410
+
1411
+ ```ts
1412
+ if (mentionSource && mentions.length > 0) onEdit({ text: editText, mentions });
1413
+ else onEdit({ text: editText });
1414
+ ```
1415
+
1416
+ which is the presence rule the creation composers already keep, one level
1417
+ down: **the key exists only when a source was supplied AND at least one id
1418
+ survived to save.** Never `mentions: []`, never the key with an `undefined`
1419
+ value — an untouched or pick-less edit calls `onEdit({ text })` byte for byte
1420
+ as it did in 0.44.0, so it can never wipe tags the annotation already carries.
1421
+ `source.onMentionsChange` fires from the hook exactly as it does in the
1422
+ composer, which on this surface means it reports `[]` once when the editor
1423
+ OPENS, before any pick: it is the live state of this edit session, not the
1424
+ annotation's stored `mentions`. Only `onEdit` is authoritative — a host that
1425
+ mirrors `onMentionsChange` into its own record must not treat that opening
1426
+ `[]` as a clear. The Save BUTTON and `Mod+Enter` go through the same call.
1427
+
1428
+ **"This edit session" is literal.** The edit box was extracted into
1429
+ `AnnotationEditComposer`, mounted only while a card is in edit mode, so the
1430
+ hook's tagged-people state lives and dies with one session: reopen the editor
1431
+ and nobody is picked, and a save with no new pick is `{ text }` again — even
1432
+ if the previous session's `@Label` token is still sitting in the body. That is
1433
+ the conservative direction (the host owns `mentions` from then on, 0.43.1 §4),
1434
+ and it is the same reason a restored draft starts with nobody tagged.
1435
+
1436
+ ### 4. No chips in the edit box (known difference, and the follow-up)
1437
+
1438
+ A picked token renders as a CHIP in `CommentPopover` (0.44.0) and as plain
1439
+ text here. The chip layer is `ComposerTextarea`'s mirrored, aria-hidden
1440
+ overlay behind a transparent-text textarea, with scroll mirroring and IME
1441
+ handling; the card's edit box is a plain `<textarea>` with its own sizing and
1442
+ classes. Duplicating that overlay for one more textarea is exactly the "two
1443
+ mirrored layers" mistake 0.44.0 avoided.
1444
+
1445
+ **Named follow-up: move the card's edit box onto `ComposerTextarea`.** That is
1446
+ the one change that gets chips here without a second overlay, and it is a
1447
+ visible change to a surface Plannotator itself renders — a separate PR with
1448
+ its own no-op argument, not a rider on a seam release.
1449
+
1450
+ Everything else about mentions is inherited unchanged and documented in the
1451
+ 0.43.x / 0.44.0 sections above, including the three limits: two labels that
1452
+ sanitize to the same token, a restored draft reporting no ids, and a label
1453
+ that is a prefix of another label.
1454
+
1455
+ ### 5. One internal module, not a new seam
1456
+
1457
+ `components/MentionAutocomplete.tsx` (`MentionAutocompleteMenu` +
1458
+ `mentionActiveOptionId`) is the glue between a `useMentionAutocomplete` result
1459
+ and `MentionPicker` — the id→index lookup, the no-op hover and the
1460
+ `aria-activedescendant` string. It exists so the third mount did not become a
1461
+ third verbatim copy of the same fifteen lines; `CommentPopover`'s two mounts
1462
+ were moved onto it in the same change, with no DOM difference (proven below).
1463
+ It is **internal**: it is not on the supported-import list and hosts never
1464
+ touch it — they pass `mentionSource`.
1465
+
1466
+ ### The no-op guarantee, and how it is pinned
1467
+
1468
+ The same components were mounted on `origin/main` and on this branch in one
1469
+ harness and their `outerHTML` diffed, with `addEventListener` and `setTimeout`
1470
+ counts taken across each mount. With NEITHER new prop supplied, all six are
1471
+ byte-identical with identical counts:
1472
+
1473
+ | scenario | bytes | listeners | timers |
1474
+ | --- | --- | --- | --- |
1475
+ | empty panel | 733 | 140 | 0 |
1476
+ | nine cards (comment, deletion, global, quick label, external `source`, unanchored, `inReplyTo` reply, a card with `renderCardFooter`, a `diffContext` card) | 17543 | 141 | 0 |
1477
+ | the same panel `readOnly` | 7854 | 141 | 0 |
1478
+ | a card in EDIT mode | 18586 | 142 | 0 |
1479
+ | the All-files grouped view (two document groups) | 19629 | 141 | 0 |
1480
+ | `CommentPopover` (the module the glue refactor touched) | 3130 | 286 | 1 |
1481
+
1482
+ The edit-mode row is the one that matters for the hook: mounted with
1483
+ `source: undefined`, `useMentionAutocomplete` registers no listener, opens no
1484
+ menu state, returns the frozen empty id array, and the picker renders nothing —
1485
+ one extra listener would have shown up as 143.
1486
+
1487
+ On top of the diff, committed tests pin the structure rather than a snapshot:
1488
+ with neither prop there is no `[data-annotation-card-header]` on any card, the
1489
+ edit textarea carries none of the five mention ARIA attributes, typing `@`
1490
+ opens nothing, and `onEdit` is called with an updates object whose key list is
1491
+ exactly `['text']`.
1492
+
1493
+ Tests (both DOM-gated, both added to the workflow's DOM_TESTS step):
1494
+ `components/AnnotationPanel.cardHeader.test.tsx` (6) and
1495
+ `components/AnnotationPanel.editMentions.test.tsx` (10).
1496
+
1324
1497
  ## Publishing & versioning
1325
1498
 
1499
+ - **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)".
1326
1500
  - **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)".
1327
1501
  - **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)".
1328
1502
  - **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)".
package/README.md CHANGED
@@ -74,7 +74,7 @@ Plannotator's own entries import the eager math and identity modules (`math-eage
74
74
  const editorExtensions = [wikiLinks({ suggest, resolve, onOpen })]; // stable reference!
75
75
  <MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} />
76
76
  ```
77
- - **`embedPicker(config)` and `embedSlashItem()` are re-exported from the same surface.** Compose the static item into `slashCommands({ items: [...] })` and pass the picker beside it in the stable `extensions` array. `getTargets`, `buildInsertLine`, optional `uploadTarget`, and optional `getNotice` stay live through callbacks. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exported `planEmbedInsert()`.
77
+ - **`embedPicker(config)` and `embedSlashItem()` are re-exported from the same surface.** Compose the static item into `slashCommands({ items: [...] })` and pass the picker beside it in the stable `extensions` array. `getTargets`, `buildInsertLine`, optional `uploadTarget`, and optional `getNotice` stay live through callbacks. Optional `labels` (`upload`, `empty`, `noMatch(query)`) reword the three rows that say "HTML"; absent keys keep the built-in text. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exported `planEmbedInsert()`.
78
78
  - **The viewer resolves wiki-links synchronously.** `InlineMarkdown` takes `resolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null` — called with the raw stored target (opaque ids like `doc_01XYZ`, no `.md` normalization). Return a `label` to display live titles (stored label is the fallback, target the last resort); return `status: 'deleted'` for a muted non-link ("Document deleted") instead of a live link. Absent or `null` → rendering is unchanged. Sync-only by design: back it with an in-memory cache.
79
79
 
80
80
  Requires `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0`. See HANDOFF.md § "Wiki-link seams (0.27.0)".
@@ -291,6 +291,47 @@ the composer (0.44.0)" covers the chips, the metric rule, the merged-range
291
291
  refactor behind them, and the three inherited limits (duplicate labels,
292
292
  restored drafts, and a label that is a prefix of another label).
293
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)".
334
+
294
335
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
295
336
 
296
337
  The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
@@ -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
- onEdit({ text: editText });
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
- // Shared edit textarea — matches the prototype composer primitive
925
+ // The edit box, mounted only while editing (see AnnotationEditComposer).
828
926
  const editComposer = (
829
- <div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
830
- <textarea
831
- data-pn-mobile-editable="true"
832
- ref={textareaRef}
833
- value={editText}
834
- onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditText(e.target.value)}
835
- onKeyDown={handleKeyDown}
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 && (
@@ -16,7 +16,7 @@ import {
16
16
  type ComposerTokenRange,
17
17
  } from '../utils/composerTokens';
18
18
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
19
- import { MentionPicker } from './MentionPicker';
19
+ import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
20
20
  import type { MentionPerson, MentionSource } from '../utils/mentions';
21
21
  import {
22
22
  hasPrimaryCoarsePointer,
@@ -551,10 +551,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
551
551
  // the textarea's ARIA relationship points at whichever one is. All three are
552
552
  // `undefined`/false with no menu open, which is every render Plannotator
553
553
  // makes without a `mentionSource`.
554
- const activeMentionOptionId =
555
- mentionAc.menu === null || mentionAc.menu.activeIndex === null
556
- ? undefined
557
- : `${mentionListboxId}-option-${mentionAc.menu.activeIndex}`;
554
+ const activeMentionOptionId = mentionActiveOptionId(mentionListboxId, mentionAc.menu);
558
555
  const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId;
559
556
  const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
560
557
  const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
@@ -709,21 +706,11 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
709
706
  activeOptionId={activeComposerOptionId}
710
707
  />
711
708
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
712
- {mentionAc.menu && (
713
- <MentionPicker
714
- id={mentionListboxId}
715
- people={mentionAc.menu.items}
716
- emptyNotice={mentionAc.menu.emptyNotice}
717
- heading={mentionAc.menu.heading}
718
- active={mentionAc.menu.activeIndex}
719
- anchor={mentionAc.menu.anchor}
720
- onPick={(person) => {
721
- const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
722
- if (index >= 0) mentionAc.select(index);
723
- }}
724
- onHover={() => {}}
725
- />
726
- )}
709
+ <MentionAutocompleteMenu
710
+ id={mentionListboxId}
711
+ menu={mentionAc.menu}
712
+ onSelect={mentionAc.select}
713
+ />
727
714
  </div>
728
715
 
729
716
  {/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
@@ -875,21 +862,11 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
875
862
  activeOptionId={activeComposerOptionId}
876
863
  />
877
864
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
878
- {mentionAc.menu && (
879
- <MentionPicker
880
- id={mentionListboxId}
881
- people={mentionAc.menu.items}
882
- emptyNotice={mentionAc.menu.emptyNotice}
883
- heading={mentionAc.menu.heading}
884
- active={mentionAc.menu.activeIndex}
885
- anchor={mentionAc.menu.anchor}
886
- onPick={(person) => {
887
- const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
888
- if (index >= 0) mentionAc.select(index);
889
- }}
890
- onHover={() => {}}
891
- />
892
- )}
865
+ <MentionAutocompleteMenu
866
+ id={mentionListboxId}
867
+ menu={mentionAc.menu}
868
+ onSelect={mentionAc.select}
869
+ />
893
870
  </div>
894
871
 
895
872
  {/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
@@ -42,6 +42,19 @@ export interface EmbedPickerConfig {
42
42
  readonly uploadTarget?: (kind: EmbedKind) => Promise<EmbedTarget | null>;
43
43
  /** Return an optional informational row for the current document text. */
44
44
  readonly getNotice?: (docBody: string) => string | null;
45
+ /**
46
+ * Host copy for the three rows that name the target type (0.45.1). A host
47
+ * that feeds the picker more than HTML files passes its own wording; each
48
+ * absent key keeps the built-in text, so Plannotator's menu is unchanged.
49
+ */
50
+ readonly labels?: {
51
+ /** The upload row. Default: "Upload HTML..." */
52
+ readonly upload?: string;
53
+ /** The row shown when there are no targets at all. Default: "No HTML files in this workspace" */
54
+ readonly empty?: string;
55
+ /** The row shown when the query matches nothing. Default: "No HTML files match “<query>”" */
56
+ readonly noMatch?: (query: string) => string;
57
+ };
45
58
  }
46
59
 
47
60
  type IconCompletion = Completion & {
@@ -175,8 +188,8 @@ function createEmbedPickerSource(
175
188
  options.push({
176
189
  label:
177
190
  targets.length === 0
178
- ? 'No HTML files in this workspace'
179
- : `No HTML files match “${query.trim()}”`,
191
+ ? config.labels?.empty ?? 'No HTML files in this workspace'
192
+ : config.labels?.noMatch?.(query.trim()) ?? `No HTML files match “${query.trim()}”`,
180
193
  apply: (view, completion, _from, to) => {
181
194
  view.dispatch({
182
195
  changes: { from: line.from, to, insert: '' },
@@ -209,7 +222,7 @@ function createEmbedPickerSource(
209
222
  },
210
223
  }
211
224
  : {
212
- label: 'Upload HTML...',
225
+ label: config.labels?.upload ?? 'Upload HTML...',
213
226
  slashCommandIcon: UPLOAD_ICON,
214
227
  apply: (view, completion, from, to) => {
215
228
  startUpload(
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",