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). */ interface QueryState { query: string; from: number; embed: 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) or `![[query` (embed, #135) immediately before a * collapsed cursor (issue #46). The optional leading `!` opens an embed. */ function detectQuery(editor: Editor): { query: string; from: number; embed: 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] === '!'; 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 }; } /** * 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(null); const [selected, setSelected] = useState(0); const suggestions = useMemo(() => { 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. editor .chain() .focus() .insertContentAt(range, { type: 'transclusion', attrs: { targetSlug: item.slug, displayText: null }, }) .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 ( ); }