From 7244b8921503ac125e087fed61c42984683236ab Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 12:42:01 +0200 Subject: [PATCH] Add wikilink node with autocomplete (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Obsidian-style `[[page links]]` (ADR 0004). - shared: reserved `wikilink` inline atom in the editor schema (attrs `targetSlug`, optional `displayText`); markdown mapping `[[slug]]` / `[[slug|text]]` via a markdown-it inline rule + serializer node; plain-text and HTML derivation include the shown text. Round-trip + parse unit tests. - web: - `Wikilink` node extension with a React NodeView: shows the explicit display text or the target's current title (so a rename updates the link), renders a missing target as a dashed phantom with a tooltip, navigates on click in read mode. - `[[` autocomplete popup (`WikilinkAutocomplete`), dependency-free: filters the pond's pages as you type with a create-new-page hint for misses, Enter/click inserts the node and removes the typed `[[query`; ↑/↓/Enter/Esc intercepted in the capture phase so ProseMirror does not act on them. - `WikilinkContext` provides the pond's pages (slug→title) for live resolution and the autocomplete, populated by the page editor. - i18n `editor.wikilink.*` (de + en); wikilink + phantom + popup styles. - e2e `wikilink.spec.ts` (new CI pack): type `[[`, autocomplete filters and inserts a working link that resolves the target title and persists across a reload. Phantom → live resolution on page creation is verified in #47. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .gitea/workflows/ci.yml | 10 ++ apps/web/e2e/wikilink.spec.ts | 63 +++++++ apps/web/src/editor/WikilinkAutocomplete.tsx | 155 ++++++++++++++++++ apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/wikilink.tsx | 62 +++++++ apps/web/src/editor/wikilink-context.tsx | 53 ++++++ apps/web/src/pages/PageEditorPage.tsx | 71 ++++---- apps/web/src/styles/base.css | 56 +++++++ packages/shared/i18n/de/editor.json | 5 + packages/shared/i18n/en/editor.json | 5 + packages/shared/src/editor-schema/html.ts | 9 + .../shared/src/editor-schema/markdown.test.ts | 24 +++ packages/shared/src/editor-schema/markdown.ts | 46 ++++++ .../shared/src/editor-schema/plain-text.ts | 12 +- packages/shared/src/editor-schema/schema.ts | 32 ++++ 15 files changed, 574 insertions(+), 31 deletions(-) create mode 100644 apps/web/e2e/wikilink.spec.ts create mode 100644 apps/web/src/editor/WikilinkAutocomplete.tsx create mode 100644 apps/web/src/editor/nodes/wikilink.tsx create mode 100644 apps/web/src/editor/wikilink-context.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6f4beb4..5dbc4fb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -184,6 +184,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts + - name: Reset login rate limit before wikilink pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run wikilink pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/wikilink.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true diff --git a/apps/web/e2e/wikilink.spec.ts b/apps/web/e2e/wikilink.spec.ts new file mode 100644 index 0000000..3d20498 --- /dev/null +++ b/apps/web/e2e/wikilink.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Wikilink pack (issue #46). Types `[[` in the editor, verifies the + * autocomplete filters and inserts a working link node, and that the link + * survives a reload (persisted through the collab server). Language-independent + * selectors (CSS classes + page titles). Phantom → live resolution on page + * creation is verified in #47. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +type Ctx = Awaited>; + +async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } }); + return created.json(); +} + +test('typing [[ autocompletes and inserts a working wikilink', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const ts = Date.now(); + const targetTitle = `Wiki Target ${ts}`; + await createPage(context, pond.id, targetTitle); + const source = await createPage(context, pond.id, `Wiki Source ${ts}`); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${source.slug}`); + + // Enter edit mode and focus the editor body. + await page.locator('.editor-page__mode-toggle').click(); + const body = page.locator('.editor-content .ProseMirror'); + await expect(body).toBeVisible(); + await body.click(); + + // Type the trigger and part of the target title — the popup filters live. + await page.keyboard.type(`[[Wiki Target ${ts}`); + const suggest = page.locator('.wikilink-suggest'); + await expect(suggest).toBeVisible(); + await expect(suggest.getByText(targetTitle, { exact: true })).toBeVisible(); + + // Enter inserts the wikilink node, which renders the target's current title. + await page.keyboard.press('Enter'); + const link = page.locator('.editor-content a.wikilink', { hasText: targetTitle }); + await expect(link).toBeVisible(); + // A resolved (existing) target is not phantom. + await expect(link).not.toHaveClass(/wikilink--phantom/); + + // Persisted through collaboration: reload and the link is still there. + await page.reload(); + await page.locator('.editor-page__mode-toggle').click(); + await expect(page.locator('.editor-content a.wikilink', { hasText: targetTitle })).toBeVisible(); + + await context.close(); +}); diff --git a/apps/web/src/editor/WikilinkAutocomplete.tsx b/apps/web/src/editor/WikilinkAutocomplete.tsx new file mode 100644 index 0000000..517d602 --- /dev/null +++ b/apps/web/src/editor/WikilinkAutocomplete.tsx @@ -0,0 +1,155 @@ +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. */ +interface QueryState { + query: string; + from: number; + 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` immediately before a collapsed cursor (issue #46). */ +function detectQuery(editor: Editor): { query: string; from: number } | 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 query = match[1] ?? ''; + return { query, from: selection.from - query.length - 2 }; +} + +/** + * 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; + editor + .chain() + .focus() + .insertContentAt({ from: current.from, to: editor.state.selection.from }, [ + { 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 ( +
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index d8704c1..2e3e54a 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -6,6 +6,7 @@ import { Image } from './nodes/image'; import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { TaskItem } from './nodes/task-item'; +import { Wikilink } from './nodes/wikilink'; import { Blockquote, CodeBlock, @@ -37,6 +38,7 @@ export const documentExtensions: AnyExtension[] = [ TaskItem, HardBreak, Image, + Wikilink, Table, TableRow, TableCell, diff --git a/apps/web/src/editor/nodes/wikilink.tsx b/apps/web/src/editor/nodes/wikilink.tsx new file mode 100644 index 0000000..855041a --- /dev/null +++ b/apps/web/src/editor/nodes/wikilink.tsx @@ -0,0 +1,62 @@ +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { attributesFromSpec, nodeSpec } from '../spec-utils'; +import { useWikilinks } from '../wikilink-context'; + +/** + * Renders a `[[wikilink]]` (issue #46): its shown text is the explicit + * `displayText` or, when none is set, the target page's current title — so + * renaming the target updates the link everywhere. A missing target renders as + * a dashed "phantom" with a tooltip and becomes live once the page exists + * (resolution is server-side in #47). Click navigates in read mode; in edit + * mode the atom just selects (no navigation). + */ +function WikilinkView({ node, editor }: NodeViewProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const navigate = useNavigate(); + const { resolve, pondSlug } = useWikilinks(); + + const slug = node.attrs.targetSlug as string; + const display = node.attrs.displayText as string | null; + const { title, exists } = resolve(slug); + const text = display ?? title ?? slug; + const editable = editor.isEditable; + + return ( + + { + event.preventDefault(); + // In edit mode a click should not navigate away from the editor. + if (!editable) navigate(`/p/${pondSlug}/${slug}`); + }} + > + {text} + + + ); +} + +const wikilinkSpec = nodeSpec('wikilink'); +export const Wikilink = Node.create({ + name: 'wikilink', + group: wikilinkSpec.group, + inline: wikilinkSpec.inline, + atom: wikilinkSpec.atom, + addAttributes() { + return attributesFromSpec(wikilinkSpec); + }, + parseHTML: () => wikilinkSpec.parseDOM, + renderHTML: ({ node }) => wikilinkSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(WikilinkView); + }, +}); diff --git a/apps/web/src/editor/wikilink-context.tsx b/apps/web/src/editor/wikilink-context.tsx new file mode 100644 index 0000000..40deba9 --- /dev/null +++ b/apps/web/src/editor/wikilink-context.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext } from 'react'; + +/** A page in the current pond, as the wikilink UI needs it (issue #46). */ +export interface WikilinkTarget { + slug: string; + title: string; +} + +/** How a wikilink node resolves its target for display and navigation. */ +export interface WikilinkResolution { + /** Current title of the target page, or null when it does not exist (phantom). */ + title: string | null; + exists: boolean; +} + +export interface WikilinkContextValue { + /** Pages of the current pond, for the `[[` autocomplete. */ + targets: WikilinkTarget[]; + /** Resolve a slug to its live title and existence. */ + resolve: (slug: string) => WikilinkResolution; + /** Pond slug, so a wikilink can build its navigation URL. */ + pondSlug: string; + /** Whether the editor is in edit mode (click behaviour differs from read mode). */ + editable: boolean; +} + +/** + * Live pond context for wikilinks (issue #46). Provided by the page editor, + * consumed by the wikilink NodeView (title resolution, phantom styling) and the + * autocomplete popup. Defaults are inert so a wikilink still renders (as its + * slug) if it is ever mounted without a provider. + */ +export const WikilinkContext = createContext({ + targets: [], + resolve: () => ({ title: null, exists: false }), + pondSlug: '', + editable: false, +}); + +export function useWikilinks(): WikilinkContextValue { + return useContext(WikilinkContext); +} + +/** Builds a slug→title lookup and a resolver from a pond's page list. */ +export function makeWikilinkResolver( + targets: WikilinkTarget[], +): (slug: string) => WikilinkResolution { + const bySlug = new Map(targets.map((t) => [t.slug, t.title])); + return (slug) => { + const title = bySlug.get(slug); + return title !== undefined ? { title, exists: true } : { title: null, exists: false }; + }; +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 4aedc84..68e9559 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -1,8 +1,8 @@ -import type { PageStateView, PondView } from '@dorfteich/shared'; +import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as Y from 'yjs'; @@ -18,6 +18,8 @@ import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; import { useCollabProvider } from '../editor/use-collab-provider'; +import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; +import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { recallPage, rememberPage } from '../offline/page-cache'; @@ -101,35 +103,48 @@ function PageEditor({ editor?.setEditable(canEdit); }, [editor, canEdit]); + // Pond pages power wikilink title resolution + the `[[` autocomplete (#46). + const pondPages = useQuery({ + queryKey: ['pages', page.pondId], + queryFn: () => apiGet(`/ponds/${page.pondId}/pages`), + }); + const wikilinks = useMemo(() => { + const targets = (pondPages.data ?? []).map((p) => ({ slug: p.slug, title: p.title })); + return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit }; + }, [pondPages.data, pondSlug, canEdit]); + if (!editor || !ydoc) return <>; return ( -
- {canEdit && } -
- {t(`connection.${collab.status}`)} + +
+ {canEdit && } +
+ {t(`connection.${collab.status}`)} +
+ + {collab.localOnly && ( +
+ {t('offline.localOnly')} +
+ )} + {mode === 'edit' && readOnly && ( +
+ {t('readOnly.notice')} +
+ )} + {collab.tooLarge && ( +
+ {t('tooLarge.notice')} +
+ )} + {collab.accessRevoked && ( + + )} + + {canEdit && }
- - {collab.localOnly && ( -
- {t('offline.localOnly')} -
- )} - {mode === 'edit' && readOnly && ( -
- {t('readOnly.notice')} -
- )} - {collab.tooLarge && ( -
- {t('tooLarge.notice')} -
- )} - {collab.accessRevoked && ( - - )} - -
+ ); } @@ -287,7 +302,7 @@ export function PageEditorPage(): React.JSX.Element { />