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();
|
||||
});
|
||||
|
||||
it('has attrs: pageId, title, alias', () => {
|
||||
it('has attrs: pageId, slugId, spaceSlug, title, alias', () => {
|
||||
const editor = makeEditor();
|
||||
const nodeSpec = editor.schema.nodes.wikilink;
|
||||
const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>;
|
||||
expect(attrs).toHaveProperty('pageId');
|
||||
expect(attrs).toHaveProperty('slugId');
|
||||
expect(attrs).toHaveProperty('spaceSlug');
|
||||
expect(attrs).toHaveProperty('title');
|
||||
expect(attrs).toHaveProperty('alias');
|
||||
expect(attrs.pageId.default).toBeNull();
|
||||
expect(attrs.slugId.default).toBeNull();
|
||||
expect(attrs.spaceSlug.default).toBeNull();
|
||||
expect(attrs.alias.default).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,147 +1,37 @@
|
|||
import {
|
||||
Node,
|
||||
nodeInputRule,
|
||||
type NodeViewRendererProps,
|
||||
} from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type { WikilinkAttrs };
|
||||
|
||||
/**
|
||||
* The wikilink node itself.
|
||||
* Wikilink client extension.
|
||||
*
|
||||
* It is `inline` and `atom` (cannot be entered — the cursor moves around it).
|
||||
* This mirrors how Docmost handles mentions.
|
||||
* Extends the shared @docmost/editor-ext WikilinkNode (registered on the
|
||||
* 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>;
|
||||
}>({
|
||||
name: 'wikilink',
|
||||
|
||||
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}]]`,
|
||||
];
|
||||
addOptions() {
|
||||
return { suggestion: {} };
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
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() {
|
||||
return [
|
||||
Suggestion({
|
||||
|
|
@ -151,60 +41,42 @@ export const WikilinkExtension = Node.create<{
|
|||
startOfLine: false,
|
||||
pluginKey: new PluginKey('wikilink-suggestion'),
|
||||
command: ({ editor, range, props }) => {
|
||||
// Delete the trigger text and insert the node
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertWikilink({
|
||||
pageId: props.pageId ?? null,
|
||||
slugId: props.slugId ?? null,
|
||||
spaceSlug: props.spaceSlug ?? null,
|
||||
title: props.title,
|
||||
alias: props.alias ?? null,
|
||||
})
|
||||
.run();
|
||||
},
|
||||
allow: ({ editor, range }) => {
|
||||
// Only trigger when not inside a code block / code mark
|
||||
allow: ({ editor }) => {
|
||||
const { $from } = editor.state.selection;
|
||||
const parent = $from.parent;
|
||||
return (
|
||||
parent.type.name !== 'codeBlock' &&
|
||||
!editor.isActive('code')
|
||||
$from.parent.type.name !== 'codeBlock' && !editor.isActive('code')
|
||||
);
|
||||
},
|
||||
...this.options.suggestion,
|
||||
// The render function is provided by the suggestion module and
|
||||
// rendered via renderWikilinkSuggestion below.
|
||||
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) {
|
||||
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 isBroken = !pageId;
|
||||
const isBroken = !pageId || !slugId || !spaceSlug;
|
||||
|
||||
const handleClick = () => {
|
||||
if (pageId) {
|
||||
// Navigate using the same slug-based URL pattern Docmost uses.
|
||||
// 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}`);
|
||||
if (!isBroken && slugId && spaceSlug) {
|
||||
navigate(buildPageUrl(spaceSlug, slugId, title));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -214,13 +86,17 @@ function WikilinkNodeView({ node }: NodeViewProps) {
|
|||
data-testid={`wikilink-${pageId ?? 'broken'}`}
|
||||
data-wikilink="true"
|
||||
data-page-id={pageId ?? undefined}
|
||||
data-slug-id={slugId ?? undefined}
|
||||
data-space-slug={spaceSlug ?? undefined}
|
||||
data-title={title}
|
||||
data-alias={alias ?? undefined}
|
||||
className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
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',
|
||||
textDecoration: 'underline',
|
||||
userSelect: 'none',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import classes from './wikilink-list.module.css';
|
|||
|
||||
export interface WikilinkSuggestionItem {
|
||||
pageId: string;
|
||||
slugId: string | null;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
spaceName: string | null;
|
||||
|
|
@ -60,6 +61,7 @@ const WikilinkList = forwardRef<any, WikilinkListProps>((props, ref) => {
|
|||
|
||||
const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({
|
||||
pageId: p.id,
|
||||
slugId: p.slugId ?? null,
|
||||
title: p.title ?? 'Untitled',
|
||||
icon: p.icon ?? null,
|
||||
spaceName: p.space?.name ?? null,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue