Add wikilink node with autocomplete (#46)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
69b00fcf2f
commit
7244b89215
@ -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
|
||||
|
||||
63
apps/web/e2e/wikilink.spec.ts
Normal file
63
apps/web/e2e/wikilink.spec.ts
Normal file
@ -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<ReturnType<typeof contextForUser>>;
|
||||
|
||||
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();
|
||||
});
|
||||
155
apps/web/src/editor/WikilinkAutocomplete.tsx
Normal file
155
apps/web/src/editor/WikilinkAutocomplete.tsx
Normal file
@ -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<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;
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
62
apps/web/src/editor/nodes/wikilink.tsx
Normal file
62
apps/web/src/editor/nodes/wikilink.tsx
Normal file
@ -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 (
|
||||
<NodeViewWrapper as="span" className="wikilink-nodeview">
|
||||
<a
|
||||
className={exists ? 'wikilink' : 'wikilink wikilink--phantom'}
|
||||
href={`/p/${pondSlug}/${slug}`}
|
||||
title={exists ? undefined : t('wikilink.phantomTooltip')}
|
||||
contentEditable={false}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
// In edit mode a click should not navigate away from the editor.
|
||||
if (!editable) navigate(`/p/${pondSlug}/${slug}`);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
53
apps/web/src/editor/wikilink-context.tsx
Normal file
53
apps/web/src/editor/wikilink-context.tsx
Normal file
@ -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<WikilinkContextValue>({
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@ -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,9 +103,20 @@ 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<PageListItemView[]>(`/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 (
|
||||
<WikilinkContext.Provider value={wikilinks}>
|
||||
<div className="editor-shell">
|
||||
{canEdit && <Toolbar editor={editor} />}
|
||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||
@ -129,7 +142,9 @@ function PageEditor({
|
||||
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
|
||||
)}
|
||||
<EditorContent editor={editor} className="editor-content" />
|
||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||
</div>
|
||||
</WikilinkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -287,7 +302,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
className="button editor-page__mode-toggle"
|
||||
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
||||
>
|
||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||
|
||||
@ -1174,3 +1174,59 @@ button {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Wikilinks (issue #46) ------------------------------------------------- */
|
||||
|
||||
.wikilink-nodeview {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.wikilink {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wikilink:hover {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
/* A link to a page that does not exist yet. */
|
||||
.wikilink--phantom {
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px dashed currentColor;
|
||||
}
|
||||
|
||||
.wikilink-suggest {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
min-width: 14rem;
|
||||
max-width: 22rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.wikilink-suggest__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.wikilink-suggest__item.is-active,
|
||||
.wikilink-suggest__item:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
@ -126,5 +126,10 @@
|
||||
"purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
|
||||
"restoreLink": "Im Papierkorb ansehen"
|
||||
},
|
||||
"wikilink": {
|
||||
"phantomTooltip": "Diese Seite existiert noch nicht",
|
||||
"autocompleteLabel": "Auf eine Seite verlinken",
|
||||
"createHint": "Seite „{{title}}“ anlegen"
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,5 +126,10 @@
|
||||
"purgeConfirm": "Permanently delete this page and its files? This cannot be undone.",
|
||||
"pageTrashedHint": "This page has been moved to the trash.",
|
||||
"restoreLink": "View in trash"
|
||||
},
|
||||
"wikilink": {
|
||||
"phantomTooltip": "This page does not exist yet",
|
||||
"autocompleteLabel": "Link to a page",
|
||||
"createHint": "Create page “{{title}}”"
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,6 +56,15 @@ function renderInline(node: Node): string {
|
||||
const width = child.attrs.width as number | null;
|
||||
const widthAttr = width ? ` width="${width}"` : '';
|
||||
out += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`;
|
||||
} else if (child.type.name === 'wikilink') {
|
||||
// The slug and shown text; live title resolution and navigation happen
|
||||
// where the pond's pages are known (issue #46) — this is the static
|
||||
// representation used for the content cache and version history.
|
||||
const slug = escapeHtml(child.attrs.targetSlug as string);
|
||||
const display = child.attrs.displayText as string | null;
|
||||
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
||||
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
||||
out += `<a class="wikilink" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
|
||||
@ -30,6 +30,10 @@ const FIXTURES: Record<string, string> = {
|
||||
image: '',
|
||||
|
||||
nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item',
|
||||
|
||||
wikilinkBare: 'See [[architecture]] for details.',
|
||||
|
||||
wikilinkWithDisplay: 'See [[architecture|the design docs]] for details.',
|
||||
};
|
||||
|
||||
describe('markdown round-trip (issue #24)', () => {
|
||||
@ -59,4 +63,24 @@ describe('markdown round-trip (issue #24)', () => {
|
||||
const doc = markdownToDoc('- [ ] Task\n- Plain item');
|
||||
expect(doc.firstChild?.type.name).toBe('bullet_list');
|
||||
});
|
||||
|
||||
it('parses a wikilink into a node with slug and optional display text (issue #46)', () => {
|
||||
const withDisplay = markdownToDoc('[[my-page|My Page]]').firstChild?.firstChild;
|
||||
expect(withDisplay?.type.name).toBe('wikilink');
|
||||
expect(withDisplay?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: 'My Page' });
|
||||
|
||||
const bare = markdownToDoc('[[my-page]]').firstChild?.firstChild;
|
||||
expect(bare?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: null });
|
||||
});
|
||||
|
||||
it('leaves malformed brackets as plain text', () => {
|
||||
// A single bracket pair is an ordinary (broken) markdown link, not a wikilink.
|
||||
const doc = markdownToDoc('[[unclosed and [not a link]');
|
||||
expect(doc.textContent).toContain('[[unclosed');
|
||||
let hasWikilink = false;
|
||||
doc.descendants((n) => {
|
||||
if (n.type.name === 'wikilink') hasWikilink = true;
|
||||
});
|
||||
expect(hasWikilink).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs';
|
||||
import Token from 'markdown-it/lib/token.mjs';
|
||||
import { Mark, Node } from 'prosemirror-model';
|
||||
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
|
||||
@ -131,8 +132,41 @@ function transformTokens(tokens: Token[]): Token[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** markdown-it inline rule for `[[slug]]` / `[[slug|display]]` (issue #46). */
|
||||
function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
// Two opening brackets.
|
||||
if (state.src.charCodeAt(start) !== 0x5b || state.src.charCodeAt(start + 1) !== 0x5b) {
|
||||
return false;
|
||||
}
|
||||
const close = state.src.indexOf(']]', start + 2);
|
||||
if (close < 0) return false;
|
||||
const inner = state.src.slice(start + 2, close);
|
||||
// No nesting or line breaks inside a wikilink.
|
||||
if (inner.includes('[') || inner.includes(']') || inner.includes('\n')) return false;
|
||||
|
||||
const pipe = inner.indexOf('|');
|
||||
const slug = (pipe >= 0 ? inner.slice(0, pipe) : inner).trim();
|
||||
const display = pipe >= 0 ? inner.slice(pipe + 1).trim() : '';
|
||||
if (!slug) return false;
|
||||
|
||||
if (!silent) {
|
||||
const token = state.push('wikilink', '', 0);
|
||||
token.attrs = display
|
||||
? [
|
||||
['target', slug],
|
||||
['display', display],
|
||||
]
|
||||
: [['target', slug]];
|
||||
}
|
||||
state.pos = close + 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
function createTokenizer(): MarkdownIt {
|
||||
const md = new MarkdownIt('default', { html: false });
|
||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||
const rawParse = md.parse.bind(md);
|
||||
md.parse = (src, env) => transformTokens(rawParse(src, env));
|
||||
return md;
|
||||
@ -173,6 +207,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
||||
}),
|
||||
},
|
||||
hardbreak: { node: 'hard_break' },
|
||||
wikilink: {
|
||||
node: 'wikilink',
|
||||
getAttrs: (tok) => ({
|
||||
targetSlug: tok.attrGet('target') ?? '',
|
||||
displayText: tok.attrGet('display') || null,
|
||||
}),
|
||||
},
|
||||
em: { mark: 'italic' },
|
||||
strong: { mark: 'bold' },
|
||||
s: { mark: 'strikethrough' },
|
||||
@ -268,6 +309,11 @@ const markdownSerializer = new MarkdownSerializer(
|
||||
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
|
||||
state.write(``);
|
||||
},
|
||||
wikilink(state, node) {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
||||
},
|
||||
hard_break(state, node, parent, index) {
|
||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||
if (parent.child(i).type !== node.type) {
|
||||
|
||||
@ -2,8 +2,14 @@ import { Node } from 'prosemirror-model';
|
||||
|
||||
/** Search/preview representation (data-model.md `page_content_cache`). */
|
||||
export function docToPlainText(doc: Node): string {
|
||||
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) =>
|
||||
leaf.type.name === 'image' ? ((leaf.attrs.alt as string) ?? '') : '',
|
||||
);
|
||||
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) => {
|
||||
if (leaf.type.name === 'image') return (leaf.attrs.alt as string) ?? '';
|
||||
// A wikilink contributes its shown text (explicit display or the slug) so
|
||||
// search and previews include what the reader sees (issue #46).
|
||||
if (leaf.type.name === 'wikilink') {
|
||||
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
@ -142,6 +142,38 @@ export const editorSchema = new Schema({
|
||||
],
|
||||
},
|
||||
|
||||
// Obsidian-style `[[page link]]` (ADR 0004, issue #46). An inline atom
|
||||
// carrying the target page's `slug` and an optional explicit `displayText`;
|
||||
// when `displayText` is null the editor/renderer shows the target's current
|
||||
// title, so a rename updates every link. Live title resolution and phantom
|
||||
// (missing target) styling happen where the pond's pages are known — the
|
||||
// stored document only keeps slug + optional display text.
|
||||
wikilink: {
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
attrs: {
|
||||
targetSlug: { validate: 'string' },
|
||||
displayText: { default: null },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'a[data-wikilink]',
|
||||
getAttrs: (dom) => ({
|
||||
targetSlug: dom.getAttribute('data-wikilink'),
|
||||
displayText: dom.getAttribute('data-display') || null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
const attrs: Record<string, string> = { 'data-wikilink': slug, class: 'wikilink' };
|
||||
if (display) attrs['data-display'] = display;
|
||||
return ['a', attrs, display ?? slug];
|
||||
},
|
||||
},
|
||||
|
||||
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user