mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
adding backlinks
This commit is contained in:
parent
0c820642e2
commit
174dcaf3ee
5 changed files with 464 additions and 5 deletions
|
|
@ -53,6 +53,7 @@ import {
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||||
|
|
||||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||||
type RunEventType = z.infer<typeof RunEvent>
|
type RunEventType = z.infer<typeof RunEvent>
|
||||||
|
|
@ -200,6 +201,12 @@ function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||||
return sortNodes(roots)
|
return sortNodes(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collectDirPaths = (nodes: TreeNode[]): string[] =>
|
||||||
|
nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : [])
|
||||||
|
|
||||||
|
const collectFilePaths = (nodes: TreeNode[]): string[] =>
|
||||||
|
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
|
||||||
|
|
||||||
// Sample chat history (will be replaced with real data later)
|
// Sample chat history (will be replaced with real data later)
|
||||||
const chatHistory = [
|
const chatHistory = [
|
||||||
{
|
{
|
||||||
|
|
@ -229,6 +236,7 @@ function App() {
|
||||||
const [editorContent, setEditorContent] = useState<string>('')
|
const [editorContent, setEditorContent] = useState<string>('')
|
||||||
const [tree, setTree] = useState<TreeNode[]>([])
|
const [tree, setTree] = useState<TreeNode[]>([])
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||||
|
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||||
|
|
||||||
// Auto-save state
|
// Auto-save state
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
@ -305,6 +313,16 @@ function App() {
|
||||||
})()
|
})()
|
||||||
}, [selectedPath])
|
}, [selectedPath])
|
||||||
|
|
||||||
|
// Track recently opened markdown files for wiki links
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPath || !selectedPath.endsWith('.md')) return
|
||||||
|
const wikiPath = stripKnowledgePrefix(selectedPath)
|
||||||
|
setRecentWikiFiles((prev) => {
|
||||||
|
const next = [wikiPath, ...prev.filter((path) => path !== wikiPath)]
|
||||||
|
return next.slice(0, 50)
|
||||||
|
})
|
||||||
|
}, [selectedPath])
|
||||||
|
|
||||||
// Auto-save when content changes
|
// Auto-save when content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPath || !selectedPath.endsWith('.md')) return
|
if (!selectedPath || !selectedPath.endsWith('.md')) return
|
||||||
|
|
@ -546,8 +564,10 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Knowledge quick actions
|
// Knowledge quick actions
|
||||||
const collectDirPaths = (nodes: TreeNode[]): string[] =>
|
const knowledgeFiles = React.useMemo(() => {
|
||||||
nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : [])
|
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
|
||||||
|
return Array.from(new Set(files.map(stripKnowledgePrefix)))
|
||||||
|
}, [tree])
|
||||||
|
|
||||||
// Get workspace root for full paths
|
// Get workspace root for full paths
|
||||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||||
|
|
@ -615,6 +635,42 @@ function App() {
|
||||||
},
|
},
|
||||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
|
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
|
||||||
|
|
||||||
|
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||||
|
const resolvedPath = toKnowledgePath(wikiPath)
|
||||||
|
if (!resolvedPath) return null
|
||||||
|
try {
|
||||||
|
const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath })
|
||||||
|
if (!exists.exists) {
|
||||||
|
const title = wikiLabel(wikiPath) || 'New Note'
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: resolvedPath,
|
||||||
|
data: `# ${title}\n\n`,
|
||||||
|
opts: { encoding: 'utf8', mkdirp: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resolvedPath
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ensure wiki link target:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openWikiLink = useCallback(async (wikiPath: string) => {
|
||||||
|
const resolvedPath = await ensureWikiFile(wikiPath)
|
||||||
|
if (resolvedPath) {
|
||||||
|
setSelectedPath(resolvedPath)
|
||||||
|
}
|
||||||
|
}, [ensureWikiFile, setSelectedPath])
|
||||||
|
|
||||||
|
const wikiLinkConfig = React.useMemo(() => ({
|
||||||
|
files: knowledgeFiles,
|
||||||
|
recent: recentWikiFiles,
|
||||||
|
onOpen: (path: string) => {
|
||||||
|
void openWikiLink(path)
|
||||||
|
},
|
||||||
|
onCreate: (path: string) => ensureWikiFile(path),
|
||||||
|
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem) => {
|
const renderConversationItem = (item: ConversationItem) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -771,6 +827,7 @@ function App() {
|
||||||
content={editorContent}
|
content={editorContent}
|
||||||
onChange={setEditorContent}
|
onChange={setEditorContent}
|
||||||
placeholder="Start writing..."
|
placeholder="Start writing..."
|
||||||
|
wikiLinks={wikiLinkConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,43 @@ import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import { Markdown } from 'tiptap-markdown'
|
import { Markdown } from 'tiptap-markdown'
|
||||||
import { useEffect, useCallback, useRef } from 'react'
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { EditorToolbar } from './editor-toolbar'
|
import { EditorToolbar } from './editor-toolbar'
|
||||||
|
import { WikiLink } from '@/extensions/wiki-link'
|
||||||
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
|
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
|
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import '@/styles/editor.css'
|
import '@/styles/editor.css'
|
||||||
|
|
||||||
|
type WikiLinkConfig = {
|
||||||
|
files: string[]
|
||||||
|
recent: string[]
|
||||||
|
onOpen: (path: string) => void
|
||||||
|
onCreate: (path: string) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
content: string
|
content: string
|
||||||
onChange: (markdown: string) => void
|
onChange: (markdown: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
wikiLinks?: WikiLinkConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownEditor({ content, onChange, placeholder = 'Start writing...' }: MarkdownEditorProps) {
|
type WikiLinkMatch = {
|
||||||
|
range: { from: number; to: number }
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Start writing...',
|
||||||
|
wikiLinks,
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
const isInternalUpdate = useRef(false)
|
const isInternalUpdate = useRef(false)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
||||||
|
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
|
|
@ -30,6 +55,13 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
WikiLink.configure({
|
||||||
|
onCreate: wikiLinks?.onCreate
|
||||||
|
? (path) => {
|
||||||
|
void wikiLinks.onCreate(path)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
|
@ -50,9 +82,94 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'prose prose-sm max-w-none focus:outline-none',
|
class: 'prose prose-sm max-w-none focus:outline-none',
|
||||||
},
|
},
|
||||||
|
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||||
|
if (node.type.name === 'wikiLink') {
|
||||||
|
event.preventDefault()
|
||||||
|
wikiLinks?.onOpen?.(node.attrs.path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const orderedFiles = useMemo(() => {
|
||||||
|
if (!wikiLinks) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const ordered: string[] = []
|
||||||
|
|
||||||
|
const addPath = (path: string) => {
|
||||||
|
const normalized = normalizeWikiPath(path)
|
||||||
|
if (!normalized || seen.has(normalized)) return
|
||||||
|
seen.add(normalized)
|
||||||
|
ordered.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiLinks.recent.forEach(addPath)
|
||||||
|
wikiLinks.files.forEach(addPath)
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
}, [wikiLinks])
|
||||||
|
|
||||||
|
const updateWikiLinkState = useCallback(() => {
|
||||||
|
if (!editor || !wikiLinks) return
|
||||||
|
const { selection } = editor.state
|
||||||
|
if (!selection.empty) {
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
if ($from.parent.type.spec.code) {
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($from.marks().some((mark) => mark.type.spec.code)) {
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||||
|
const textBefore = text.slice(0, $from.parentOffset)
|
||||||
|
const triggerIndex = textBefore.lastIndexOf('[[')
|
||||||
|
if (triggerIndex === -1 || textBefore.indexOf(']]', triggerIndex) !== -1) {
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchText = textBefore.slice(triggerIndex)
|
||||||
|
const query = matchText.slice(2)
|
||||||
|
const range = { from: selection.from - matchText.length, to: selection.from }
|
||||||
|
setActiveWikiLink({ range, query })
|
||||||
|
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) {
|
||||||
|
setAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = editor.view.coordsAtPos(selection.from)
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
setAnchorPosition({
|
||||||
|
left: coords.left - wrapperRect.left,
|
||||||
|
top: coords.bottom - wrapperRect.top,
|
||||||
|
})
|
||||||
|
}, [editor, wikiLinks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !wikiLinks) return
|
||||||
|
editor.on('update', updateWikiLinkState)
|
||||||
|
editor.on('selectionUpdate', updateWikiLinkState)
|
||||||
|
return () => {
|
||||||
|
editor.off('update', updateWikiLinkState)
|
||||||
|
editor.off('selectionUpdate', updateWikiLinkState)
|
||||||
|
}
|
||||||
|
}, [editor, wikiLinks, updateWikiLinkState])
|
||||||
|
|
||||||
// Update editor content when prop changes (e.g., file selection changes)
|
// Update editor content when prop changes (e.g., file selection changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && content !== undefined) {
|
if (editor && content !== undefined) {
|
||||||
|
|
@ -65,6 +182,47 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing
|
||||||
}
|
}
|
||||||
}, [editor, content])
|
}, [editor, content])
|
||||||
|
|
||||||
|
const normalizedQuery = normalizeWikiPath(activeWikiLink?.query ?? '').toLowerCase()
|
||||||
|
const filteredFiles = useMemo(() => {
|
||||||
|
if (!activeWikiLink) return []
|
||||||
|
if (!normalizedQuery) return orderedFiles
|
||||||
|
return orderedFiles.filter((path) => path.toLowerCase().includes(normalizedQuery))
|
||||||
|
}, [activeWikiLink, normalizedQuery, orderedFiles])
|
||||||
|
|
||||||
|
const visibleFiles = filteredFiles.slice(0, 12)
|
||||||
|
const rawCreateCandidate = activeWikiLink ? normalizeWikiPath(activeWikiLink.query) : ''
|
||||||
|
const createCandidate = rawCreateCandidate && !rawCreateCandidate.endsWith('/')
|
||||||
|
? ensureMarkdownExtension(rawCreateCandidate)
|
||||||
|
: ''
|
||||||
|
const canCreate = Boolean(
|
||||||
|
createCandidate
|
||||||
|
&& !orderedFiles.some((path) => path.toLowerCase() === createCandidate.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectWikiLink = useCallback((path: string) => {
|
||||||
|
if (!editor || !activeWikiLink) return
|
||||||
|
const normalized = normalizeWikiPath(path)
|
||||||
|
if (!normalized) return
|
||||||
|
const finalPath = ensureMarkdownExtension(normalized)
|
||||||
|
void wikiLinks?.onCreate?.(finalPath)
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(
|
||||||
|
{ from: activeWikiLink.range.from, to: activeWikiLink.range.to },
|
||||||
|
{ type: 'wikiLink', attrs: { path: finalPath } }
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
}, [editor, activeWikiLink, wikiLinks])
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
updateWikiLinkState()
|
||||||
|
}, [updateWikiLinkState])
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||||
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
||||||
|
|
@ -73,11 +231,66 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
||||||
<EditorToolbar editor={editor} />
|
<EditorToolbar editor={editor} />
|
||||||
<div className="editor-content-wrapper">
|
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
{wikiLinks ? (
|
||||||
|
<Popover
|
||||||
|
open={showWikiPopover}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveWikiLink(null)
|
||||||
|
setAnchorPosition(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<span
|
||||||
|
className="wiki-link-anchor"
|
||||||
|
style={
|
||||||
|
anchorPosition
|
||||||
|
? { left: anchorPosition.left, top: anchorPosition.top }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-72 p-1"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandList>
|
||||||
|
{canCreate ? (
|
||||||
|
<CommandItem
|
||||||
|
value={createCandidate}
|
||||||
|
onSelect={() => handleSelectWikiLink(createCandidate)}
|
||||||
|
>
|
||||||
|
Create "{wikiLabel(createCandidate) || createCandidate}"
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
{visibleFiles.map((path) => (
|
||||||
|
<CommandItem
|
||||||
|
key={path}
|
||||||
|
value={path}
|
||||||
|
onSelect={() => handleSelectWikiLink(path)}
|
||||||
|
>
|
||||||
|
{wikiLabel(path)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{visibleFiles.length === 0 && !canCreate ? (
|
||||||
|
<CommandEmpty>No matches found.</CommandEmpty>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
154
apps/x/apps/renderer/src/extensions/wiki-link.ts
Normal file
154
apps/x/apps/renderer/src/extensions/wiki-link.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/react'
|
||||||
|
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
|
||||||
|
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||||
|
|
||||||
|
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
|
||||||
|
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
|
||||||
|
|
||||||
|
type WikiLinkOptions = {
|
||||||
|
onCreate?: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInsideCode = (textNode: Text) =>
|
||||||
|
Boolean(textNode.parentElement?.closest('code, pre, a, wiki-link'))
|
||||||
|
|
||||||
|
const replaceWikiLinksInTextNode = (textNode: Text) => {
|
||||||
|
const text = textNode.nodeValue
|
||||||
|
if (!text || !text.includes('[[')) return
|
||||||
|
if (isInsideCode(textNode)) return
|
||||||
|
|
||||||
|
const matches = [...text.matchAll(wikiLinkTokenRegex)]
|
||||||
|
if (!matches.length) return
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let lastIndex = 0
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const matchIndex = match.index ?? 0
|
||||||
|
const matchText = match[0] ?? ''
|
||||||
|
const rawPath = match[1]?.trim() ?? ''
|
||||||
|
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||||
|
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
|
||||||
|
|
||||||
|
if (matchIndex > lastIndex) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidPath) {
|
||||||
|
const el = document.createElement('wiki-link')
|
||||||
|
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
|
||||||
|
fragment.appendChild(el)
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(document.createTextNode(matchText))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = matchIndex + matchText.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
textNode.parentNode?.replaceChild(fragment, textNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceWikiLinksInTextNodes = (root: HTMLElement) => {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
const textNodes: Text[] = []
|
||||||
|
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
textNodes.push(walker.currentNode as Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
textNodes.forEach(replaceWikiLinksInTextNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WikiLink = Node.create<WikiLinkOptions>({
|
||||||
|
name: 'wikiLink',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
onCreate: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
path: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'wiki-link[data-path]',
|
||||||
|
getAttrs: (element) => ({
|
||||||
|
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'a[data-type="wiki-link"]',
|
||||||
|
getAttrs: (element) => ({
|
||||||
|
path: (element as HTMLElement).getAttribute('data-path') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
const label = wikiLabel(node.attrs.path) || node.attrs.path
|
||||||
|
return [
|
||||||
|
'a',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-type': 'wiki-link',
|
||||||
|
'data-path': node.attrs.path,
|
||||||
|
'href': '#',
|
||||||
|
'class': 'wiki-link',
|
||||||
|
'aria-label': node.attrs.path,
|
||||||
|
}),
|
||||||
|
label,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
markdown: {
|
||||||
|
serialize(state, node) {
|
||||||
|
const path = node.attrs.path ?? ''
|
||||||
|
state.write(`[[${path}]]`)
|
||||||
|
},
|
||||||
|
parse: {
|
||||||
|
updateDOM(element) {
|
||||||
|
replaceWikiLinksInTextNodes(element)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const onCreate = this.options.onCreate
|
||||||
|
const rules = [
|
||||||
|
new InputRule(wikiLinkInputRegex, (state, match, start, end) => {
|
||||||
|
const rawPath = match[1]?.trim()
|
||||||
|
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
|
||||||
|
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
|
||||||
|
if (state.selection.$from.parent.type.spec.code) return null
|
||||||
|
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
|
||||||
|
|
||||||
|
const finalPath = ensureMarkdownExtension(normalizedPath)
|
||||||
|
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
|
||||||
|
onCreate?.(finalPath)
|
||||||
|
return tr
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return [inputRules({ rules })]
|
||||||
|
},
|
||||||
|
})
|
||||||
27
apps/x/apps/renderer/src/lib/wiki-links.ts
Normal file
27
apps/x/apps/renderer/src/lib/wiki-links.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
const KNOWLEDGE_PREFIX = 'knowledge/'
|
||||||
|
|
||||||
|
export const stripKnowledgePrefix = (path: string) =>
|
||||||
|
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
|
||||||
|
|
||||||
|
export const normalizeWikiPath = (input: string) => {
|
||||||
|
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
|
||||||
|
return stripKnowledgePrefix(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureMarkdownExtension = (path: string) => {
|
||||||
|
const lastSegment = path.split('/').pop() ?? path
|
||||||
|
if (lastSegment.includes('.')) return path
|
||||||
|
return `${path}.md`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toKnowledgePath = (wikiPath: string) => {
|
||||||
|
const normalized = normalizeWikiPath(wikiPath)
|
||||||
|
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
|
||||||
|
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wikiLabel = (wikiPath: string) => {
|
||||||
|
const normalized = normalizeWikiPath(wikiPath)
|
||||||
|
const name = normalized.split('/').pop() || normalized
|
||||||
|
return name.replace(/\.md$/i, '')
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
|
|
@ -220,3 +221,10 @@
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wiki-link-anchor {
|
||||||
|
position: absolute;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue