feat(transcription): re-edit @mention via a pencil affordance (#628)

Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the
@displayName token (textContent — never innerHTML) plus a contenteditable=false
pencil button in a fixed-width slot, revealed on whole-token hover and keyboard
focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/
Space) opens the single mention dropdown via the controller, anchored at the
token and pre-filled with the stored displayName.

commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id
solely from the selected Person — the stored displayName is preserved by
construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1
invariant). renderHTML/renderText stay for serialization + clipboard.

AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-02 19:33:53 +02:00
parent cf1d34657e
commit 02c95b9cfc
3 changed files with 377 additions and 0 deletions

View File

@@ -771,3 +771,190 @@ describe('PersonMentionEditor — touch target', () => {
expect(option.className).toContain('min-h-[44px]');
});
});
// ─── #628: re-edit an existing @mention via the pencil affordance ─────────────
describe('PersonMentionEditor — #628 re-edit pencil', () => {
// A saved mention seeded into the editor: text "@Aug " + sidecar. The typed
// displayName is "Aug" (short form, #380 AC-1), distinct from the DB name.
const SAVED = { personId: 'p-aug', displayName: 'Aug' };
function editPencil() {
return page.getByRole('button', { name: m.person_mention_edit_label() });
}
async function renderSavedMention() {
const host = renderHost({ value: '@Aug ', mentionedPersons: [SAVED] });
await expect.element(editPencil()).toBeInTheDocument();
return host;
}
async function pencilElement(): Promise<HTMLElement> {
return (await editPencil().element()) as HTMLElement;
}
function clickPencil(btn: HTMLElement) {
// Native dispatch — CDP userEvent.click is unreliable for handlers attached
// imperatively inside a ProseMirror NodeView (same rationale as the option
// mousedown dispatch elsewhere in this file).
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
async function pickOption(name: RegExp) {
const el = (await page.getByRole('option', { name }).element()) as HTMLElement;
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
}
// AC-1 ----------------------------------------------------------------------
it('renders an edit pencil on a saved mention, labelled via aria-label', async () => {
await renderSavedMention();
await expect.element(editPencil()).toBeInTheDocument();
});
it('the pencil is keyboard-focusable (tabindex 0, focus lands on it)', async () => {
await renderSavedMention();
const btn = await pencilElement();
expect(btn.getAttribute('tabindex')).toBe('0');
btn.focus();
expect(document.activeElement).toBe(btn);
});
it('hides the pencil by default and reveals it on hover + focus-within (opacity swap)', async () => {
await renderSavedMention();
const btn = await pencilElement();
// Reveal is a class-driven instant opacity swap (no Tailwind CSS in the
// component test env — assert the mechanism structurally, as the existing
// touch-target test does for min-h-[44px]).
expect(btn.className).toContain('opacity-0');
expect(btn.className).toContain('group-hover/mention:opacity-100');
expect(btn.className).toContain('group-focus-within/mention:opacity-100');
});
it('renders the pencil in an always-present fixed-width slot (no reflow)', async () => {
await renderSavedMention();
const slot = document.querySelector('.mention-edit-slot') as HTMLElement | null;
expect(slot).not.toBeNull();
expect(slot!.className).toContain('w-4');
});
// AC-2 ----------------------------------------------------------------------
it('opens the dropdown pre-filled with the stored displayName when the pencil is clicked', async () => {
mockFetchEmpty();
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
});
it('opens the dropdown when the pencil is activated by keyboard (Enter)', async () => {
mockFetchEmpty();
await renderSavedMention();
const btn = await pencilElement();
btn.focus();
btn.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
});
// AC-3 ----------------------------------------------------------------------
it('relinks in place: picking a different person swaps data-person-id', async () => {
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
const token = document.querySelector('[data-type="mention"]') as HTMLElement;
expect(token.getAttribute('data-person-id')).toBe('p-anna');
});
});
it('relink preserves the displayed token text exactly (only personId changes)', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
});
});
// Serializer regression — text byte-identical, only personId swapped --------
it('serializer regression: re-link keeps the text byte-identical, swaps only personId', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
});
});
// AC-5 — edited-then-picked (highest value): personId updates, text invariant
it('AC-5: editing the search input then picking sets the new personId', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await page.getByRole('searchbox').fill('Anna');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
});
});
it('AC-5: editing the search input then picking keeps the ORIGINAL displayName', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await page.getByRole('searchbox').fill('Anna');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
// The #380 AC-1 invariant wins over the edited search input.
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.value).not.toContain('@Anna');
});
});
});