@plannotator/ui 0.44.0 → 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 CHANGED
@@ -1321,8 +1321,180 @@ Tests: `utils/composerTokens.test.ts` (16, DOM-free) and
1321
1321
  `components/CommentPopover.mentionChips.test.tsx` (11, DOM-gated, in the
1322
1322
  workflow's DOM_TESTS step).
1323
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
+
1324
1495
  ## Publishing & versioning
1325
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)".
1326
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)".
1327
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)".
1328
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)".
package/README.md CHANGED
@@ -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 */}
@@ -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.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",