mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user