From 9a9769334e5a18f2a872264b43b6027ecf99f33e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Feb 2026 19:50:23 +0100 Subject: [PATCH] feat: proper wiki-links as custom BlockNote inline content with suggestion menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom 'wikilink' inline content type via createReactInlineContentSpec - Extended BlockNote schema with wikilink spec - Suggestion menu triggered by [[ showing all vault entries - Markdown round-trip: [[target]] → placeholder tokens → wikilink inline content - Click handler on .wikilink elements for navigation - Removed old hack (regex preprocessing to wikilink.internal URLs + DOM click interception on tags) - Added .wikilink CSS styles - Passed entries prop from App.tsx to Editor for suggestion menu --- src/App.tsx | 1 + src/components/Editor.css | 16 ++++ src/components/Editor.tsx | 175 +++++++++++++++++++++++++++++--------- 3 files changed, 154 insertions(+), 38 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d0684d98..15a6bc59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -543,6 +543,7 @@ function App() { void onCloseTab: (path: string) => void onNavigateWikilink: (target: string) => void @@ -20,6 +23,39 @@ interface EditorProps { isModified?: (path: string) => boolean } +// --- Custom Inline Content: WikiLink --- + +const WikiLink = createReactInlineContentSpec( + { + type: "wikilink" as const, + propSchema: { + target: { default: "" }, + }, + content: "none", + }, + { + render: (props) => ( + + {props.inlineContent.props.target} + + ), + } +) + +// --- Schema with wikilink --- + +const schema = BlockNoteSchema.create({ + inlineContentSpecs: { + ...defaultInlineContentSpecs, + wikilink: WikiLink, + }, +}) + +type EditorType = typeof schema.BlockNoteEditorType + /** Strip YAML frontmatter from markdown, returning [frontmatter, body] */ function splitFrontmatter(content: string): [string, string] { if (!content.startsWith('---')) return ['', content] @@ -30,6 +66,62 @@ function splitFrontmatter(content: string): [string, string] { return [content.slice(0, to), content.slice(to)] } +// Wikilink placeholder tokens for markdown round-trip +const WL_START = '\u2039WIKILINK:' +const WL_END = '\u203A' +const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g') + +/** Pre-process markdown: replace [[target]] with placeholder tokens */ +function preProcessWikilinks(md: string): string { + return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`) +} + +/** Walk blocks and replace placeholder text with wikilink inline content */ +function injectWikilinks(blocks: any[]): any[] { + return blocks.map(block => { + if (block.content && Array.isArray(block.content)) { + block.content = expandWikilinksInContent(block.content) + } + if (block.children && Array.isArray(block.children)) { + block.children = injectWikilinks(block.children) + } + return block + }) +} + +function expandWikilinksInContent(content: any[]): any[] { + const result: any[] = [] + for (const item of content) { + if (item.type === 'text' && typeof item.text === 'string' && item.text.includes(WL_START)) { + // Split this text node around wikilink placeholders + const text = item.text as string + let lastIndex = 0 + WL_RE.lastIndex = 0 + let match + while ((match = WL_RE.exec(text)) !== null) { + // Text before this match + if (match.index > lastIndex) { + result.push({ ...item, text: text.slice(lastIndex, match.index) }) + } + // The wikilink + result.push({ + type: 'wikilink', + props: { target: match[1] }, + content: undefined, + }) + lastIndex = match.index + match[0].length + } + // Text after last match + if (lastIndex < text.length) { + result.push({ ...item, text: text.slice(lastIndex) }) + } + } else { + result.push(item) + } + } + return result +} + function DiffView({ diff }: { diff: string }) { if (!diff) { return ( @@ -67,61 +159,61 @@ function DiffView({ diff }: { diff: string }) { } /** Inner component that creates/manages BlockNote for a single tab */ -function BlockNoteTab({ content, onNavigateWikilink }: { content: string; onNavigateWikilink: (target: string) => void }) { +function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: string; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) { const [, body] = useMemo(() => splitFrontmatter(content), [content]) const navigateRef = useRef(onNavigateWikilink) navigateRef.current = onNavigateWikilink - // Extract wiki-link targets from the raw markdown - const wikiTargets = useMemo(() => { - const targets = new Set() - const re = /\[\[([^\]]+)\]\]/g - let m - while ((m = re.exec(body))) targets.add(m[1]) - return targets - }, [body]) + const editor = useCreateBlockNote({ schema }) - const editor = useCreateBlockNote({}) - - // Load markdown content into editor + // Load markdown content into editor, converting [[target]] to wikilink inline content useEffect(() => { async function load() { - // Convert [[target]] wiki-links to markdown links — BlockNote renders them as underlined text - const preprocessed = body.replace(/\[\[([^\]]+)\]\]/g, (_match, target) => `[${target}](https://wikilink.internal/${encodeURIComponent(target)})`) + const preprocessed = preProcessWikilinks(body) const blocks = await editor.tryParseMarkdownToBlocks(preprocessed) - editor.replaceBlocks(editor.document, blocks) + const withWikilinks = injectWikilinks(blocks) + editor.replaceBlocks(editor.document, withWikilinks) } load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [body]) - // Intercept all clicks in the editor — check if target is a wikilink anchor + // Click handler for wikilinks useEffect(() => { - const WIKILINK_PREFIX = 'https://wikilink.internal/' - - const handler = (e: MouseEvent) => { - // Walk up from click target to find an with wikilink href - let el = e.target as HTMLElement | null - while (el && el.tagName !== 'A') el = el.parentElement - if (!el) return - const href = (el as HTMLAnchorElement).getAttribute('href') || '' - if (href.startsWith(WIKILINK_PREFIX)) { - e.preventDefault() - e.stopPropagation() - e.stopImmediatePropagation() - const target = decodeURIComponent(href.replace(WIKILINK_PREFIX, '')) - navigateRef.current(target) - return false - } - } - - // Capture phase on the container const container = document.querySelector('.editor__blocknote-container') if (!container) return + const handler = (e: MouseEvent) => { + const wikilink = (e.target as HTMLElement).closest('.wikilink') + if (wikilink) { + e.preventDefault() + e.stopPropagation() + const target = (wikilink as HTMLElement).dataset.target + if (target) navigateRef.current(target) + } + } container.addEventListener('click', handler as EventListener, true) return () => container.removeEventListener('click', handler as EventListener, true) }, [editor]) + // Suggestion menu items for [[ trigger + const getWikilinkItems = useCallback(async (query: string) => { + const items = entries.map(entry => ({ + title: entry.title, + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'wikilink' as const, + props: { target: entry.title }, + }, + " ", + ]) + }, + aliases: [entry.filename.replace(/\.md$/, ''), ...entry.aliases], + group: entry.isA || 'Note', + })) + return filterSuggestionItems(items, query) + }, [entries, editor]) + const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light' return ( @@ -129,12 +221,18 @@ function BlockNoteTab({ content, onNavigateWikilink }: { content: string; onNavi + > + {/* Wikilink suggestion menu triggered by [[ */} + + ) } -export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigateWikilink, onLoadDiff, isModified }: EditorProps) { +export function Editor({ tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onNavigateWikilink, onLoadDiff, isModified }: EditorProps) { const [diffMode, setDiffMode] = useState(false) const [diffContent, setDiffContent] = useState(null) const [diffLoading, setDiffLoading] = useState(false) @@ -221,6 +319,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigat )