diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 69b45f446b0..a06817fd7d9 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -605,6 +605,11 @@ color: #58a6ff; } +.rich-markdown-editor .rich-markdown-doc-link--missing { + color: var(--muted-foreground); + text-decoration-style: dashed; +} + .rich-markdown-mod-held .rich-markdown-editor .rich-markdown-doc-link { cursor: pointer; } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 1d06d79f9f1..fd1f17d709a 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -193,6 +193,7 @@ export function EditorContent({ onDirtyStateHint={handleDirtyStateHint} onSave={onSaveWithFm} onOpenDocLink={md.onOpenDocLink} + markdownDocuments={md.markdownDocuments} // Why: render the front-matter banner below the editor toolbar // (inside the editor shell) so formatting controls remain at // the top of the pane — the banner is read-only context, not diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 8da00e4f261..fd9953ce53e 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' +import type { MarkdownDocument } from '../../../../shared/types' import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu' import { useAppStore } from '@/store' import { RichMarkdownToolbar } from './RichMarkdownToolbar' @@ -43,6 +44,7 @@ type RichMarkdownEditorProps = { onDirtyStateHint: (dirty: boolean) => void onSave: (content: string) => void onOpenDocLink?: (target: string) => void + markdownDocuments?: MarkdownDocument[] // Why: front-matter is stripped from the rich editor's content but we still // want it visible to the user. It renders between the toolbar and the editor // surface so the formatting toolbar stays at the top of the pane. @@ -63,6 +65,7 @@ export default function RichMarkdownEditor({ onDirtyStateHint, onSave, onOpenDocLink, + markdownDocuments, headerSlot }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) @@ -355,6 +358,22 @@ export default function RichMarkdownEditor({ } }, [editor, filePath]) + // Why: same pattern as Image storage above — the doc link NodeView reads the + // document list from storage to style resolved vs. missing links. A no-op + // transaction triggers ProseMirror to re-render the nodeViews with the new list. + useEffect(() => { + if (editor && markdownDocuments) { + isApplyingProgrammaticUpdateRef.current = true + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(editor.storage as any).markdownDocLink.documents = markdownDocuments + editor.view.dispatch(editor.state.tr) + } finally { + isApplyingProgrammaticUpdateRef.current = false + } + } + }, [editor, markdownDocuments]) + const handleLocalImagePick = useLocalImagePick(editor, filePath) useEffect(() => { diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index 474ca791d15..3c94e5eca4f 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -1,6 +1,11 @@ import { Node, mergeAttributes } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { getMarkdownDocLinkTarget } from './markdown-doc-links' +import type { MarkdownDocument } from '../../../../shared/types' +import { + createMarkdownDocumentIndex, + getMarkdownDocLinkTarget, + resolveMarkdownDocLink +} from './markdown-doc-links' const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:' const DOC_LINK_PLACEHOLDER_SUFFIX = ']]' @@ -9,6 +14,14 @@ const DOC_LINK_PATTERN = /\[\[([^[\]\r\n|]+)\]\]/g const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert') +function isDocLinkResolved(target: string, documents: MarkdownDocument[]): boolean { + if (documents.length === 0) { + return true + } + const index = createMarkdownDocumentIndex(documents) + return resolveMarkdownDocLink(target, index).status === 'resolved' +} + export const MarkdownDocLink = Node.create({ name: 'markdownDocLink', inline: true, @@ -16,6 +29,10 @@ export const MarkdownDocLink = Node.create({ atom: true, selectable: true, + addStorage() { + return { documents: [] as MarkdownDocument[] } + }, + addAttributes() { return { target: { @@ -63,6 +80,41 @@ export const MarkdownDocLink = Node.create({ renderMarkdown: (node) => `[[${typeof node.attrs?.target === 'string' ? node.attrs.target : ''}]]`, + addNodeView() { + const storage = this.storage as { documents: MarkdownDocument[] } + return ({ node }: { node: { type: { name: string }; attrs: { target?: string } } }) => { + const target = typeof node.attrs.target === 'string' ? node.attrs.target : '' + const dom = document.createElement('span') + dom.setAttribute('data-doc-link-target', target) + dom.setAttribute('contenteditable', 'false') + dom.textContent = target + + const applyResolutionClass = (t: string): void => { + const resolved = isDocLinkResolved(t, storage.documents) + dom.className = resolved + ? 'rich-markdown-doc-link' + : 'rich-markdown-doc-link rich-markdown-doc-link--missing' + } + + applyResolutionClass(target) + + return { + dom, + update: (updatedNode) => { + if (updatedNode.type.name !== 'markdownDocLink') { + return false + } + const newTarget = + typeof updatedNode.attrs.target === 'string' ? updatedNode.attrs.target : '' + dom.setAttribute('data-doc-link-target', newTarget) + dom.textContent = newTarget + applyResolutionClass(newTarget) + return true + } + } + } + }, + // Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in // any order — brackets first then target, paste, etc. — converts to a doc // link node. Input rules only fire on sequential append at the cursor.