fix(wikilink): navigate to real page URL and extend shared node schema
Resolve wikilinks to /s/<spaceSlug>/p/<slugId> via buildPageUrl instead of the inexistent /page/<uuid> route. Persist slugId and spaceSlug as node attributes so the URL can be rebuilt from the document alone, without an extra lookup round-trip. The client extension now .extend()s the shared WikilinkNode from @docmost/editor-ext to keep the schema identical between client and the Hocuspocus server.
This commit is contained in:
parent
b802f1d647
commit
a87e61e382
3 changed files with 40 additions and 158 deletions
|
|
@ -47,14 +47,18 @@ describe('WikilinkExtension schema', () => {
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has attrs: pageId, title, alias', () => {
|
it('has attrs: pageId, slugId, spaceSlug, title, alias', () => {
|
||||||
const editor = makeEditor();
|
const editor = makeEditor();
|
||||||
const nodeSpec = editor.schema.nodes.wikilink;
|
const nodeSpec = editor.schema.nodes.wikilink;
|
||||||
const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>;
|
const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>;
|
||||||
expect(attrs).toHaveProperty('pageId');
|
expect(attrs).toHaveProperty('pageId');
|
||||||
|
expect(attrs).toHaveProperty('slugId');
|
||||||
|
expect(attrs).toHaveProperty('spaceSlug');
|
||||||
expect(attrs).toHaveProperty('title');
|
expect(attrs).toHaveProperty('title');
|
||||||
expect(attrs).toHaveProperty('alias');
|
expect(attrs).toHaveProperty('alias');
|
||||||
expect(attrs.pageId.default).toBeNull();
|
expect(attrs.pageId.default).toBeNull();
|
||||||
|
expect(attrs.slugId.default).toBeNull();
|
||||||
|
expect(attrs.spaceSlug.default).toBeNull();
|
||||||
expect(attrs.alias.default).toBeNull();
|
expect(attrs.alias.default).toBeNull();
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,147 +1,37 @@
|
||||||
import {
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
|
||||||
Node,
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
nodeInputRule,
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
type NodeViewRendererProps,
|
|
||||||
} from '@tiptap/core';
|
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
||||||
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
|
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { WikilinkNode, type WikilinkAttrs } from '@docmost/editor-ext';
|
||||||
|
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
||||||
import { renderWikilinkSuggestion } from './wikilink-suggestion';
|
import { renderWikilinkSuggestion } from './wikilink-suggestion';
|
||||||
|
|
||||||
/**
|
export type { WikilinkAttrs };
|
||||||
* Wikilink Tiptap extension (R3.2).
|
|
||||||
*
|
|
||||||
* Implements the Obsidian-style [[Page Title]] and [[Page Title|alias]] syntax.
|
|
||||||
*
|
|
||||||
* Node attrs:
|
|
||||||
* - pageId : resolved UUID of the target page (null when unresolved)
|
|
||||||
* - title : canonical title of the target page
|
|
||||||
* - alias : optional display alias (text shown in editor)
|
|
||||||
*
|
|
||||||
* Rendering:
|
|
||||||
* - React NodeView: a styled link chip.
|
|
||||||
* - Unresolved (pageId === null): applies 'broken-wikilink' class (red / italic).
|
|
||||||
*
|
|
||||||
* Input rule:
|
|
||||||
* - Typing [[ opens the suggestion popup (reuses Tiptap Suggestion).
|
|
||||||
* - Pressing Esc or selecting a page closes the popup and inserts the node.
|
|
||||||
*
|
|
||||||
* The suggestion popup searches pages via `GET /api/search/suggestions?q=...`
|
|
||||||
* (same endpoint used by the native @mention system).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface WikilinkAttrs {
|
|
||||||
pageId: string | null;
|
|
||||||
title: string;
|
|
||||||
alias: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WIKILINK_INPUT_REGEX = /\[\[$/;
|
|
||||||
const WIKILINK_PARSE_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
|
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
wikilink: {
|
|
||||||
/**
|
|
||||||
* Insert a wikilink node at the current cursor position.
|
|
||||||
*/
|
|
||||||
insertWikilink: (attrs: WikilinkAttrs) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wikilink node itself.
|
* Wikilink client extension.
|
||||||
*
|
*
|
||||||
* It is `inline` and `atom` (cannot be entered — the cursor moves around it).
|
* Extends the shared @docmost/editor-ext WikilinkNode (registered on the
|
||||||
* This mirrors how Docmost handles mentions.
|
* Hocuspocus server too — without that, collab strips unknown nodes) to add:
|
||||||
|
* - a React NodeView (rendered chip + click navigation)
|
||||||
|
* - a Suggestion plugin triggered by [[
|
||||||
|
*
|
||||||
|
* Navigation uses buildPageUrl(spaceSlug, slugId, title) so links land on the
|
||||||
|
* real /s/<space>/p/<slug-id> route. spaceSlug/slugId are written into node
|
||||||
|
* attrs at insert time (resolved from the search suggestion).
|
||||||
*/
|
*/
|
||||||
export const WikilinkExtension = Node.create<{
|
export const WikilinkExtension = WikilinkNode.extend<{
|
||||||
suggestion: Partial<SuggestionOptions>;
|
suggestion: Partial<SuggestionOptions>;
|
||||||
}>({
|
}>({
|
||||||
name: 'wikilink',
|
addOptions() {
|
||||||
|
return { suggestion: {} };
|
||||||
group: 'inline',
|
|
||||||
inline: true,
|
|
||||||
atom: true,
|
|
||||||
selectable: true,
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
pageId: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (el) => el.getAttribute('data-page-id'),
|
|
||||||
renderHTML: (attrs) =>
|
|
||||||
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: '',
|
|
||||||
parseHTML: (el) => el.getAttribute('data-title') ?? el.textContent,
|
|
||||||
renderHTML: (attrs) => ({ 'data-title': attrs.title }),
|
|
||||||
},
|
|
||||||
alias: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (el) => el.getAttribute('data-alias') ?? null,
|
|
||||||
renderHTML: (attrs) =>
|
|
||||||
attrs.alias ? { 'data-alias': attrs.alias } : {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: 'span[data-wikilink]',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes, node }) {
|
|
||||||
const display = node.attrs.alias ?? node.attrs.title ?? '?';
|
|
||||||
const isBroken = !node.attrs.pageId;
|
|
||||||
return [
|
|
||||||
'span',
|
|
||||||
{
|
|
||||||
'data-wikilink': 'true',
|
|
||||||
...HTMLAttributes,
|
|
||||||
class: isBroken ? 'wikilink wikilink--broken' : 'wikilink',
|
|
||||||
},
|
|
||||||
`[[${display}]]`,
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(WikilinkNodeView);
|
return ReactNodeViewRenderer(WikilinkNodeView);
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
insertWikilink:
|
|
||||||
(attrs: WikilinkAttrs) =>
|
|
||||||
({ commands }) => {
|
|
||||||
return commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addInputRules() {
|
|
||||||
return [
|
|
||||||
// Input rule fires when the user types [[
|
|
||||||
// The rule itself doesn't insert a node — it triggers the suggestion popup.
|
|
||||||
// We use a nodeInputRule with a regex that won't consume anything so the
|
|
||||||
// Suggestion plugin can take over after detecting the [[ trigger.
|
|
||||||
// This is intentionally a no-op rule; the real work is in addProseMirrorPlugins.
|
|
||||||
nodeInputRule({
|
|
||||||
find: /\[\[Page Title\]\]$/,
|
|
||||||
type: this.type,
|
|
||||||
getAttributes: () => ({ pageId: null, title: 'Page Title', alias: null }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
Suggestion({
|
Suggestion({
|
||||||
|
|
@ -151,60 +41,42 @@ export const WikilinkExtension = Node.create<{
|
||||||
startOfLine: false,
|
startOfLine: false,
|
||||||
pluginKey: new PluginKey('wikilink-suggestion'),
|
pluginKey: new PluginKey('wikilink-suggestion'),
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }) => {
|
||||||
// Delete the trigger text and insert the node
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange(range)
|
.deleteRange(range)
|
||||||
.insertWikilink({
|
.insertWikilink({
|
||||||
pageId: props.pageId ?? null,
|
pageId: props.pageId ?? null,
|
||||||
|
slugId: props.slugId ?? null,
|
||||||
|
spaceSlug: props.spaceSlug ?? null,
|
||||||
title: props.title,
|
title: props.title,
|
||||||
alias: props.alias ?? null,
|
alias: props.alias ?? null,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
allow: ({ editor, range }) => {
|
allow: ({ editor }) => {
|
||||||
// Only trigger when not inside a code block / code mark
|
|
||||||
const { $from } = editor.state.selection;
|
const { $from } = editor.state.selection;
|
||||||
const parent = $from.parent;
|
|
||||||
return (
|
return (
|
||||||
parent.type.name !== 'codeBlock' &&
|
$from.parent.type.name !== 'codeBlock' && !editor.isActive('code')
|
||||||
!editor.isActive('code')
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
...this.options.suggestion,
|
...this.options.suggestion,
|
||||||
// The render function is provided by the suggestion module and
|
|
||||||
// rendered via renderWikilinkSuggestion below.
|
|
||||||
render: renderWikilinkSuggestion,
|
render: renderWikilinkSuggestion,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// React NodeView component (defined in the same file to keep the module
|
|
||||||
// self-contained — it does NOT import from the React component world at
|
|
||||||
// parse time so SSR / unit tests remain safe).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { NodeViewWrapper } from '@tiptap/react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
|
||||||
|
|
||||||
function WikilinkNodeView({ node }: NodeViewProps) {
|
function WikilinkNodeView({ node }: NodeViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pageId, title, alias } = node.attrs as WikilinkAttrs;
|
const { pageId, slugId, spaceSlug, title, alias } =
|
||||||
|
node.attrs as WikilinkAttrs;
|
||||||
const display = alias ?? title ?? '?';
|
const display = alias ?? title ?? '?';
|
||||||
const isBroken = !pageId;
|
const isBroken = !pageId || !slugId || !spaceSlug;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (pageId) {
|
if (!isBroken && slugId && spaceSlug) {
|
||||||
// Navigate using the same slug-based URL pattern Docmost uses.
|
navigate(buildPageUrl(spaceSlug, slugId, title));
|
||||||
// The actual path is <spaceSlug>/page/<slugId> — since we only have
|
|
||||||
// the UUID here, we navigate to a lookup route that redirects.
|
|
||||||
// As a fallback, we navigate to a search URL.
|
|
||||||
navigate(`/page/${pageId}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -214,13 +86,17 @@ function WikilinkNodeView({ node }: NodeViewProps) {
|
||||||
data-testid={`wikilink-${pageId ?? 'broken'}`}
|
data-testid={`wikilink-${pageId ?? 'broken'}`}
|
||||||
data-wikilink="true"
|
data-wikilink="true"
|
||||||
data-page-id={pageId ?? undefined}
|
data-page-id={pageId ?? undefined}
|
||||||
|
data-slug-id={slugId ?? undefined}
|
||||||
|
data-space-slug={spaceSlug ?? undefined}
|
||||||
data-title={title}
|
data-title={title}
|
||||||
data-alias={alias ?? undefined}
|
data-alias={alias ?? undefined}
|
||||||
className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'}
|
className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
style={{
|
style={{
|
||||||
cursor: isBroken ? 'not-allowed' : 'pointer',
|
cursor: isBroken ? 'not-allowed' : 'pointer',
|
||||||
color: isBroken ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-blue-6)',
|
color: isBroken
|
||||||
|
? 'var(--mantine-color-red-6)'
|
||||||
|
: 'var(--mantine-color-blue-6)',
|
||||||
fontStyle: isBroken ? 'italic' : 'normal',
|
fontStyle: isBroken ? 'italic' : 'normal',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import classes from './wikilink-list.module.css';
|
||||||
|
|
||||||
export interface WikilinkSuggestionItem {
|
export interface WikilinkSuggestionItem {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
slugId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
spaceName: string | null;
|
spaceName: string | null;
|
||||||
|
|
@ -60,6 +61,7 @@ const WikilinkList = forwardRef<any, WikilinkListProps>((props, ref) => {
|
||||||
|
|
||||||
const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({
|
const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({
|
||||||
pageId: p.id,
|
pageId: p.id,
|
||||||
|
slugId: p.slugId ?? null,
|
||||||
title: p.title ?? 'Untitled',
|
title: p.title ?? 'Untitled',
|
||||||
icon: p.icon ?? null,
|
icon: p.icon ?? null,
|
||||||
spaceName: p.space?.name ?? null,
|
spaceName: p.space?.name ?? null,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue