dorfteich/apps/web/src/editor/WikilinkAutocomplete.tsx
Claude Fable 5 15376d4ac2 #135 Seiten-Einbettung ![[Seite]] (Transklusion) im Lesemodus
Obsidian-Syntax `![[slug]]` (optional `![[slug|Anzeige]]`) als Seiten-
Einbettung. Im Lese- und öffentlichen Modus wird der Inhalt der Zielseite
inline gerendert; im Editier-Modus zeigt die NodeView eine Platzhalter-
Karte (Titel + Öffnen-Link).

Shared (Vorbild plugin_block):
- Neuer Block-Atom-Node `transclusion` (targetSlug + optional displayText).
- Markdown: Block-Regel für eine reine `![[…]]`-Zeile (vor `paragraph`
  registriert; mitten im Absatz greift sie bewusst nicht), Token→Node-
  Mapping, Serializer — Round-Trip stabil.
- html.ts: Platzhalter `<div class="dt-transclusion" data-transclusion>`.
- extractWikilinkSlugs erfasst jetzt auch Transklusionen → Einbettung
  zählt als Backlink/Graph-Kante.

Backend (zentraler Render-Pfad):
- PublicService expandiert Platzhalter zur gerenderten Body-HTML der
  Zielseite: SELBER Pond, read-permission-geprüft, Tiefe ≤2 + Zyklen-
  Guard (visited); Fehlend/unlesbar/zyklisch → neutraler Wikilink. Medien
  werden EINMAL über den ganzen Baum aufgelöst (kein Doppel-Processing).
- Neuer authentifizierter Endpoint GET /read/:pondSlug/:pageSlug (nicht
  @Public) liefert dieselbe gerenderte HTML — für die NodeView im
  authentifizierten Lesemodus, auch bei nicht-öffentlichen Seiten.

Web:
- NodeView `transclusion.tsx`: Editier-Modus → Karte; Lesemodus → holt
  /read/:pond/:slug und rendert den (server-sanitisierten) Inhalt inline.
- WikilinkAutocomplete unterstützt `![[` → fügt einen Transklusions-Block
  ein (statt Wikilink).
- CSS für Karte (.dt-transclusion-card) und Embed (.dt-embed), i18n de+en.

Tests: shared Round-Trip-Unit (5), public-DB-Test um Embed-Expansion
(zyklus-sicher, Fehlend→Link) erweitert — grün. typecheck/lint/i18n grün.
Visuelle Editor-Verifikation folgt auf dem Test-Stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 04:01:18 +02:00

175 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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.
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 (
<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}`}>
<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>
);
}