Die Editorfläche bekommt einen lokalisierten zugänglichen Namen und ist im Lesemodus role=document statt eines unbenannten Textfelds (setOptions im selben Layout-Effekt wie setEditable). Eingeklappte Sidebar zusätzlich inert (aria-hidden allein ließ fokussierbare Kinder im Tab-Weg). Die li-Zwischenknoten der Listboxen (Wikilink-/Mention-Autocomplete, Suchergebnisse) sind role=presentation, damit listbox→option wieder eine gültige Eltern-Kind-Beziehung ist. Toolbar: Pfeiltasten-Navigation über die Controls (native Selects behalten ihre Pfeiltasten) und ein sprechendes Toolbar-Label statt des Absatz-Buttons-Labels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import { slugify } from '@dorfteich/shared';
|
||
import type { Editor } from '@tiptap/react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import { useWikilinks } from './wikilink-context';
|
||
|
||
/** An open `[[` context: the query typed so far and where its `[[` began.
|
||
* `embed` is true when it was opened as `![[` — a page embed (issue #135);
|
||
* `bare` when it was `$[[` — the frameless variant (issue #146). */
|
||
interface QueryState {
|
||
query: string;
|
||
from: number;
|
||
embed: boolean;
|
||
bare: boolean;
|
||
coords: { left: number; bottom: number };
|
||
}
|
||
|
||
/** A suggestion row: an existing page, or a create-phantom hint for a miss. */
|
||
type Suggestion =
|
||
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
|
||
|
||
/** Detects a `[[query` (link), `![[query` (embed, #135) or `$[[query` (bare
|
||
* embed, #146) immediately before a collapsed cursor (issue #46). */
|
||
function detectQuery(
|
||
editor: Editor,
|
||
): { query: string; from: number; embed: boolean; bare: boolean } | null {
|
||
const { selection } = editor.state;
|
||
if (!selection.empty) return null;
|
||
const $from = selection.$from;
|
||
if (!$from.parent.isTextblock) return null;
|
||
const start = Math.max(0, $from.parentOffset - 200);
|
||
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
||
const match = /([!$]?)\[\[([^[\]\n]*)$/.exec(before);
|
||
if (!match) return null;
|
||
const embed = match[1] === '!' || match[1] === '$';
|
||
const bare = match[1] === '$';
|
||
const query = match[2] ?? '';
|
||
// `[[` is 2 chars; an embed's leading `!`/`$` is one more to swallow.
|
||
return { query, from: selection.from - query.length - 2 - (embed ? 1 : 0), embed, bare };
|
||
}
|
||
|
||
/**
|
||
* Autocomplete popup for `[[` wikilinks (issue #46). Typing `[[` opens a list
|
||
* of the current pond's pages filtered by the query (with a create-new-page
|
||
* hint for misses); Enter/click inserts the wikilink node and removes the typed
|
||
* `[[query`. Keyboard navigation (↑/↓/Enter/Esc) is intercepted in the capture
|
||
* phase so ProseMirror does not act on those keys while the popup is open.
|
||
*/
|
||
export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
|
||
const { t } = useTranslation('editor');
|
||
const { targets } = useWikilinks();
|
||
const [state, setState] = useState<QueryState | null>(null);
|
||
const [selected, setSelected] = useState(0);
|
||
|
||
const suggestions = useMemo<Suggestion[]>(() => {
|
||
if (!state) return [];
|
||
const q = state.query.trim().toLowerCase();
|
||
const pages: Suggestion[] = targets
|
||
.filter((page) => page.title.toLowerCase().includes(q) || page.slug.toLowerCase().includes(q))
|
||
.slice(0, 8)
|
||
.map((page) => ({ kind: 'page', slug: page.slug, label: page.title }));
|
||
const createSlug = slugify(state.query);
|
||
if (createSlug && !targets.some((page) => page.slug === createSlug)) {
|
||
pages.push({ kind: 'create', slug: createSlug, label: state.query.trim() });
|
||
}
|
||
return pages;
|
||
}, [state, targets]);
|
||
|
||
// Latest state/suggestions/selection, so the once-attached keydown handler
|
||
// (below) and click handler both act on current values, not a stale closure.
|
||
const live = useRef({ state, suggestions, selected });
|
||
live.current = { state, suggestions, selected };
|
||
|
||
function close(): void {
|
||
setState(null);
|
||
setSelected(0);
|
||
}
|
||
|
||
function choose(item: Suggestion | undefined): void {
|
||
const current = live.current.state;
|
||
if (!item || !current) return;
|
||
const range = { from: current.from, to: editor.state.selection.from };
|
||
if (current.embed) {
|
||
// A page embed is a block node (#135) — replace the typed `![[query` with
|
||
// the transclusion block; ProseMirror lifts it out of the paragraph.
|
||
// `$[[` opens the frameless variant (#146).
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.insertContentAt(range, {
|
||
type: 'transclusion',
|
||
attrs: { targetSlug: item.slug, displayText: null, bare: current.bare },
|
||
})
|
||
.run();
|
||
} else {
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.insertContentAt(range, [
|
||
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
|
||
{ type: 'text', text: ' ' },
|
||
])
|
||
.run();
|
||
}
|
||
close();
|
||
}
|
||
|
||
// Recompute the open query on every doc/selection change.
|
||
useEffect(() => {
|
||
const update = (): void => {
|
||
const found = detectQuery(editor);
|
||
if (!found) {
|
||
setState(null);
|
||
return;
|
||
}
|
||
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
|
||
setSelected(0);
|
||
};
|
||
editor.on('transaction', update);
|
||
return () => {
|
||
editor.off('transaction', update);
|
||
};
|
||
}, [editor]);
|
||
|
||
// Keyboard navigation, intercepted before ProseMirror (capture phase).
|
||
useEffect(() => {
|
||
const dom = editor.view.dom;
|
||
const onKeyDown = (event: KeyboardEvent): void => {
|
||
const { state: s, suggestions: items, selected: sel } = live.current;
|
||
if (!s || items.length === 0) return;
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
setSelected((i) => (i + 1) % items.length);
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
setSelected((i) => (i - 1 + items.length) % items.length);
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
choose(items[sel]);
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
close();
|
||
}
|
||
};
|
||
dom.addEventListener('keydown', onKeyDown, true);
|
||
return () => dom.removeEventListener('keydown', onKeyDown, true);
|
||
}, [editor]);
|
||
|
||
if (!state || suggestions.length === 0) return null;
|
||
|
||
return (
|
||
<ul
|
||
className="wikilink-suggest"
|
||
role="listbox"
|
||
aria-label={t('wikilink.autocompleteLabel')}
|
||
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
||
>
|
||
{suggestions.map((item, index) => (
|
||
<li key={`${item.kind}:${item.slug}`} role="presentation">
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
aria-selected={index === selected}
|
||
className={
|
||
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
|
||
}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
choose(item);
|
||
}}
|
||
>
|
||
{item.kind === 'create' ? t('wikilink.createHint', { title: item.label }) : item.label}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|