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
175 lines
6.3 KiB
TypeScript
175 lines
6.3 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). */
|
||
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>
|
||
);
|
||
}
|