feat: resolution-aware doc link styling in the rich editor

Doc links now show blue when the target document exists and grey with a
dashed underline when it doesn't, matching the preview surface behavior.
The document index is passed via editor storage and a vanilla NodeView
checks resolution status on each render.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-01 03:14:46 -04:00
co-authored by Orca
parent 7b494a4a89
commit dd80c54ef4
4 changed files with 78 additions and 1 deletions
@@ -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;
}
@@ -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
@@ -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<HTMLDivElement | null>(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(() => {
@@ -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.