import { Component, useEffect, useCallback, useMemo, useRef, useContext, useState, type ReactNode } from 'react' import { invoke } from '@tauri-apps/api/core' import { trackEvent } from '../lib/telemetry' import { useCreateBlockNote, SuggestionMenuController, BlockNoteViewRaw, ComponentsContext, DeleteLinkButton, EditLinkButton, LinkToolbar, LinkToolbarController, SideMenuController, useComponentsContext, useDictionary, type LinkToolbarProps, } from '@blocknote/react' import { components } from '@blocknote/mantine' import { MantineContext, MantineProvider } from '@mantine/core' import { Copy } from '@phosphor-icons/react' import { ExternalLink } from 'lucide-react' import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode' import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair' import { useEditorTheme } from '../hooks/useTheme' import { useImageDrop } from '../hooks/useImageDrop' import { useImageLightbox } from '../hooks/useImageLightbox' import { createTranslator, type AppLocale } from '../lib/i18n' import { isTauri } from '../mock-tauri' import { buildTypeEntryMap } from '../utils/typeColors' import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions' import { attachClickHandlers, enrichSuggestionItems, hasMultipleSuggestionWorkspaces } from '../utils/suggestionEnrichment' import { observeNativeTextAssistanceDisabled } from '../lib/nativeTextAssistance' import { getRuntimeStyleNonce } from '../lib/runtimeStyleNonce' import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu' import type { VaultEntry } from '../types' import { _wikilinkEntriesRef } from './editorSchema' import { handleEditorFileBlockClick, openEditorAttachmentOrUrl, } from './editorAttachmentActions' import { useBlockNoteSideMenuHoverGuard } from './blockNoteSideMenuHoverGuard' import { getTolariaSlashMenuItems } from './tolariaEditorFormattingConfig' import { TolariaFormattingToolbar, TolariaFormattingToolbarController, } from './tolariaEditorFormatting' import { TolariaSideMenu } from './tolariaBlockNoteSideMenu' import { useEditorLinkActivation } from './useEditorLinkActivation' import { findNearestTextCursorBlock } from './blockNoteCursorTarget' import { ImageLightbox } from './ImageLightbox' import { ActionTooltip } from './ui/action-tooltip' import { Button } from './ui/button' import { activatePlainTextPasteTarget, registerPlainTextPasteTarget, type PlainTextPasteTarget, } from '../utils/plainTextPaste' import { isRecoverableBlockNoteRenderError, markRecoveredBlockNoteRenderError, } from './blockNoteRenderRecovery' import { queueTitleHeadingCursorRepair, useEditorPasteHandler, } from './titleHeadingInteractions' const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 | | --- | --- | --- | | A | B | C | | D | E | F | ` const CONTAINER_CLICK_IGNORE_SELECTOR = [ '[contenteditable="true"]', 'button', 'input', 'select', 'textarea', '.bn-formatting-toolbar', '.bn-link-toolbar', '.bn-panel', '.bn-side-menu', '.bn-suggestion-menu', '.bn-grid-suggestion-menu', '.bn-form-popover', '[data-editor-code-copy]', '[role="menu"]', '[role="dialog"]', ].join(', ') const TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR = [ '[role="menu"]', '[role="dialog"]', 'button[aria-haspopup]', 'input', 'textarea', '[contenteditable="true"]', ].join(', ') const MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES = 1 type TestTableBlock = { type?: string content?: { type?: string; columnWidths?: Array } } type SuggestionAction = () => void type SuggestionItemWithClick = { onItemClick?: SuggestionAction } type BlockNoteRenderRecoveryState = { error: unknown recoveryKey: number retries: number } class BlockNoteRenderRecoveryBoundary extends Component<{ children: (recoveryKey: number) => ReactNode onRecover?: (attempt: number) => void }, BlockNoteRenderRecoveryState> { state: BlockNoteRenderRecoveryState = { error: null, recoveryKey: 0, retries: 0, } static getDerivedStateFromError(error: unknown): Partial { return { error } } componentDidCatch(error: unknown) { if (!isRecoverableBlockNoteRenderError(error)) return if (this.state.retries >= MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES) return const attempt = this.state.retries + 1 markRecoveredBlockNoteRenderError(error) trackEvent('editor_render_recovered', { reason: 'block_missing_id', attempt }) this.props.onRecover?.(attempt) this.setState(({ recoveryKey, retries }) => ({ error: null, recoveryKey: recoveryKey + 1, retries: retries + 1, })) } render() { if (this.state.error) { if ( !isRecoverableBlockNoteRenderError(this.state.error) || this.state.retries >= MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES ) { throw this.state.error } return null } return this.props.children(this.state.recoveryKey) } } function repairEditorDocumentForRenderRecovery(editor: ReturnType) { const current = editor.document const safeBlocks = repairMalformedEditorBlocks(current) if (safeBlocks === current) return try { editor.replaceBlocks(current, safeBlocks) } catch (error) { console.warn('[editor] Failed to repair BlockNote document before render recovery:', error) try { const markup = editor.blocksToHTMLLossy(safeBlocks) editor._tiptapEditor.commands.setContent(markup) } catch (fallbackError) { console.warn('[editor] Failed to apply repaired BlockNote document fallback:', fallbackError) } } } function isEditorReadyForSuggestionAction( editor: ReturnType, container: HTMLElement | null, ) { if (!container?.isConnected) return false const editorElement = editor.domElement if (!(editorElement instanceof HTMLElement)) return true return editorElement.isConnected } function runSuggestionActionSafely({ action, container, editor, }: { action: SuggestionAction container: HTMLElement | null editor: ReturnType }) { if (!isEditorReadyForSuggestionAction(editor, container)) return try { action() } catch (error) { console.warn('[editor] Ignored stale suggestion menu action:', error) } } function guardSuggestionMenuItems( items: T[], runEditorAction: (action: SuggestionAction) => void, ): T[] { return items.map((item) => { if (!item.onItemClick) return item const onItemClick = item.onItemClick return { ...item, onItemClick: () => runEditorAction(onItemClick), } }) } function SharedContextBlockNoteView(props: React.ComponentProps) { const { children, className, theme, ...rest } = props const mantineContext = useContext(MantineContext) const colorScheme = theme === 'dark' ? 'dark' : 'light' const view = ( {children} ) if (mantineContext) return view return ( undefined} > {view} ) } function shouldAllowToolbarMouseDown(target: HTMLElement) { return Boolean(target.closest(TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR)) } function handleToolbarMouseDownCapture( event: Pick, 'target' | 'preventDefault'>, ) { if (!(event.target instanceof HTMLElement) || shouldAllowToolbarMouseDown(event.target)) { return } event.preventDefault() } function TolariaOpenLinkButton({ url, vaultPath, }: Pick & { vaultPath?: string }) { const Components = useComponentsContext()! const dict = useDictionary() const handleOpen = useCallback(() => { openEditorAttachmentOrUrl({ url, vaultPath, source: 'link' }) }, [url, vaultPath]) return ( } /> ) } function TolariaLinkToolbar({ vaultPath, ...props }: LinkToolbarProps & { vaultPath?: string }) { return ( ) } function applySeededColumnWidths( parsedBlocks: Array, columnWidths?: Array, ) { if (!columnWidths) return const tableBlock = parsedBlocks[0] if (tableBlock?.type !== 'table') return const tableContent = tableBlock.content if (tableContent?.type !== 'tableContent') return tableContent.columnWidths = [...columnWidths] } async function seedEditorWithTestTable( editor: ReturnType, columnWidths?: Array, ) { const parsedBlocks = await Promise.resolve( editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN), ) as Array applySeededColumnWidths(parsedBlocks, columnWidths) const tableMarkup = editor.blocksToHTMLLossy([ ...parsedBlocks, { type: 'paragraph', content: [], children: [] }, ] as typeof editor.document) editor._tiptapEditor.commands.setContent(tableMarkup) editor.focus() } function useSeedBlockNoteTableBridge(editor: ReturnType) { useEffect(() => { const seedBlockNoteTable = (columnWidths?: Array) => ( seedEditorWithTestTable(editor, columnWidths) ) window.__laputaTest = { ...window.__laputaTest, seedBlockNoteTable, } return () => { if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) { delete window.__laputaTest.seedBlockNoteTable } } }, [editor]) } function shouldIgnoreContainerClick(target: HTMLElement) { return Boolean(target.closest(CONTAINER_CLICK_IGNORE_SELECTOR)) } function normalizeSuggestionQuery(query: string, triggerCharacter: string): string { return query.startsWith(triggerCharacter) ? query.slice(triggerCharacter.length) : query } const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]' const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u' const CODE_BLOCK_COPY_RESET_MS = 1200 function nodeElement(node: Node | null): HTMLElement | null { if (!node) return null if (node instanceof HTMLElement) return node return node.parentElement } function hasSingleActiveRange(selection: Selection | null): selection is Selection { return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed) } function closestCodeBlockInContainer(options: { range: Range container: HTMLElement }): HTMLElement | null { const { range, container } = options const codeBlock = nodeElement(range.commonAncestorContainer) ?.closest(CODE_BLOCK_SELECTOR) return codeBlock && container.contains(codeBlock) ? codeBlock : null } function nodeBelongsToElement(node: Node, element: HTMLElement): boolean { const elementNode = nodeElement(node) return Boolean(elementNode && element.contains(elementNode)) } function rangeBelongsToElement(range: Range, element: HTMLElement): boolean { return nodeBelongsToElement(range.startContainer, element) && nodeBelongsToElement(range.endContainer, element) } function selectedCodeBlockRange(options: { selection: Selection | null container: HTMLElement }): Range | null { const { selection, container } = options if (!hasSingleActiveRange(selection)) return null const range = selection.getRangeAt(0) const codeBlock = closestCodeBlockInContainer({ range, container }) if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null return range } function selectedCodeBlockText(options: { selection: Selection | null container: HTMLElement }): string | null { const range = selectedCodeBlockRange(options) if (!range) return null return range.cloneContents().textContent || options.selection?.toString() || '' } function selectedEditorRange(selection: Selection | null, container: HTMLElement): Range | null { if (!hasSingleActiveRange(selection)) return null const range = selection.getRangeAt(0) return rangeBelongsToElement(range, container) ? range : null } function selectedEditorPlainText(selection: Selection, range: Range): string | null { const text = selection.toString() || range.cloneContents().textContent || '' if (text.length === 0) return null return text.replace(/\r?\n$/, '') } function selectedEditorHtml(range: Range): string { const wrapper = document.createElement('div') const selectedContent = range.cloneContents() const commonElement = nodeElement(range.commonAncestorContainer) if (commonElement?.matches(CLIPBOARD_INLINE_FORMAT_SELECTOR)) { const inlineWrapper = commonElement.cloneNode(false) inlineWrapper.appendChild(selectedContent) wrapper.appendChild(inlineWrapper) return wrapper.innerHTML } wrapper.appendChild(selectedContent) return wrapper.innerHTML } function codeBlockText(codeBlock: HTMLElement): string { const codeElement = codeBlock.querySelector('pre code') return codeElement?.textContent ?? '' } async function writeClipboardText(text: string): Promise { if (isTauri()) { await invoke('copy_text_to_clipboard', { text }) return } if (!navigator.clipboard?.writeText) { throw new Error('Clipboard API is unavailable') } await navigator.clipboard.writeText(text) } type CodeBlockCopyTarget = { codeBlock: HTMLElement left: number top: number } function codeBlockCopyTarget(codeBlock: HTMLElement, container: HTMLElement): CodeBlockCopyTarget { const codeBlockRect = codeBlock.getBoundingClientRect() const containerRect = container.getBoundingClientRect() return { codeBlock, left: codeBlockRect.right - containerRect.left + container.scrollLeft - 30, top: codeBlockRect.top - containerRect.top + container.scrollTop + 6, } } function sameCopyTarget(left: CodeBlockCopyTarget | null, right: CodeBlockCopyTarget): boolean { return Boolean( left && left.codeBlock === right.codeBlock && left.left === right.left && left.top === right.top, ) } function useCodeBlockCopyTarget(containerRef: React.RefObject) { const [copyTarget, setCopyTarget] = useState(null) const showCopyTarget = useCallback((codeBlock: HTMLElement) => { const container = containerRef.current if (!container || !container.contains(codeBlock)) return const nextTarget = codeBlockCopyTarget(codeBlock, container) setCopyTarget((previous) => sameCopyTarget(previous, nextTarget) ? previous : nextTarget) }, [containerRef]) const updateFromEventTarget = useCallback((target: EventTarget | null) => { const container = containerRef.current if (!(target instanceof HTMLElement) || !container) return if (target.closest('[data-editor-code-copy]')) return const codeBlock = target.closest(CODE_BLOCK_SELECTOR) if (codeBlock && container.contains(codeBlock)) { showCopyTarget(codeBlock) return } setCopyTarget(null) }, [containerRef, showCopyTarget]) const handleMouseMove = useCallback((event: React.MouseEvent) => { updateFromEventTarget(event.target) }, [updateFromEventTarget]) const handleFocus = useCallback((event: React.FocusEvent) => { updateFromEventTarget(event.target) }, [updateFromEventTarget]) const clearCopyTarget = useCallback(() => setCopyTarget(null), []) return { clearCopyTarget, copyTarget, handleFocus, handleMouseMove } } function CodeBlockCopyButton({ copyTarget, locale }: { copyTarget: CodeBlockCopyTarget; locale: AppLocale }) { const [active, setActive] = useState(false) const resetTimerRef = useRef(null) const t = useMemo(() => createTranslator(locale), [locale]) const label = t('editor.codeBlock.copy') useEffect(() => () => { if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current) }, []) const handleCopy = useCallback((event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() void writeClipboardText(codeBlockText(copyTarget.codeBlock)) .then(() => { trackEvent('code_block_copied') setActive(true) if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current) resetTimerRef.current = window.setTimeout(() => { setActive(false) resetTimerRef.current = null }, CODE_BLOCK_COPY_RESET_MS) }) .catch((error) => { console.warn('[editor] Failed to copy code block:', error) }) }, [copyTarget]) const stopEditorMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() }, []) return (
) } function eventTargetElement(target: EventTarget | null): HTMLElement | null { if (!(target instanceof Node)) return null return nodeElement(target) } type EditorClientPoint = Pick type TiptapSelectionRange = { from: number; to: number } type TiptapSelectionBridge = { commands?: { setTextSelection?: (selection: number | TiptapSelectionRange) => unknown } state?: { doc?: { content?: { size?: unknown } } } view?: { dom?: Element posAtCoords?: (coords: { left: number; top: number }) => { pos?: unknown } | null } } type EditorWithTiptapSelection = { _tiptapEditor?: TiptapSelectionBridge } type WhitespaceSelectionStart = { anchor: number tiptapEditor: TiptapSelectionBridge } type WhitespaceDragState = WhitespaceSelectionStart & { moved: boolean startX: number startY: number } type WhitespaceMouseDownEvent = EditorClientPoint & { button: number target: EventTarget | null preventDefault: () => void } const EDGE_SELECTION_INSET_PX = 1 const DRAG_SELECTION_THRESHOLD_PX = 3 function getTiptapSelectionBridge( editor: ReturnType, ): TiptapSelectionBridge | null { return (editor as EditorWithTiptapSelection)._tiptapEditor ?? null } function isValidDocumentPosition(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 } function textSelectionDocumentBounds( tiptapEditor: TiptapSelectionBridge, ): { start: number; end: number } | null { const size = tiptapEditor.state?.doc?.content?.size if (!isValidDocumentPosition(size)) return null if (size <= 0) return { start: 0, end: 0 } const end = Math.max(1, Math.floor(size) - 1) return { start: Math.min(1, end), end } } function clampCoordinate(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min if (max <= min) return min return Math.min(max, Math.max(min, value)) } function clampedEditorCoords( point: EditorClientPoint, editorRect: DOMRect, ): { left: number; top: number } { return { left: clampCoordinate( point.clientX, editorRect.left + EDGE_SELECTION_INSET_PX, editorRect.right - EDGE_SELECTION_INSET_PX, ), top: clampCoordinate( point.clientY, editorRect.top + EDGE_SELECTION_INSET_PX, editorRect.bottom - EDGE_SELECTION_INSET_PX, ), } } function fallbackTextPosition( tiptapEditor: TiptapSelectionBridge, point: EditorClientPoint, editorRect: DOMRect, ): number | null { const bounds = textSelectionDocumentBounds(tiptapEditor) if (!bounds) return null return point.clientY < editorRect.top ? bounds.start : bounds.end } function textPositionAtEditorPoint( tiptapEditor: TiptapSelectionBridge, point: EditorClientPoint, ): number | null { const view = tiptapEditor.view const editorDom = view?.dom if (!editorDom || typeof view.posAtCoords !== 'function') return null const editorRect = editorDom.getBoundingClientRect() const position = view.posAtCoords(clampedEditorCoords(point, editorRect))?.pos if (isValidDocumentPosition(position)) return position return fallbackTextPosition(tiptapEditor, point, editorRect) } function applyTiptapTextSelection( tiptapEditor: TiptapSelectionBridge, anchor: number, head: number, ): boolean { const setTextSelection = tiptapEditor.commands?.setTextSelection if (typeof setTextSelection !== 'function') return false const range = { from: Math.min(anchor, head), to: Math.max(anchor, head), } try { setTextSelection(range) return true } catch { return false } } function suppressNextContainerClick(suppressNextContainerClickRef: React.MutableRefObject) { suppressNextContainerClickRef.current = true window.setTimeout(() => { suppressNextContainerClickRef.current = false }, 0) } function whitespaceSelectionStartFromEvent(options: { editable: boolean editor: ReturnType event: WhitespaceMouseDownEvent selectionRoot: HTMLElement }): WhitespaceSelectionStart | null { const { editable, editor, event, selectionRoot } = options if (!editable || event.button !== 0) return null const target = eventTargetElement(event.target) if (!target || !selectionRoot.contains(target)) return null if (shouldIgnoreContainerClick(target)) return null const tiptapEditor = getTiptapSelectionBridge(editor) if (!tiptapEditor) return null const anchor = textPositionAtEditorPoint(tiptapEditor, event) return anchor === null ? null : { anchor, tiptapEditor } } function movedPastDragThreshold(state: WhitespaceDragState, point: EditorClientPoint): boolean { const movedDistance = Math.max( Math.abs(point.clientX - state.startX), Math.abs(point.clientY - state.startY), ) return movedDistance >= DRAG_SELECTION_THRESHOLD_PX } function updateWhitespaceDragSelection( state: WhitespaceDragState, point: EditorClientPoint, ): boolean { const head = textPositionAtEditorPoint(state.tiptapEditor, point) if (head === null) return false state.moved = state.moved || movedPastDragThreshold(state, point) || head !== state.anchor return applyTiptapTextSelection(state.tiptapEditor, state.anchor, head) } function installWhitespaceSelectionDrag(options: { cleanupDragRef: React.MutableRefObject<(() => void) | null> state: WhitespaceDragState suppressNextContainerClickRef: React.MutableRefObject }): () => void { const { cleanupDragRef, state, suppressNextContainerClickRef } = options function cleanupDrag() { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) if (cleanupDragRef.current === cleanupDrag) { cleanupDragRef.current = null } } function handleMouseMove(moveEvent: MouseEvent) { if ((moveEvent.buttons & 1) !== 1) { cleanupDrag() return } if (updateWhitespaceDragSelection(state, moveEvent)) { moveEvent.preventDefault() } } function handleMouseUp(upEvent: MouseEvent) { updateWhitespaceDragSelection(state, upEvent) if (state.moved) { suppressNextContainerClick(suppressNextContainerClickRef) } cleanupDrag() } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return cleanupDrag } function closestEditorScrollArea(container: HTMLElement): HTMLElement | null { const scrollArea = container.closest('.editor-scroll-area') return scrollArea instanceof HTMLElement ? scrollArea : null } function eventTargetIsOutsideContainer(event: MouseEvent, container: HTMLElement): boolean { const target = eventTargetElement(event.target) return !target || !container.contains(target) } function installScrollAreaWhitespaceSelection(options: { beginWhitespaceSelection: (event: WhitespaceMouseDownEvent, selectionRoot: HTMLElement) => void container: HTMLElement }): (() => void) | undefined { const { beginWhitespaceSelection, container } = options const scrollArea = closestEditorScrollArea(container) if (!scrollArea || scrollArea === container) return undefined const selectionRoot = scrollArea function handleScrollAreaMouseDown(event: MouseEvent) { if (eventTargetIsOutsideContainer(event, container)) { beginWhitespaceSelection(event, selectionRoot) } } selectionRoot.addEventListener('mousedown', handleScrollAreaMouseDown, true) return () => { selectionRoot.removeEventListener('mousedown', handleScrollAreaMouseDown, true) } } function useEditorWhitespaceMouseSelection(options: { containerRef: React.RefObject editable: boolean editor: ReturnType suppressNextContainerClickRef: React.MutableRefObject }) { const { containerRef, editable, editor, suppressNextContainerClickRef } = options const cleanupDragRef = useRef<(() => void) | null>(null) useEffect(() => () => { cleanupDragRef.current?.() }, []) const beginWhitespaceSelection = useCallback(( event: WhitespaceMouseDownEvent, selectionRoot: HTMLElement, ) => { const selectionStart = whitespaceSelectionStartFromEvent({ editable, editor, event, selectionRoot, }) if (!selectionStart) return cleanupDragRef.current?.() editor.focus() const { anchor, tiptapEditor } = selectionStart if (!applyTiptapTextSelection(tiptapEditor, anchor, anchor)) return event.preventDefault() const state: WhitespaceDragState = { ...selectionStart, moved: false, startX: event.clientX, startY: event.clientY, } cleanupDragRef.current = installWhitespaceSelectionDrag({ cleanupDragRef, state, suppressNextContainerClickRef, }) }, [editable, editor, suppressNextContainerClickRef]) useEffect(() => { const container = containerRef.current if (!container) return return installScrollAreaWhitespaceSelection({ beginWhitespaceSelection, container }) }, [beginWhitespaceSelection, containerRef]) return useCallback((event: React.MouseEvent) => { beginWhitespaceSelection(event, event.currentTarget) }, [beginWhitespaceSelection]) } function useEditorContainerClickHandler(options: { editable: boolean editor: ReturnType suppressNextContainerClickRef: React.MutableRefObject vaultPath?: string }) { const { editable, editor, suppressNextContainerClickRef, vaultPath } = options return useCallback((e: React.MouseEvent) => { if (!editable) return if (suppressNextContainerClickRef.current) { suppressNextContainerClickRef.current = false return } if (handleEditorFileBlockClick({ event: e, editor, vaultPath })) return const target = eventTargetElement(e.target) if (!target) return if (queueTitleHeadingCursorRepair(target, editor)) return if (shouldIgnoreContainerClick(target)) return const blocks = editor.document if (blocks.length > 0) { const targetBlock = findNearestTextCursorBlock(blocks, blocks.length - 1) if (targetBlock) { try { editor.setTextCursorPosition(targetBlock.id, 'end') } catch { // Ignore transient BlockNote selection errors and at least restore focus. } } } editor.focus() }, [editor, editable, suppressNextContainerClickRef, vaultPath]) } function useCompositionAwareEditorChange(options: { containerRef: React.RefObject onChange?: () => void }) { const { containerRef, onChange } = options const onChangeRef = useRef(onChange) const composingRef = useRef(false) const pendingChangeRef = useRef(false) useEffect(() => { onChangeRef.current = onChange }, [onChange]) useEffect(() => { const container = containerRef.current if (!container) return const flushPendingChange = () => { if (composingRef.current || !pendingChangeRef.current) return pendingChangeRef.current = false onChangeRef.current?.() } const handleCompositionStart = () => { composingRef.current = true } const handleCompositionEnd = () => { composingRef.current = false queueMicrotask(flushPendingChange) } container.addEventListener('compositionstart', handleCompositionStart, true) container.addEventListener('compositionend', handleCompositionEnd, true) return () => { container.removeEventListener('compositionstart', handleCompositionStart, true) container.removeEventListener('compositionend', handleCompositionEnd, true) } }, [containerRef]) return useCallback(() => { if (composingRef.current) { pendingChangeRef.current = true return } pendingChangeRef.current = false onChangeRef.current?.() }, []) } function handleCodeBlockCopy(event: React.ClipboardEvent): boolean { const codeText = selectedCodeBlockText({ selection: window.getSelection(), container: event.currentTarget, }) if (codeText === null) return false event.clipboardData.setData('text/plain', codeText) event.preventDefault() return true } function handleSelectedEditorCopy(event: React.ClipboardEvent) { const selection = window.getSelection() const range = selectedEditorRange(selection, event.currentTarget) if (!selection || !range) return const plainText = selectedEditorPlainText(selection, range) if (plainText === null) return event.clipboardData.setData('text/plain', plainText) const markup = selectedEditorHtml(range) if (markup.length > 0) { event.clipboardData.setData('text/html', markup) } event.preventDefault() } function handleEditorCopy(event: React.ClipboardEvent) { if (handleCodeBlockCopy(event)) return handleSelectedEditorCopy(event) } function nonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null } function markdownStem(value: string): string { return value.replace(/\.md$/i, '') } function pathStem(path: string): string { return markdownStem(path.split('/').pop() ?? path) } function safeStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => nonEmptyString(item) !== null) : [] } function buildBaseSuggestionItems(entries: VaultEntry[]) { return deduplicateByPath(entries.flatMap(entry => { const path = nonEmptyString(entry.path) if (!path) return [] const filename = nonEmptyString(entry.filename) const filenameStem = filename ? markdownStem(filename) : pathStem(path) const title = nonEmptyString(entry.title) ?? filenameStem const entryType = nonEmptyString(entry.isA) return [{ title, aliases: [...new Set([filenameStem, ...safeStringArray(entry.aliases)])], group: entryType ?? 'Note', entry, entryType, entryTitle: title, path, }] })) } function useInsertWikilink( editor: ReturnType, runEditorAction: (action: SuggestionAction) => void, ) { return useCallback((target: string) => { runEditorAction(() => { editor.insertInlineContent([ { type: 'wikilink' as const, props: { target } }, " ", ], { updateSelection: true }) trackEvent('wikilink_inserted') }) }, [editor, runEditorAction]) } function useSuggestionMenuItems(options: { baseItems: ReturnType editor: ReturnType insertWikilink: (target: string) => void locale: AppLocale runEditorAction: (action: SuggestionAction) => void sourceEntry?: VaultEntry typeEntryMap: Record vaultPath?: string }) { const { baseItems, editor, insertWikilink, locale, runEditorAction, sourceEntry, typeEntryMap, vaultPath, } = options const t = useMemo(() => createTranslator(locale), [locale]) const buildItems = useCallback((query: string, triggerCharacter: '[[' | '@') => { const normalizedQuery = normalizeSuggestionQuery(query, triggerCharacter) const minLength = triggerCharacter === '[[' ? MIN_QUERY_LENGTH : PERSON_MENTION_MIN_QUERY if (normalizedQuery.length < minLength) return null const candidates = triggerCharacter === '[[' ? preFilterWikilinks(baseItems, normalizedQuery) : filterPersonMentions(baseItems, normalizedQuery) const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '', sourceEntry) return guardSuggestionMenuItems( enrichSuggestionItems(items, normalizedQuery, typeEntryMap, { showWorkspace: hasMultipleSuggestionWorkspaces(baseItems), }), runEditorAction, ) }, [baseItems, insertWikilink, runEditorAction, sourceEntry, typeEntryMap, vaultPath]) const getWikilinkItems = useCallback(async (query: string): Promise => ( buildItems(query, '[[') ?? [] ), [buildItems]) const getPersonMentionItems = useCallback(async (query: string): Promise => ( buildItems(query, '@') ?? [] ), [buildItems]) const getSlashMenuItems = useCallback(async (query: string) => { try { return guardSuggestionMenuItems( await Promise.resolve(getTolariaSlashMenuItems(editor, query, { mathTitle: t('editor.slash.math'), })), runEditorAction, ) } catch (error) { console.warn('[editor] Ignored stale slash menu query:', error) return [] } }, [editor, runEditorAction, t]) return { getWikilinkItems, getPersonMentionItems, getSlashMenuItems, } } type EditorInteractionControllersProps = ReturnType & { runEditorAction: (action: SuggestionAction) => void vaultPath?: string } function EditorInteractionControllers({ getPersonMentionItems, getSlashMenuItems, getWikilinkItems, runEditorAction, vaultPath, }: EditorInteractionControllersProps) { return ( <> ( )} floatingUIOptions={{ elementProps: { onMouseDownCapture: handleToolbarMouseDownCapture, }, }} /> ( )} floatingUIOptions={{ elementProps: { onMouseDownCapture: handleToolbarMouseDownCapture, }, }} /> runEditorAction(item.onItemClick)} /> runEditorAction(item.onItemClick)} /> ) } /** Insert an image block after the current cursor position. */ function useInsertImageCallback(editor: ReturnType) { const editorRef = useRef(editor) useEffect(() => { editorRef.current = editor }, [editor]) return useCallback((url: string) => { const e = editorRef.current const cursorBlock = e.getTextCursorPosition().block e.insertBlocks([{ type: 'image' as const, props: { url } }], cursorBlock, 'after') }, []) } function useRichEditorPlainTextPasteTarget(options: { containerRef: React.RefObject editable: boolean editor: ReturnType runEditorAction: (action: SuggestionAction) => void }) { const { containerRef, editable, editor, runEditorAction } = options const targetRef = useRef(null) useEffect(() => { const target: PlainTextPasteTarget = { surface: 'rich_editor', contains: (element) => Boolean(element && containerRef.current?.contains(element)), isConnected: () => containerRef.current?.isConnected === true, insert: (text) => { if (!editable) return false let inserted = false runEditorAction(() => { editor.focus() editor.insertInlineContent(text, { updateSelection: true }) inserted = true }) return inserted }, } targetRef.current = target const unregister = registerPlainTextPasteTarget(target) return () => { unregister() if (targetRef.current === target) { targetRef.current = null } } }, [containerRef, editable, editor, runEditorAction]) return useCallback(() => { if (targetRef.current) { activatePlainTextPasteTarget(targetRef.current) } }, []) } /** Single BlockNote editor view — content is swapped via replaceBlocks */ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, sourceEntry, vaultPath, editable = true, locale = 'en' }: { editor: ReturnType entries: VaultEntry[] onNavigateWikilink: (target: string) => void onChange?: () => void sourceEntry?: VaultEntry | null vaultPath?: string editable?: boolean locale?: AppLocale }) { const { cssVars } = useEditorTheme() const themeMode = useDocumentThemeMode() const containerRef = useRef(null) const suppressNextContainerClickRef = useRef(false) const handleContainerClick = useEditorContainerClickHandler({ editable, editor, suppressNextContainerClickRef, vaultPath, }) const handleWhitespaceMouseSelection = useEditorWhitespaceMouseSelection({ containerRef, editable, editor, suppressNextContainerClickRef, }) const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange }) const onImageUrl = useInsertImageCallback(editor) const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath }) const lightbox = useImageLightbox({ containerRef }) const { clearCopyTarget, copyTarget, handleFocus: handleCodeBlockCopyFocus, handleMouseMove: handleCodeBlockCopyMouseMove, } = useCodeBlockCopyTarget(containerRef) useBlockNoteSideMenuHoverGuard(containerRef) useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath) useEffect(() => { _wikilinkEntriesRef.current = entries }, [entries]) useEffect(() => { const container = containerRef.current if (!container) return return observeNativeTextAssistanceDisabled(container) }, []) useSeedBlockNoteTableBridge(editor) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries]) const runEditorAction = useCallback((action: SuggestionAction) => { runSuggestionActionSafely({ action, container: containerRef.current, editor, }) }, [editor]) const activatePlainTextPaste = useRichEditorPlainTextPasteTarget({ containerRef, editable, editor, runEditorAction, }) const handlePasteCapture = useEditorPasteHandler({ editable, editor, runEditorAction, }) const handleFocusCapture = useCallback((event: React.FocusEvent) => { activatePlainTextPaste() handleCodeBlockCopyFocus(event) }, [activatePlainTextPaste, handleCodeBlockCopyFocus]) const handleMouseDownCapture = useCallback((event: React.MouseEvent) => { activatePlainTextPaste() handleWhitespaceMouseSelection(event) }, [activatePlainTextPaste, handleWhitespaceMouseSelection]) const insertWikilink = useInsertWikilink(editor, runEditorAction) const suggestionMenuItems = useSuggestionMenuItems({ baseItems, editor, insertWikilink, locale, runEditorAction, sourceEntry: sourceEntry ?? undefined, typeEntryMap, vaultPath, }) return (
{isDragOver && (
Drop image here
)} repairEditorDocumentForRenderRecovery(editor)}> {(recoveryKey) => ( )} {copyTarget && }
) }