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 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts
|
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
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
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 { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
|
import { Wikilink } from './nodes/wikilink';
|
||||||
import {
|
import {
|
||||||
Blockquote,
|
Blockquote,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
@ -37,6 +38,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
TaskItem,
|
TaskItem,
|
||||||
HardBreak,
|
HardBreak,
|
||||||
Image,
|
Image,
|
||||||
|
Wikilink,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
@ -18,6 +18,8 @@ import { ImageUpload } from '../editor/image-upload';
|
|||||||
import { PresenceStrip } from '../editor/PresenceStrip';
|
import { PresenceStrip } from '../editor/PresenceStrip';
|
||||||
import { Toolbar } from '../editor/Toolbar';
|
import { Toolbar } from '../editor/Toolbar';
|
||||||
import { useCollabProvider } from '../editor/use-collab-provider';
|
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 { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||||
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
||||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||||
@ -101,35 +103,48 @@ function PageEditor({
|
|||||||
editor?.setEditable(canEdit);
|
editor?.setEditable(canEdit);
|
||||||
}, [editor, 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 <></>;
|
if (!editor || !ydoc) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-shell">
|
<WikilinkContext.Provider value={wikilinks}>
|
||||||
{canEdit && <Toolbar editor={editor} />}
|
<div className="editor-shell">
|
||||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
{canEdit && <Toolbar editor={editor} />}
|
||||||
{t(`connection.${collab.status}`)}
|
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||||
|
{t(`connection.${collab.status}`)}
|
||||||
|
</div>
|
||||||
|
<PresenceStrip provider={collab.provider} />
|
||||||
|
{collab.localOnly && (
|
||||||
|
<div className="editor-banner editor-banner--info" role="note">
|
||||||
|
{t('offline.localOnly')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'edit' && readOnly && (
|
||||||
|
<div className="editor-banner editor-banner--info" role="note">
|
||||||
|
{t('readOnly.notice')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collab.tooLarge && (
|
||||||
|
<div className="editor-banner editor-banner--error" role="alert">
|
||||||
|
{t('tooLarge.notice')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collab.accessRevoked && (
|
||||||
|
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
|
||||||
|
)}
|
||||||
|
<EditorContent editor={editor} className="editor-content" />
|
||||||
|
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||||
</div>
|
</div>
|
||||||
<PresenceStrip provider={collab.provider} />
|
</WikilinkContext.Provider>
|
||||||
{collab.localOnly && (
|
|
||||||
<div className="editor-banner editor-banner--info" role="note">
|
|
||||||
{t('offline.localOnly')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === 'edit' && readOnly && (
|
|
||||||
<div className="editor-banner editor-banner--info" role="note">
|
|
||||||
{t('readOnly.notice')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{collab.tooLarge && (
|
|
||||||
<div className="editor-banner editor-banner--error" role="alert">
|
|
||||||
{t('tooLarge.notice')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{collab.accessRevoked && (
|
|
||||||
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
|
|
||||||
)}
|
|
||||||
<EditorContent editor={editor} className="editor-content" />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,7 +302,7 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button"
|
className="button editor-page__mode-toggle"
|
||||||
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
||||||
>
|
>
|
||||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||||
|
|||||||
@ -1174,3 +1174,59 @@ button {
|
|||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-subtle);
|
||||||
color: var(--color-text);
|
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.",
|
"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.",
|
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
|
||||||
"restoreLink": "Im Papierkorb ansehen"
|
"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.",
|
"purgeConfirm": "Permanently delete this page and its files? This cannot be undone.",
|
||||||
"pageTrashedHint": "This page has been moved to the trash.",
|
"pageTrashedHint": "This page has been moved to the trash.",
|
||||||
"restoreLink": "View in 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 width = child.attrs.width as number | null;
|
||||||
const widthAttr = width ? ` width="${width}"` : '';
|
const widthAttr = width ? ` width="${width}"` : '';
|
||||||
out += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`;
|
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;
|
return out;
|
||||||
|
|||||||
@ -30,6 +30,10 @@ const FIXTURES: Record<string, string> = {
|
|||||||
image: '',
|
image: '',
|
||||||
|
|
||||||
nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item',
|
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)', () => {
|
describe('markdown round-trip (issue #24)', () => {
|
||||||
@ -59,4 +63,24 @@ describe('markdown round-trip (issue #24)', () => {
|
|||||||
const doc = markdownToDoc('- [ ] Task\n- Plain item');
|
const doc = markdownToDoc('- [ ] Task\n- Plain item');
|
||||||
expect(doc.firstChild?.type.name).toBe('bullet_list');
|
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 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 Token from 'markdown-it/lib/token.mjs';
|
||||||
import { Mark, Node } from 'prosemirror-model';
|
import { Mark, Node } from 'prosemirror-model';
|
||||||
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
|
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
|
||||||
@ -131,8 +132,41 @@ function transformTokens(tokens: Token[]): Token[] {
|
|||||||
return out;
|
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 {
|
function createTokenizer(): MarkdownIt {
|
||||||
const md = new MarkdownIt('default', { html: false });
|
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);
|
const rawParse = md.parse.bind(md);
|
||||||
md.parse = (src, env) => transformTokens(rawParse(src, env));
|
md.parse = (src, env) => transformTokens(rawParse(src, env));
|
||||||
return md;
|
return md;
|
||||||
@ -173,6 +207,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
hardbreak: { node: 'hard_break' },
|
hardbreak: { node: 'hard_break' },
|
||||||
|
wikilink: {
|
||||||
|
node: 'wikilink',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
targetSlug: tok.attrGet('target') ?? '',
|
||||||
|
displayText: tok.attrGet('display') || null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
em: { mark: 'italic' },
|
em: { mark: 'italic' },
|
||||||
strong: { mark: 'bold' },
|
strong: { mark: 'bold' },
|
||||||
s: { mark: 'strikethrough' },
|
s: { mark: 'strikethrough' },
|
||||||
@ -268,6 +309,11 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
|
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
|
||||||
state.write(``);
|
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) {
|
hard_break(state, node, parent, index) {
|
||||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||||
if (parent.child(i).type !== node.type) {
|
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`). */
|
/** Search/preview representation (data-model.md `page_content_cache`). */
|
||||||
export function docToPlainText(doc: Node): string {
|
export function docToPlainText(doc: Node): string {
|
||||||
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) =>
|
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) => {
|
||||||
leaf.type.name === 'image' ? ((leaf.attrs.alt as string) ?? '') : '',
|
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();
|
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: {} }),
|
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user