2026-01-14 11:49:29 +05:30
|
|
|
import { useEditor, EditorContent, Extension } from '@tiptap/react'
|
|
|
|
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
|
|
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
2026-01-12 00:04:17 +05:30
|
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
|
|
|
import Link from '@tiptap/extension-link'
|
2026-01-21 13:23:33 +05:30
|
|
|
import Image from '@tiptap/extension-image'
|
2026-01-12 00:04:17 +05:30
|
|
|
import Placeholder from '@tiptap/extension-placeholder'
|
2026-01-14 11:49:29 +05:30
|
|
|
import TaskList from '@tiptap/extension-task-list'
|
|
|
|
|
import TaskItem from '@tiptap/extension-task-item'
|
2026-01-21 13:23:33 +05:30
|
|
|
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
2026-01-12 00:04:17 +05:30
|
|
|
import { Markdown } from 'tiptap-markdown'
|
2026-01-13 11:21:35 +05:30
|
|
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
2026-01-12 00:04:17 +05:30
|
|
|
import { EditorToolbar } from './editor-toolbar'
|
2026-01-13 11:21:35 +05:30
|
|
|
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'
|
2026-01-12 00:04:17 +05:30
|
|
|
import '@/styles/editor.css'
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
type WikiLinkConfig = {
|
|
|
|
|
files: string[]
|
|
|
|
|
recent: string[]
|
|
|
|
|
onOpen: (path: string) => void
|
|
|
|
|
onCreate: (path: string) => void | Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 00:04:17 +05:30
|
|
|
interface MarkdownEditorProps {
|
|
|
|
|
content: string
|
|
|
|
|
onChange: (markdown: string) => void
|
|
|
|
|
placeholder?: string
|
2026-01-13 11:21:35 +05:30
|
|
|
wikiLinks?: WikiLinkConfig
|
2026-01-21 13:23:33 +05:30
|
|
|
onImageUpload?: (file: File) => Promise<string | null>
|
2026-01-12 00:04:17 +05:30
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
type WikiLinkMatch = {
|
|
|
|
|
range: { from: number; to: number }
|
|
|
|
|
query: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 11:49:29 +05:30
|
|
|
type SelectionHighlightRange = { from: number; to: number } | null
|
|
|
|
|
|
|
|
|
|
// Plugin key for the selection highlight
|
|
|
|
|
const selectionHighlightKey = new PluginKey('selectionHighlight')
|
|
|
|
|
|
|
|
|
|
// Create the selection highlight extension
|
|
|
|
|
const createSelectionHighlightExtension = (getRange: () => SelectionHighlightRange) => {
|
|
|
|
|
return Extension.create({
|
|
|
|
|
name: 'selectionHighlight',
|
|
|
|
|
addProseMirrorPlugins() {
|
|
|
|
|
return [
|
|
|
|
|
new Plugin({
|
|
|
|
|
key: selectionHighlightKey,
|
|
|
|
|
props: {
|
|
|
|
|
decorations(state) {
|
|
|
|
|
const range = getRange()
|
|
|
|
|
if (!range) return DecorationSet.empty
|
|
|
|
|
|
|
|
|
|
const { from, to } = range
|
|
|
|
|
if (from >= to || from < 0 || to > state.doc.content.size) {
|
|
|
|
|
return DecorationSet.empty
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const decoration = Decoration.inline(from, to, {
|
|
|
|
|
class: 'selection-highlight',
|
|
|
|
|
})
|
|
|
|
|
return DecorationSet.create(state.doc, [decoration])
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
export function MarkdownEditor({
|
|
|
|
|
content,
|
|
|
|
|
onChange,
|
|
|
|
|
placeholder = 'Start writing...',
|
|
|
|
|
wikiLinks,
|
2026-01-21 13:23:33 +05:30
|
|
|
onImageUpload,
|
2026-01-13 11:21:35 +05:30
|
|
|
}: MarkdownEditorProps) {
|
2026-01-12 00:04:17 +05:30
|
|
|
const isInternalUpdate = useRef(false)
|
2026-01-13 11:21:35 +05:30
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
|
|
|
|
|
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
2026-01-14 11:49:29 +05:30
|
|
|
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
|
|
|
|
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
|
|
|
|
|
|
|
|
|
|
// Keep ref in sync with state for the plugin to access
|
|
|
|
|
selectionHighlightRef.current = selectionHighlight
|
|
|
|
|
|
|
|
|
|
// Memoize the selection highlight extension
|
|
|
|
|
const selectionHighlightExtension = useMemo(
|
|
|
|
|
() => createSelectionHighlightExtension(() => selectionHighlightRef.current),
|
|
|
|
|
[]
|
|
|
|
|
)
|
2026-01-12 00:04:17 +05:30
|
|
|
|
|
|
|
|
const editor = useEditor({
|
|
|
|
|
extensions: [
|
|
|
|
|
StarterKit.configure({
|
|
|
|
|
heading: {
|
|
|
|
|
levels: [1, 2, 3],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
Link.configure({
|
|
|
|
|
openOnClick: false,
|
|
|
|
|
HTMLAttributes: {
|
|
|
|
|
rel: 'noopener noreferrer',
|
|
|
|
|
target: '_blank',
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-01-21 13:23:33 +05:30
|
|
|
Image.configure({
|
|
|
|
|
inline: false,
|
|
|
|
|
allowBase64: true,
|
|
|
|
|
HTMLAttributes: {
|
|
|
|
|
class: 'editor-image',
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
ImageUploadPlaceholderExtension,
|
2026-01-13 11:21:35 +05:30
|
|
|
WikiLink.configure({
|
|
|
|
|
onCreate: wikiLinks?.onCreate
|
|
|
|
|
? (path) => {
|
|
|
|
|
void wikiLinks.onCreate(path)
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
}),
|
2026-01-14 11:49:29 +05:30
|
|
|
TaskList,
|
|
|
|
|
TaskItem.configure({
|
|
|
|
|
nested: true,
|
|
|
|
|
}),
|
2026-01-12 00:04:17 +05:30
|
|
|
Placeholder.configure({
|
|
|
|
|
placeholder,
|
|
|
|
|
}),
|
|
|
|
|
Markdown.configure({
|
|
|
|
|
html: false,
|
2026-01-12 12:11:08 +05:30
|
|
|
breaks: true,
|
2026-01-12 00:04:17 +05:30
|
|
|
transformCopiedText: true,
|
|
|
|
|
transformPastedText: true,
|
|
|
|
|
}),
|
2026-01-14 11:49:29 +05:30
|
|
|
selectionHighlightExtension,
|
2026-01-12 00:04:17 +05:30
|
|
|
],
|
|
|
|
|
content: '',
|
|
|
|
|
onUpdate: ({ editor }) => {
|
|
|
|
|
if (isInternalUpdate.current) return
|
2026-01-14 10:54:37 +05:30
|
|
|
const storage = editor.storage as unknown as Record<string, { getMarkdown?: () => string }>
|
|
|
|
|
const markdown = storage.markdown?.getMarkdown?.() ?? ''
|
2026-01-12 00:04:17 +05:30
|
|
|
onChange(markdown)
|
|
|
|
|
},
|
|
|
|
|
editorProps: {
|
|
|
|
|
attributes: {
|
|
|
|
|
class: 'prose prose-sm max-w-none focus:outline-none',
|
|
|
|
|
},
|
2026-01-13 11:21:35 +05:30
|
|
|
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
|
|
|
|
if (node.type.name === 'wikiLink') {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
wikiLinks?.onOpen?.(node.attrs.path)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
},
|
2026-01-12 00:04:17 +05:30
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
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])
|
|
|
|
|
|
2026-01-12 00:04:17 +05:30
|
|
|
// Update editor content when prop changes (e.g., file selection changes)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (editor && content !== undefined) {
|
2026-01-14 10:54:37 +05:30
|
|
|
const storage = editor.storage as unknown as Record<string, { getMarkdown?: () => string }>
|
|
|
|
|
const currentContent = storage.markdown?.getMarkdown?.() ?? ''
|
2026-01-12 00:04:17 +05:30
|
|
|
if (currentContent !== content) {
|
|
|
|
|
isInternalUpdate.current = true
|
|
|
|
|
editor.commands.setContent(content)
|
|
|
|
|
isInternalUpdate.current = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [editor, content])
|
|
|
|
|
|
2026-01-14 11:49:29 +05:30
|
|
|
// Force re-render decorations when selection highlight changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (editor) {
|
|
|
|
|
// Trigger a transaction to force decoration re-render
|
|
|
|
|
editor.view.dispatch(editor.state.tr)
|
|
|
|
|
}
|
|
|
|
|
}, [editor, selectionHighlight])
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
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])
|
|
|
|
|
|
2026-01-12 00:04:17 +05:30
|
|
|
// Handle keyboard shortcuts
|
|
|
|
|
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
|
|
|
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
// The parent component handles saving via onChange
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-01-13 11:21:35 +05:30
|
|
|
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
|
|
|
|
|
2026-01-21 13:23:33 +05:30
|
|
|
// Create image upload handler that shows placeholder
|
|
|
|
|
const handleImageUploadWithPlaceholder = useMemo(() => {
|
|
|
|
|
if (!editor || !onImageUpload) return undefined
|
|
|
|
|
return createImageUploadHandler(editor, onImageUpload)
|
|
|
|
|
}, [editor, onImageUpload])
|
|
|
|
|
|
2026-01-12 00:04:17 +05:30
|
|
|
return (
|
|
|
|
|
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
|
2026-01-21 13:23:33 +05:30
|
|
|
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} />
|
2026-01-13 11:21:35 +05:30
|
|
|
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
2026-01-12 23:46:20 +05:30
|
|
|
<EditorContent editor={editor} />
|
2026-01-13 11:21:35 +05:30
|
|
|
{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}
|
2026-01-12 23:46:20 +05:30
|
|
|
</div>
|
2026-01-12 00:04:17 +05:30
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|