feat(transcription): re-edit existing @mention by pre-filling the search input (#628) #717
@@ -506,6 +506,9 @@
|
|||||||
"person_mention_create_new": "Neue Person anlegen",
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
"person_mention_results_count_singular": "1 Person gefunden",
|
"person_mention_results_count_singular": "1 Person gefunden",
|
||||||
"person_mention_results_count_plural": "{count} Personen gefunden",
|
"person_mention_results_count_plural": "{count} Personen gefunden",
|
||||||
|
"person_mention_edit_label": "Erwähnung bearbeiten",
|
||||||
|
"person_mention_editing_announce": "Erwähnung wird bearbeitet: {displayName}",
|
||||||
|
"person_mention_dismiss_label": "Suche schließen",
|
||||||
"transcription_editor_aria_label": "Transkriptionstext",
|
"transcription_editor_aria_label": "Transkriptionstext",
|
||||||
"person_born_name_prefix": "geb.",
|
"person_born_name_prefix": "geb.",
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
|
|||||||
@@ -506,6 +506,9 @@
|
|||||||
"person_mention_create_new": "Create new person",
|
"person_mention_create_new": "Create new person",
|
||||||
"person_mention_results_count_singular": "1 person found",
|
"person_mention_results_count_singular": "1 person found",
|
||||||
"person_mention_results_count_plural": "{count} persons found",
|
"person_mention_results_count_plural": "{count} persons found",
|
||||||
|
"person_mention_edit_label": "Edit mention",
|
||||||
|
"person_mention_editing_announce": "Editing mention: {displayName}",
|
||||||
|
"person_mention_dismiss_label": "Close search",
|
||||||
"transcription_editor_aria_label": "Transcription text",
|
"transcription_editor_aria_label": "Transcription text",
|
||||||
"person_born_name_prefix": "née",
|
"person_born_name_prefix": "née",
|
||||||
"page_title_home": "Archive",
|
"page_title_home": "Archive",
|
||||||
|
|||||||
@@ -506,6 +506,9 @@
|
|||||||
"person_mention_create_new": "Crear nueva persona",
|
"person_mention_create_new": "Crear nueva persona",
|
||||||
"person_mention_results_count_singular": "1 persona encontrada",
|
"person_mention_results_count_singular": "1 persona encontrada",
|
||||||
"person_mention_results_count_plural": "{count} personas encontradas",
|
"person_mention_results_count_plural": "{count} personas encontradas",
|
||||||
|
"person_mention_edit_label": "Editar mención",
|
||||||
|
"person_mention_editing_announce": "Editando mención: {displayName}",
|
||||||
|
"person_mention_dismiss_label": "Cerrar búsqueda",
|
||||||
"transcription_editor_aria_label": "Texto de transcripción",
|
"transcription_editor_aria_label": "Texto de transcripción",
|
||||||
"person_born_name_prefix": "n.",
|
"person_born_name_prefix": "n.",
|
||||||
"page_title_home": "Archivo",
|
"page_title_home": "Archivo",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
||||||
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
||||||
import { untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
// Layered defence cap on the @mention search query length (CWE-400
|
// Layered defence cap on the @mention search query length (CWE-400
|
||||||
// amplification). The <input maxlength> attribute below caps direct
|
// amplification). The <input maxlength> attribute below caps direct
|
||||||
@@ -31,17 +31,47 @@ type DropdownState = {
|
|||||||
let {
|
let {
|
||||||
model,
|
model,
|
||||||
editorQuery = '',
|
editorQuery = '',
|
||||||
onSearch = () => {}
|
onSearch = () => {},
|
||||||
|
ondismiss = () => {},
|
||||||
|
focusOnMount = false,
|
||||||
|
editingDisplayName = ''
|
||||||
}: {
|
}: {
|
||||||
model: DropdownState;
|
model: DropdownState;
|
||||||
/** Text typed after `@` in the host editor. Mirrors into the search input
|
/** Text typed after `@` in the host editor. Mirrors into the search input
|
||||||
* until the user takes manual ownership by typing into the input itself. */
|
* until the user takes manual ownership by typing into the input itself. */
|
||||||
editorQuery?: string;
|
editorQuery?: string;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
|
/** Closes the dropdown without touching the document — invoked by the visible
|
||||||
|
* × dismiss control and by Escape on the re-edit path (#628 AC-4). */
|
||||||
|
ondismiss?: () => void;
|
||||||
|
/** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps
|
||||||
|
* focus in the editor so typing flows to the contenteditable. */
|
||||||
|
focusOnMount?: boolean;
|
||||||
|
/** When set (re-edit, #628), the persistent live region announces the editing
|
||||||
|
* context for this mention. Routed through the SAME aria-live region as the
|
||||||
|
* result count — never a second live region (avoids the double-announce bug). */
|
||||||
|
editingDisplayName?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
||||||
let userHasEdited = $state(false);
|
let userHasEdited = $state(false);
|
||||||
|
let searchInput: HTMLInputElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (focusOnMount) searchInput?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-edit has no Tiptap suggestion plugin to forward keys, so the search input
|
||||||
|
// handles its own navigation: Escape dismisses (and is prevented from clearing
|
||||||
|
// the native search field), Arrow/Enter reuse the exported selection logic.
|
||||||
|
function handleSearchKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
ondismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onKeyDown(event)) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
// Intent-revealing alias used by both the persistent aria-live announcer and
|
// Intent-revealing alias used by both the persistent aria-live announcer and
|
||||||
// the visible empty-state copy. Folding the duplicated rule into one $derived
|
// the visible empty-state copy. Folding the duplicated rule into one $derived
|
||||||
@@ -184,6 +214,7 @@ function selectItem(item: Person) {
|
|||||||
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
id="mention-search"
|
id="mention-search"
|
||||||
type="search"
|
type="search"
|
||||||
data-test-search-input
|
data-test-search-input
|
||||||
@@ -194,8 +225,34 @@ function selectItem(item: Person) {
|
|||||||
oninput={() => {
|
oninput={() => {
|
||||||
userHasEdited = true;
|
userHasEdited = true;
|
||||||
}}
|
}}
|
||||||
|
onkeydown={handleSearchKeydown}
|
||||||
onmousedown={(e) => e.stopPropagation()}
|
onmousedown={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
<!--
|
||||||
|
Visible dismiss control (#628 AC-4) — shared by the fresh-@ and the
|
||||||
|
re-edit paths. onmousedown preventDefault keeps the editor selection
|
||||||
|
from blurring before onclick fires; onclick handles both pointer and
|
||||||
|
keyboard (Enter/Space) activation.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-test-dismiss
|
||||||
|
aria-label={m.person_mention_dismiss_label()}
|
||||||
|
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm text-ink-2 hover:bg-canvas focus:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => ondismiss()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
<!--
|
||||||
@@ -208,7 +265,11 @@ function selectItem(item: Person) {
|
|||||||
-->
|
-->
|
||||||
<p class="sr-only" aria-live="polite">
|
<p class="sr-only" aria-live="polite">
|
||||||
{#if model.items.length === 0}
|
{#if model.items.length === 0}
|
||||||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
{#if editingDisplayName && !userHasEdited}
|
||||||
|
{m.person_mention_editing_announce({ displayName: editingDisplayName })}
|
||||||
|
{:else}
|
||||||
|
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||||||
|
{/if}
|
||||||
{:else if model.items.length === 1}
|
{:else if model.items.length === 1}
|
||||||
{m.person_mention_results_count_singular()}
|
{m.person_mention_results_count_singular()}
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { PersonMention } from '$lib/shared/types';
|
|||||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||||
import { debounce } from '$lib/shared/utils/debounce';
|
import { debounce } from '$lib/shared/utils/debounce';
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
|
import { createMentionNodeView } from './mentionNodeView';
|
||||||
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
|
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
@@ -63,6 +64,184 @@ type DropdownExports = {
|
|||||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommitFn = (item: Person) => void;
|
||||||
|
type RectGetter = (() => DOMRect | null) | null;
|
||||||
|
|
||||||
|
// Tiptap's SuggestionProps types `command` against the default MentionNodeAttrs
|
||||||
|
// (id/label). Our custom Mention extension uses personId/displayName, so the
|
||||||
|
// render() adapter casts the renderProps to this looser shape locally.
|
||||||
|
type LooseRenderProps = {
|
||||||
|
items: unknown;
|
||||||
|
command: (props: { personId: string; displayName: string }) => void;
|
||||||
|
query: string;
|
||||||
|
clientRect?: (() => DOMRect | null) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single owner of the dropdown lifecycle. Both entry points route through one
|
||||||
|
// controller — Tiptap's fresh-`@` suggestion (via the render() adapter below)
|
||||||
|
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever
|
||||||
|
// mounted (the AC-6 single-dropdown invariant). open() closes any prior
|
||||||
|
// dropdown first; render() is a thin adapter over open()/update()/close().
|
||||||
|
type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string };
|
||||||
|
|
||||||
|
type MentionController = {
|
||||||
|
open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
|
||||||
|
update: (clientRect: RectGetter, query: string, commit: CommitFn) => void;
|
||||||
|
close: () => void;
|
||||||
|
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMentionController(): MentionController {
|
||||||
|
// Request-token guard: every search AND every open bumps `requestId`;
|
||||||
|
// runSearch captures the id active when its fetch starts and discards the
|
||||||
|
// response if a newer search/open has happened since. Without this, a late
|
||||||
|
// response can repopulate a dropdown the user already moved on from (e.g.
|
||||||
|
// open A → open B → A's stale response). Sara on PR #629.
|
||||||
|
let requestId = 0;
|
||||||
|
let exports: DropdownExports | null = null;
|
||||||
|
|
||||||
|
const runSearch = async (query: string) => {
|
||||||
|
const id = requestId;
|
||||||
|
try {
|
||||||
|
// Defensive client-side cap — server-side enforcement is tracked
|
||||||
|
// separately. Markus on PR #629.
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
|
||||||
|
);
|
||||||
|
if (id !== requestId) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
dropdownState.items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { items?: Person[] };
|
||||||
|
if (id !== requestId) return;
|
||||||
|
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
|
||||||
|
} catch {
|
||||||
|
if (id !== requestId) return;
|
||||||
|
dropdownState.items = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
||||||
|
// Hoisted so onDestroy can cancel any pending fetch even after the editor is
|
||||||
|
// gone — a trailing debounced search would otherwise pollute later tests.
|
||||||
|
cancelPendingSearch = () => debouncedSearch.cancel();
|
||||||
|
|
||||||
|
const onSearch = (query: string) => {
|
||||||
|
requestId++;
|
||||||
|
if (query.trim() === '') {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
dropdownState.items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debouncedSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
if (mountedDropdown) {
|
||||||
|
unmount(mountedDropdown);
|
||||||
|
mountedDropdown = null;
|
||||||
|
exports = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clip the query once so the dropdown's editor-mirror sees the capped value
|
||||||
|
// (CWE-400 amplification — Nora #1 / Felix #3 on PR #629). The commit closure
|
||||||
|
// is supplied by the caller, so the fresh-insert path keeps clipping the
|
||||||
|
// inserted displayName while the relink path (#628) preserves the stored
|
||||||
|
// displayName by construction.
|
||||||
|
const writeState = (clientRect: RectGetter, query: string, commit: CommitFn) => {
|
||||||
|
dropdownState.command = commit;
|
||||||
|
dropdownState.clientRect = clientRect;
|
||||||
|
dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close the dropdown without touching the document and return focus to the
|
||||||
|
// editor — wired to the dropdown's × control and the re-edit Escape path.
|
||||||
|
const dismiss = () => {
|
||||||
|
close();
|
||||||
|
editor?.commands.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (
|
||||||
|
clientRect: RectGetter,
|
||||||
|
query: string,
|
||||||
|
commit: CommitFn,
|
||||||
|
opts: OpenOptions = {}
|
||||||
|
) => {
|
||||||
|
// Single-dropdown invariant: tear down any open dropdown before mounting a
|
||||||
|
// new one, and bump the request token so a previous open's in-flight fetch
|
||||||
|
// cannot repopulate this dropdown.
|
||||||
|
close();
|
||||||
|
requestId++;
|
||||||
|
dropdownState.items = [];
|
||||||
|
writeState(clientRect, query, commit);
|
||||||
|
const mounted = mount(MentionDropdown, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
model: dropdownState,
|
||||||
|
ondismiss: dismiss,
|
||||||
|
focusOnMount: opts.focusOnMount ?? false,
|
||||||
|
editingDisplayName: opts.editingDisplayName ?? '',
|
||||||
|
// MentionDropdown reads `editorQuery` off the shared state proxy via
|
||||||
|
// this getter — Svelte 5's mount() does not expose settable prop
|
||||||
|
// accessors, so we route through the proxy (same pattern as items).
|
||||||
|
get editorQuery() {
|
||||||
|
return dropdownState.editorQuery;
|
||||||
|
},
|
||||||
|
onSearch
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mountedDropdown = mounted as object;
|
||||||
|
exports = mounted as unknown as DropdownExports;
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (clientRect: RectGetter, query: string, commit: CommitFn) => {
|
||||||
|
writeState(clientRect, query, commit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => exports?.onKeyDown(event) ?? false;
|
||||||
|
|
||||||
|
return { open, update, close, onKeyDown };
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = createMentionController();
|
||||||
|
|
||||||
|
// Re-link an existing mention in place (#628 AC-3/AC-5). Captures the node's
|
||||||
|
// pos when the pencil opens and swaps ONLY personId via setNodeMarkup, so the
|
||||||
|
// stored displayName is preserved by construction — never round-tripped through
|
||||||
|
// insertContentAt (which would rebind displayName to the search query). The new
|
||||||
|
// personId comes solely from the selected Person (anti-spoof — never from the
|
||||||
|
// reflected data-person-id or the search input).
|
||||||
|
function commitRelink(pos: number): CommitFn {
|
||||||
|
return (item: Person) => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.command(({ tr, state }) => {
|
||||||
|
const node = state.doc.nodeAt(pos);
|
||||||
|
if (!node || node.type.name !== 'mention') return false;
|
||||||
|
tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId: item.id });
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
controller.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry point handed to the mention NodeView's pencil. Opens the one dropdown
|
||||||
|
// (closing any other first — AC-6) anchored at the mention and pre-filled with
|
||||||
|
// the stored displayName.
|
||||||
|
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
|
||||||
|
if (!editor || !editor.isEditable) return;
|
||||||
|
controller.open(getRect, displayName, commitRelink(pos), {
|
||||||
|
focusOnMount: true,
|
||||||
|
editingDisplayName: displayName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Custom Mention node: uses personId / displayName instead of the
|
// Custom Mention node: uses personId / displayName instead of the
|
||||||
// default id / label attribute names so the mentionSerializer can
|
// default id / label attribute names so the mentionSerializer can
|
||||||
@@ -88,6 +267,12 @@ onMount(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
// #628: host the mention as a NodeView so each token carries a pencil
|
||||||
|
// re-edit affordance. renderHTML/renderText below stay for serialization
|
||||||
|
// and clipboard; this only governs the live in-editor DOM.
|
||||||
|
addNodeView() {
|
||||||
|
return createMentionNodeView(requestRelink);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,120 +357,32 @@ onMount(() => {
|
|||||||
])
|
])
|
||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
|
// Thin adapter over the single mention controller. The fresh-`@`
|
||||||
|
// commit clips the typed query into the inserted displayName
|
||||||
|
// (AC-1, #380); the controller owns the dropdown lifecycle and the
|
||||||
|
// debounced/request-token search.
|
||||||
render() {
|
render() {
|
||||||
let exports: DropdownExports | null = null;
|
const buildFreshCommit = (loose: LooseRenderProps): CommitFn => {
|
||||||
|
const clippedQuery = loose.query.slice(0, MAX_QUERY_LENGTH);
|
||||||
// Tiptap's SuggestionProps types `command` against the default
|
return (item: Person) =>
|
||||||
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
loose.command({ personId: item.id, displayName: clippedQuery });
|
||||||
// personId/displayName, so we cast the renderProps locally.
|
|
||||||
type LooseRenderProps = {
|
|
||||||
items: unknown;
|
|
||||||
command: (props: { personId: string; displayName: string }) => void;
|
|
||||||
query: string;
|
|
||||||
clientRect?: (() => DOMRect | null) | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Request-token guard: every onSearch invocation bumps `requestId`;
|
|
||||||
// runSearch captures the id active when its fetch starts and discards
|
|
||||||
// the response if a newer onSearch has fired since. Without this, a
|
|
||||||
// late response can repopulate the dropdown after the user cleared
|
|
||||||
// the search input. Sara on PR #629.
|
|
||||||
let requestId = 0;
|
|
||||||
const runSearch = async (query: string) => {
|
|
||||||
const id = requestId;
|
|
||||||
try {
|
|
||||||
// Defensive client-side cap — server-side enforcement is tracked
|
|
||||||
// separately. Markus on PR #629.
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
|
|
||||||
);
|
|
||||||
if (id !== requestId) return;
|
|
||||||
if (!res.ok) {
|
|
||||||
dropdownState.items = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { items?: Person[] };
|
|
||||||
if (id !== requestId) return;
|
|
||||||
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
|
|
||||||
} catch {
|
|
||||||
if (id !== requestId) return;
|
|
||||||
dropdownState.items = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
|
||||||
cancelPendingSearch = () => debouncedSearch.cancel();
|
|
||||||
const onSearch = (query: string) => {
|
|
||||||
requestId++;
|
|
||||||
if (query.trim() === '') {
|
|
||||||
debouncedSearch.cancel();
|
|
||||||
dropdownState.items = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debouncedSearch(query);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateState = (renderProps: LooseRenderProps) => {
|
|
||||||
// Clip once here so both the inserted displayName and the
|
|
||||||
// dropdown's editor-mirror see the same value. The dropdown
|
|
||||||
// already clips the mirror (Nora #1 CWE-400), but without
|
|
||||||
// clipping at the command boundary an unclipped query would
|
|
||||||
// still flow through as the inserted displayName — visible
|
|
||||||
// UI divergence between "what I searched" and "what was
|
|
||||||
// inserted". Felix #3 on PR #629.
|
|
||||||
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
|
|
||||||
// AC-1: pass typed query as displayName, not person.displayName
|
|
||||||
dropdownState.command = (item: Person) =>
|
|
||||||
renderProps.command({
|
|
||||||
personId: item.id,
|
|
||||||
displayName: clippedQuery
|
|
||||||
});
|
|
||||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
|
||||||
dropdownState.editorQuery = clippedQuery;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart(renderProps) {
|
onStart(renderProps) {
|
||||||
const loose = renderProps as unknown as LooseRenderProps;
|
const loose = renderProps as unknown as LooseRenderProps;
|
||||||
updateState(loose);
|
controller.open(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
|
||||||
// MentionDropdown reads `editorQuery` off the shared state
|
|
||||||
// proxy via its `editorQuery` prop binding below — this is
|
|
||||||
// the same pattern as `model.items`. We do not pass it as a
|
|
||||||
// separate prop because Svelte 5's mount() does not expose
|
|
||||||
// settable prop accessors, so we route through the proxy.
|
|
||||||
const mounted = mount(MentionDropdown, {
|
|
||||||
target: document.body,
|
|
||||||
props: {
|
|
||||||
model: dropdownState,
|
|
||||||
get editorQuery() {
|
|
||||||
return dropdownState.editorQuery;
|
|
||||||
},
|
|
||||||
onSearch
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mountedDropdown = mounted as object;
|
|
||||||
exports = mounted as unknown as DropdownExports;
|
|
||||||
},
|
},
|
||||||
onUpdate(renderProps) {
|
onUpdate(renderProps) {
|
||||||
updateState(renderProps as unknown as LooseRenderProps);
|
const loose = renderProps as unknown as LooseRenderProps;
|
||||||
|
controller.update(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
|
||||||
},
|
},
|
||||||
onKeyDown({ event }) {
|
onKeyDown({ event }) {
|
||||||
// Escape is handled by the suggestion plugin itself.
|
// Escape is handled by the suggestion plugin itself.
|
||||||
if (event.key === 'Escape') return false;
|
if (event.key === 'Escape') return false;
|
||||||
return exports?.onKeyDown(event) ?? false;
|
return controller.onKeyDown(event);
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
// Cancel any pending debounce so a closed dropdown's trailing
|
controller.close();
|
||||||
// runSearch cannot fire against the *next* dropdown's state.
|
|
||||||
// The hoisted `cancelPendingSearch` would be overwritten by
|
|
||||||
// the next render()'s onStart before the trailing call fires,
|
|
||||||
// so we cancel locally via the closure-scoped debouncedSearch.
|
|
||||||
// Felix #1 on PR #629.
|
|
||||||
debouncedSearch.cancel();
|
|
||||||
if (mountedDropdown) {
|
|
||||||
unmount(mountedDropdown);
|
|
||||||
mountedDropdown = null;
|
|
||||||
exports = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -771,3 +771,616 @@ describe('PersonMentionEditor — touch target', () => {
|
|||||||
expect(option.className).toContain('min-h-[44px]');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-4 — dismiss leaves the node byte-identical on BOTH close paths ---------
|
||||||
|
|
||||||
|
function dismissButton() {
|
||||||
|
return page.getByRole('button', { name: m.person_mention_dismiss_label() });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('AC-4: Escape closes the re-edit dropdown leaving the node byte-identical', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = await renderSavedMention();
|
||||||
|
clickPencil(await pencilElement());
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||||||
|
search.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(host.snapshot.value).toBe('@Aug ');
|
||||||
|
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AC-4: the visible dismiss control closes the re-edit dropdown leaving the node byte-identical', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = await renderSavedMention();
|
||||||
|
clickPencil(await pencilElement());
|
||||||
|
await expect.element(dismissButton()).toBeVisible();
|
||||||
|
|
||||||
|
const dismissEl = (await dismissButton().element()) as HTMLElement;
|
||||||
|
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(host.snapshot.value).toBe('@Aug ');
|
||||||
|
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-9 parity — the re-edit dropdown is keyboard-operable on its own --------
|
||||||
|
|
||||||
|
it('re-edit open focuses the search input so keyboard users land in the field', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
await renderSavedMention();
|
||||||
|
clickPencil(await pencilElement());
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.activeElement).toBe(search);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-edit dropdown is keyboard-navigable: ArrowDown then Enter relinks', async () => {
|
||||||
|
mockFetchWithPersons(); // [AUGUSTE (p-aug, highlighted), ANNA (p-anna)]
|
||||||
|
const host = await renderSavedMention();
|
||||||
|
clickPencil(await pencilElement());
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||||||
|
search.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
search.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
|
||||||
|
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The × control is shared with the fresh-@ dropdown ------------------------
|
||||||
|
|
||||||
|
it('the fresh-@ dropdown also exposes the shared dismiss control', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost();
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await expect.element(dismissButton()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 AC-6: at most one mention dropdown open at a time ───────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => {
|
||||||
|
const TWO_MENTIONS = [
|
||||||
|
{ personId: 'p-aug', displayName: 'Aug' },
|
||||||
|
{ personId: 'p-bert', displayName: 'Bert' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function mentionButtons(): HTMLButtonElement[] {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll('[data-type="mention"] button')
|
||||||
|
) as HTMLButtonElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(el: HTMLElement) {
|
||||||
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listboxCount() {
|
||||||
|
return document.querySelectorAll('[role="listbox"]').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pencil → pencil: opening a second mention pencil closes the first', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
|
||||||
|
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
|
||||||
|
const [pencilA, pencilB] = mentionButtons();
|
||||||
|
|
||||||
|
click(pencilA);
|
||||||
|
await vi.waitFor(() => expect(listboxCount()).toBe(1));
|
||||||
|
|
||||||
|
click(pencilB);
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
expect(listboxCount()).toBe(1);
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('Bert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh-@ → pencil: activating a pencil closes the open fresh-@ dropdown', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||||
|
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
|
||||||
|
|
||||||
|
// Open a fresh-@ dropdown (prefilled with the typed query "x").
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@x');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now activate the saved mention's pencil — the fresh dropdown must give way.
|
||||||
|
click(mentionButtons()[0]);
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
expect(listboxCount()).toBe(1);
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pencil → fresh-@: typing @ in the editor closes the open re-edit dropdown', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||||
|
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
|
||||||
|
|
||||||
|
click(mentionButtons()[0]);
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing @ in the editor starts a fresh suggestion — the re-edit dropdown
|
||||||
|
// must give way to it (single owner, both orderings).
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@y');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
expect(listboxCount()).toBe(1);
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('y');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regression: fresh-@ still inserts the typed text as displayName (#380 AC-1 intact)', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
const option = (await page
|
||||||
|
.getByRole('option', { name: /Auguste Raddatz/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a stale fetch from a superseded open (open A → open B → A resolves)', async () => {
|
||||||
|
// A's fetch hangs; B's resolves empty. When A finally resolves with a
|
||||||
|
// person, the request-token guard must discard it so B's dropdown is not
|
||||||
|
// repopulated. Deterministic — no sleeps.
|
||||||
|
let resolveA!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
|
||||||
|
const pendingA = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>((r) => {
|
||||||
|
resolveA = r;
|
||||||
|
});
|
||||||
|
let call = 0;
|
||||||
|
const fetchMock = vi.fn().mockImplementation(() => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) return pendingA;
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
|
||||||
|
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
|
||||||
|
const [pencilA, pencilB] = mentionButtons();
|
||||||
|
|
||||||
|
click(pencilA);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Aug'));
|
||||||
|
});
|
||||||
|
|
||||||
|
click(pencilB);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Bert'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A's stale response arrives last — it must NOT repopulate B's dropdown.
|
||||||
|
resolveA({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
|
||||||
|
await tick();
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 AC-7: pencil is inert when the editor is disabled (WCAG 2.1.1) ──────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 AC-7 disabled editor', () => {
|
||||||
|
function mentionButton(): HTMLButtonElement | null {
|
||||||
|
return document.querySelector('[data-type="mention"] button');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the pencil disabled, aria-disabled and out of tab order when disabled', async () => {
|
||||||
|
renderHost({
|
||||||
|
value: '@Aug ',
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const btn = mentionButton();
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn!.disabled).toBe(true);
|
||||||
|
expect(btn!.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(btn!.tabIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activating the disabled pencil (keyboard or pointer) mounts no dropdown', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost({
|
||||||
|
value: '@Aug ',
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
|
||||||
|
const btn = mentionButton()!;
|
||||||
|
|
||||||
|
btn.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
// Give any (incorrectly) scheduled open a chance to mount before asserting.
|
||||||
|
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||||||
|
|
||||||
|
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 AC-8: no pencil / dropdown where there is no mention under the caret ─
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 AC-8 no mention under caret', () => {
|
||||||
|
it('plain text shows no edit pencil and no dropdown', async () => {
|
||||||
|
renderHost({ value: 'Nur Text ohne Erwaehnung', mentionedPersons: [] });
|
||||||
|
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[data-type="mention"]')).toBeNull();
|
||||||
|
expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(0);
|
||||||
|
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two adjacent mentions each keep their own pencil and auto-open nothing', async () => {
|
||||||
|
renderHost({
|
||||||
|
value: '@Aug@Bert ',
|
||||||
|
mentionedPersons: [
|
||||||
|
{ personId: 'p-aug', displayName: 'Aug' },
|
||||||
|
{ personId: 'p-bert', displayName: 'Bert' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelectorAll('[data-type="mention"]').length).toBe(2)
|
||||||
|
);
|
||||||
|
// One pencil per token (no spurious pencil for the gap between them) ...
|
||||||
|
expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(2);
|
||||||
|
// ... and nothing opens just from caret position.
|
||||||
|
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a mention at the document start still renders its pencil', async () => {
|
||||||
|
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 security: query clip + personId provenance ──────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 security', () => {
|
||||||
|
const OVERSIZED = 'A'.repeat(150);
|
||||||
|
|
||||||
|
function mentionButton(): HTMLButtonElement {
|
||||||
|
return document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('clips an oversized stored displayName to MAX_QUERY_LENGTH in the search input, node text untouched', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost({
|
||||||
|
value: `@${OVERSIZED} `,
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
|
||||||
|
mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||||
|
});
|
||||||
|
const search = (await page.getByRole('searchbox').element()) as HTMLInputElement;
|
||||||
|
expect(search.value.length).toBe(100);
|
||||||
|
// The preserved node text must NOT be truncated — only the search query is.
|
||||||
|
expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-link derives personId solely from the selected Person, never the DOM/search text', async () => {
|
||||||
|
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
|
||||||
|
const host = renderHost({
|
||||||
|
value: `@${OVERSIZED} `,
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
|
||||||
|
mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
const anna = (await page
|
||||||
|
.getByRole('option', { name: /Anna Schmidt/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
anna.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// id comes from the picked Person (p-anna), not the reflected p-aug
|
||||||
|
// nor the clipped search text; the long displayName stays untouched.
|
||||||
|
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
|
||||||
|
expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 NFR a11y: editing context announced via the existing live region ───
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 editing announce', () => {
|
||||||
|
it('re-edit open announces the editing context through the single persistent live region', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
|
||||||
|
);
|
||||||
|
(document.querySelector('[data-type="mention"] button') as HTMLElement).dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const liveRegions = document.querySelectorAll('[role="listbox"] [aria-live="polite"]');
|
||||||
|
// Exactly ONE live region (no second announcer — avoids double-announce).
|
||||||
|
expect(liveRegions.length).toBe(1);
|
||||||
|
expect(liveRegions[0].textContent ?? '').toContain(
|
||||||
|
m.person_mention_editing_announce({ displayName: 'Aug' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── #628 review fixes: hidden-pencil hit-testing + mid-session disable ───────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — #628 review fixes', () => {
|
||||||
|
it('keeps the hidden pencil out of hit-testing (pointer-events-none until revealed)', async () => {
|
||||||
|
// opacity-0 alone still hit-tests, and the 44px button overhangs adjacent
|
||||||
|
// text — so a click in the gap between mentions would otherwise land on the
|
||||||
|
// invisible pencil and open the dropdown (AC-8). It must be pointer-events-none
|
||||||
|
// while hidden and only become clickable together with the opacity reveal.
|
||||||
|
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
|
||||||
|
);
|
||||||
|
const btn = document.querySelector('[data-type="mention"] button') as HTMLElement;
|
||||||
|
expect(btn.className).toContain('pointer-events-none');
|
||||||
|
expect(btn.className).toContain('group-hover/mention:pointer-events-auto');
|
||||||
|
expect(btn.className).toContain('group-focus-within/mention:pointer-events-auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-syncs the pencil to inert when the editor is disabled mid-session', async () => {
|
||||||
|
// editor.setEditable() emits "update", not a transaction — the NodeView must
|
||||||
|
// listen on "update" or a runtime disable flip leaves the pencil stale.
|
||||||
|
let setDisabled!: (value: boolean) => void;
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: '@Aug ',
|
||||||
|
initialMentions: [{ personId: 'p-aug', displayName: 'Aug' }],
|
||||||
|
disabled: false,
|
||||||
|
onChange: () => {},
|
||||||
|
onReady: (api: { setDisabled: (value: boolean) => void }) => {
|
||||||
|
setDisabled = api.setDisabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const btn = document.querySelector(
|
||||||
|
'[data-type="mention"] button'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn!.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
setDisabled(true);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const btn = document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(btn.tabIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ type Props = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||||
|
/** Hands the test a setter so it can flip `disabled` after mount — used to
|
||||||
|
* exercise the editor's reactive `disabled` $effect (mid-session toggle). */
|
||||||
|
onReady?: (api: { setDisabled: (value: boolean) => void }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,13 +21,19 @@ let {
|
|||||||
initialMentions = [],
|
initialMentions = [],
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange
|
onChange,
|
||||||
|
onReady
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// initial* props seed mount-time state; reading them inside untrack signals
|
// initial* props seed mount-time state; reading them inside untrack signals
|
||||||
// the intentional one-shot capture and silences state_referenced_locally.
|
// the intentional one-shot capture and silences state_referenced_locally.
|
||||||
let value = $state(untrack(() => initialValue));
|
let value = $state(untrack(() => initialValue));
|
||||||
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
||||||
|
let disabledState = $state(untrack(() => disabled));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
onReady?.({ setDisabled: (next: boolean) => (disabledState = next) });
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
||||||
@@ -35,5 +44,5 @@ $effect(() => {
|
|||||||
bind:value={value}
|
bind:value={value}
|
||||||
bind:mentionedPersons={mentionedPersons}
|
bind:mentionedPersons={mentionedPersons}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabledState}
|
||||||
/>
|
/>
|
||||||
|
|||||||
167
frontend/src/lib/shared/discussion/mentionNodeView.ts
Normal file
167
frontend/src/lib/shared/discussion/mentionNodeView.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the re-edit dropdown for a saved @mention. The host editor supplies
|
||||||
|
* this; the NodeView calls it when the pencil is activated.
|
||||||
|
*
|
||||||
|
* @param getRect anchor for the dropdown — the mention token's on-screen rect
|
||||||
|
* @param displayName the stored display text, used to pre-fill the search input
|
||||||
|
* @param pos the mention node's document position, captured at open time
|
||||||
|
* so the eventual re-link targets exactly this node
|
||||||
|
*/
|
||||||
|
export type RelinkRequest = (
|
||||||
|
getRect: () => DOMRect | null,
|
||||||
|
displayName: string,
|
||||||
|
pos: number
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type NodeViewArgs = {
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
editor: Editor;
|
||||||
|
getPos: () => number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static developer markup — no user data reaches the DOM here, so building the
|
||||||
|
// glyph element-by-element (never innerHTML) keeps the "user text is textContent
|
||||||
|
// only" rule honest across the whole NodeView.
|
||||||
|
function buildPencilIcon(): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('stroke-width', '2');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.setAttribute('class', 'h-4 w-4');
|
||||||
|
const tip = document.createElementNS(SVG_NS, 'path');
|
||||||
|
tip.setAttribute('d', 'M12 20h9');
|
||||||
|
tip.setAttribute('stroke-linecap', 'round');
|
||||||
|
const body = document.createElementNS(SVG_NS, 'path');
|
||||||
|
body.setAttribute('d', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z');
|
||||||
|
body.setAttribute('stroke-linejoin', 'round');
|
||||||
|
svg.append(tip, body);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiptap NodeView for a person @mention. Renders the `@displayName` token text
|
||||||
|
* (as `textContent` — the name is user-influenced, never `innerHTML`) plus a
|
||||||
|
* `contenteditable="false"` pencil button that opens the re-edit dropdown (#628).
|
||||||
|
*
|
||||||
|
* The pencil sits in a fixed-width slot revealed on whole-token hover and
|
||||||
|
* keyboard focus — an instant opacity swap (respects prefers-reduced-motion),
|
||||||
|
* so following words never shift and the caret model stays stable.
|
||||||
|
*/
|
||||||
|
export function createMentionNodeView(requestRelink: RelinkRequest) {
|
||||||
|
return ({ node, editor, getPos }: NodeViewArgs) => {
|
||||||
|
// Tracks the node's current attrs across update()s so the pencil always
|
||||||
|
// opens with the live displayName (relink only swaps personId today, but
|
||||||
|
// this keeps openRelink honest if displayName ever becomes mutable).
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const dom = document.createElement('span');
|
||||||
|
dom.className = 'mention-token group/mention';
|
||||||
|
dom.setAttribute('data-type', 'mention');
|
||||||
|
dom.setAttribute('data-person-id', node.attrs.personId ?? '');
|
||||||
|
dom.setAttribute('data-display-name', node.attrs.displayName ?? '');
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium';
|
||||||
|
text.textContent = `@${node.attrs.displayName}`;
|
||||||
|
dom.appendChild(text);
|
||||||
|
|
||||||
|
// Fixed-width slot — always present so revealing the pencil never reflows
|
||||||
|
// the following text (NFR no-reflow). Only opacity changes on reveal.
|
||||||
|
const slot = document.createElement('span');
|
||||||
|
slot.className =
|
||||||
|
'mention-edit-slot ml-0.5 inline-flex w-4 shrink-0 items-center justify-center align-middle';
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.contentEditable = 'false';
|
||||||
|
button.tabIndex = 0;
|
||||||
|
button.setAttribute('aria-label', m.person_mention_edit_label());
|
||||||
|
// Visible glyph stays ~16px; the 44px tap target comes from padding pulled
|
||||||
|
// back with negative margin so the larger hit box does not reflow the line.
|
||||||
|
// While hidden the button must be pointer-events-none — opacity-0 alone
|
||||||
|
// still hit-tests, and the 44px box overhangs the adjacent text, so a click
|
||||||
|
// in the gap between two mentions would otherwise land on this invisible
|
||||||
|
// button and spuriously open the dropdown (AC-8). Pointer events are
|
||||||
|
// re-enabled together with the opacity reveal on hover/focus.
|
||||||
|
button.className = [
|
||||||
|
'mention-edit-btn',
|
||||||
|
'-mx-3 -my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-2',
|
||||||
|
'pointer-events-none opacity-0 transition-none',
|
||||||
|
'group-hover/mention:pointer-events-auto group-hover/mention:opacity-100',
|
||||||
|
'group-focus-within/mention:pointer-events-auto group-focus-within/mention:opacity-100',
|
||||||
|
'focus:pointer-events-auto focus:opacity-100',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy',
|
||||||
|
'disabled:cursor-not-allowed'
|
||||||
|
].join(' ');
|
||||||
|
button.appendChild(buildPencilIcon());
|
||||||
|
slot.appendChild(button);
|
||||||
|
dom.appendChild(slot);
|
||||||
|
|
||||||
|
const openRelink = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// AC-7: inert when the editor is disabled — independent of the wrapper's
|
||||||
|
// pointer-events-none, on both the pointer and the keyboard path.
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
requestRelink(() => text.getBoundingClientRect(), currentNode.attrs.displayName ?? '', pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent mousedown from moving the editor selection before the click
|
||||||
|
// fires — keeps the captured pos valid. click + Enter/Space activate.
|
||||||
|
button.addEventListener('mousedown', (event) => event.preventDefault());
|
||||||
|
button.addEventListener('click', openRelink);
|
||||||
|
button.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') openRelink(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncEditable = () => {
|
||||||
|
const editable = editor.isEditable;
|
||||||
|
button.disabled = !editable;
|
||||||
|
if (editable) {
|
||||||
|
button.removeAttribute('aria-disabled');
|
||||||
|
button.tabIndex = 0;
|
||||||
|
} else {
|
||||||
|
button.setAttribute('aria-disabled', 'true');
|
||||||
|
button.tabIndex = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
syncEditable();
|
||||||
|
// editor.setEditable() emits "update" (not a ProseMirror transaction), so
|
||||||
|
// we listen on "update" to re-sync the pencil's inert state when `disabled`
|
||||||
|
// flips mid-session. "update" also skips selection-only changes, so this
|
||||||
|
// fires far less often than a "transaction" listener would.
|
||||||
|
editor.on('update', syncEditable);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
// The mention is an atom leaf — its DOM is fully owned here, so PM must
|
||||||
|
// ignore our mutations (token textContent / disabled attr) rather than
|
||||||
|
// trying to read them back into the document.
|
||||||
|
ignoreMutation: () => true,
|
||||||
|
// Pencil events are ours; everything else (caret placement on the token)
|
||||||
|
// flows to ProseMirror.
|
||||||
|
stopEvent: (event: Event) => button.contains(event.target as Node),
|
||||||
|
update(updatedNode: ProseMirrorNode) {
|
||||||
|
if (updatedNode.type.name !== 'mention') return false;
|
||||||
|
currentNode = updatedNode;
|
||||||
|
text.textContent = `@${updatedNode.attrs.displayName}`;
|
||||||
|
dom.setAttribute('data-person-id', updatedNode.attrs.personId ?? '');
|
||||||
|
dom.setAttribute('data-display-name', updatedNode.attrs.displayName ?? '');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
editor.off('update', syncEditable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -74,7 +74,16 @@ export default defineConfig({
|
|||||||
'src/lib/document/**',
|
'src/lib/document/**',
|
||||||
'src/hooks.server.ts'
|
'src/hooks.server.ts'
|
||||||
],
|
],
|
||||||
exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'],
|
exclude: [
|
||||||
|
'**/*.svelte',
|
||||||
|
'**/*.svelte.ts',
|
||||||
|
'**/__mocks__/**',
|
||||||
|
// Tiptap NodeView — builds live ProseMirror DOM and only runs inside
|
||||||
|
// the browser editor, so it is exercised by the client project's
|
||||||
|
// browser tests, not this node server-coverage run. Browser-only UI
|
||||||
|
// .ts file, excluded like the actions/hooks this config measures around.
|
||||||
|
'src/lib/shared/discussion/mentionNodeView.ts'
|
||||||
|
],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 80,
|
lines: 80,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user