@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 +172 -0
- package/README.md +41 -0
- package/components/AnnotationPanel.tsx +141 -40
- package/components/CommentPopover.tsx +12 -35
- package/components/MentionAutocomplete.tsx +55 -0
- package/package.json +1 -1
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
|
-
|
|
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 && (
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type ComposerTokenRange,
|
|
17
17
|
} from '../utils/composerTokens';
|
|
18
18
|
import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
|
|
19
|
-
import {
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
+
}
|