384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { syntaxTree } from '@codemirror/language'
|
|
import { StateEffect, Facet, type EditorState, type Range } from '@codemirror/state'
|
|
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
|
import type { SyntaxNode } from '@lezer/common'
|
|
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
|
|
import {
|
|
BulletWidget,
|
|
CheckboxWidget,
|
|
CodeHeaderWidget,
|
|
EmojiWidget,
|
|
FootnoteRefWidget,
|
|
HorizontalRuleWidget,
|
|
ImageWidget,
|
|
MathWidget,
|
|
TocWidget,
|
|
type TocEntry,
|
|
} from './widgets'
|
|
|
|
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
|
|
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
|
|
combine: (values) => values[0] ?? ((src) => src),
|
|
})
|
|
|
|
export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] })
|
|
|
|
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
|
|
export const refreshImages = StateEffect.define<null>()
|
|
|
|
const hidden = Decoration.replace({})
|
|
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
|
|
const linkText = Decoration.mark({ class: 'cm-rm-link' })
|
|
const highlightText = Decoration.mark({ class: 'cm-rm-highlight' })
|
|
const superscriptText = Decoration.mark({ class: 'cm-rm-sup' })
|
|
const subscriptText = Decoration.mark({ class: 'cm-rm-sub' })
|
|
const footnoteLabel = Decoration.mark({ class: 'cm-rm-footnote-label' })
|
|
const lineDecos = new Map<string, Decoration>()
|
|
|
|
function lineDeco(cls: string): Decoration {
|
|
let deco = lineDecos.get(cls)
|
|
if (!deco) lineDecos.set(cls, (deco = Decoration.line({ class: cls })))
|
|
return deco
|
|
}
|
|
|
|
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
|
|
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
|
|
function activeLines(view: EditorView): Set<number> {
|
|
const lines = new Set<number>()
|
|
if (!view.hasFocus) return lines
|
|
const { doc } = view.state
|
|
for (const r of view.state.selection.ranges) {
|
|
for (let n = doc.lineAt(r.from).number, end = doc.lineAt(r.to).number; n <= end; n++) lines.add(n)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
function buildDecorations(view: EditorView): DecorationSet {
|
|
const { state } = view
|
|
const { doc } = state
|
|
const active = activeLines(view)
|
|
const resolveUrl = state.facet(resolveImageUrl)
|
|
const out: Range<Decoration>[] = []
|
|
|
|
const lineOf = (pos: number) => doc.lineAt(pos).number
|
|
const isActive = (from: number, to = from) => {
|
|
for (let n = lineOf(from), end = lineOf(to); n <= end; n++) if (active.has(n)) return true
|
|
return false
|
|
}
|
|
const ranges = view.hasFocus ? state.selection.ranges : []
|
|
const touches = (node: SyntaxNode) => ranges.some((r) => r.from <= node.to && r.to >= node.from)
|
|
// 视图插件提供的替换装饰不能跨越换行
|
|
const hide = (from: number, to: number) => {
|
|
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
|
|
}
|
|
const classLines = (from: number, to: number, cls: (line: number, first: number, last: number) => string) => {
|
|
const first = lineOf(from)
|
|
const last = lineOf(to)
|
|
for (const vr of view.visibleRanges) {
|
|
for (let n = Math.max(first, lineOf(vr.from)), end = Math.min(last, lineOf(vr.to)); n <= end; n++) {
|
|
out.push(lineDeco(cls(n, first, last)).range(doc.line(n).from))
|
|
}
|
|
}
|
|
}
|
|
const spacesAfter = (pos: number) => {
|
|
let end = pos
|
|
while (end < doc.length && state.sliceDoc(end, end + 1) === ' ') end++
|
|
return end
|
|
}
|
|
|
|
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
|
|
let footnoteMap: Map<string, string> | undefined
|
|
function footnotes() {
|
|
if (!footnoteMap) {
|
|
footnoteMap = new Map()
|
|
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
|
if (n.name !== 'FootnoteDefinition') continue
|
|
const label = n.getChild('FootnoteLabel')!
|
|
footnoteMap.set(state.sliceDoc(label.from + 2, label.to - 2), state.sliceDoc(label.to, n.to).trim())
|
|
}
|
|
}
|
|
return footnoteMap
|
|
}
|
|
|
|
function tocEntries(): TocEntry[] {
|
|
const entries: TocEntry[] = []
|
|
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
|
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
|
|
if (!m) continue
|
|
const raw = m[1] === 'ATX' ? state.sliceDoc(n.from, n.to).replace(/^#+\s*/, '').replace(/\s+#+\s*$/, '') : doc.lineAt(n.from).text
|
|
const text = raw.replace(/\*\*|__|==|~~|`/g, '').trim()
|
|
if (text) entries.push({ level: Number(m[2]), text, pos: n.from })
|
|
}
|
|
return entries
|
|
}
|
|
|
|
for (const { from, to } of view.visibleRanges) {
|
|
syntaxTree(state).iterate({
|
|
from,
|
|
to,
|
|
enter(ref) {
|
|
const node = ref.node
|
|
const name = node.name
|
|
if (name.startsWith('ATXHeading') || name.startsWith('SetextHeading')) {
|
|
out.push(lineDeco(`cm-rm-h${name.slice(-1)}`).range(doc.lineAt(node.from).from))
|
|
return
|
|
}
|
|
switch (name) {
|
|
case 'Paragraph':
|
|
if (!node.parent || !['ListItem', 'Blockquote'].includes(node.parent.name)) out.push(lineDeco('cm-rm-paragraph-end').range(doc.lineAt(node.to).from))
|
|
return
|
|
case 'HeaderMark': {
|
|
if (isActive(node.from)) return
|
|
const parent = node.parent!
|
|
if (parent.name.startsWith('SetextHeading')) hide(node.from, node.to)
|
|
else if (node.from === parent.from) hide(node.from, spacesAfter(node.to))
|
|
else hide(trailingSpaceStart(state, node.from), node.to)
|
|
return
|
|
}
|
|
case 'EmphasisMark':
|
|
case 'StrikethroughMark':
|
|
case 'SuperscriptMark':
|
|
case 'SubscriptMark':
|
|
case 'HighlightMark':
|
|
if (!touches(node.parent!)) hide(node.from, node.to)
|
|
return
|
|
case 'Highlight':
|
|
out.push(highlightText.range(node.from, node.to))
|
|
return
|
|
case 'Superscript':
|
|
out.push(superscriptText.range(node.from, node.to))
|
|
return
|
|
case 'Subscript':
|
|
out.push(subscriptText.range(node.from, node.to))
|
|
return
|
|
case 'InlineMath':
|
|
if (!touches(node) && lineOf(node.from) === lineOf(node.to)) {
|
|
const widget = new MathWidget(state.sliceDoc(node.from + 1, node.to - 1), false, 1)
|
|
out.push(Decoration.replace({ widget }).range(node.from, node.to))
|
|
}
|
|
return false
|
|
case 'Emoji':
|
|
if (!touches(node)) out.push(Decoration.replace({ widget: new EmojiWidget(state.sliceDoc(node.from + 1, node.to - 1)) }).range(node.from, node.to))
|
|
return
|
|
case 'FootnoteReference':
|
|
if (!touches(node)) {
|
|
const label = state.sliceDoc(node.from + 2, node.to - 1)
|
|
out.push(Decoration.replace({ widget: new FootnoteRefWidget(label, footnotes().get(label)) }).range(node.from, node.to))
|
|
}
|
|
return
|
|
case 'FootnoteDefinition':
|
|
classLines(node.from, node.to, () => 'cm-rm-footnote')
|
|
return
|
|
case 'FootnoteLabel':
|
|
if (!isActive(node.from)) out.push(footnoteLabel.range(node.from, node.to))
|
|
return
|
|
case 'TableOfContents':
|
|
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new TocWidget(tocEntries()) }).range(node.from, node.to))
|
|
return
|
|
case 'FrontMatter':
|
|
classLines(node.from, node.to, (n, first, last) =>
|
|
n === first ? 'cm-rm-frontmatter cm-rm-frontmatter-begin' : n === last ? 'cm-rm-frontmatter cm-rm-frontmatter-end' : 'cm-rm-frontmatter',
|
|
)
|
|
return false
|
|
case 'BlockMath':
|
|
// 未编辑时由 blocks.ts 整块替换为渲染结果,这里只负责编辑时的源码样式
|
|
classLines(node.from, node.to, () => 'cm-rm-math-source')
|
|
return false
|
|
case 'InlineCode':
|
|
out.push(inlineCode.range(node.from, node.to))
|
|
return
|
|
case 'CodeMark':
|
|
if (node.parent?.name === 'InlineCode' && !touches(node.parent)) hide(node.from, node.to)
|
|
return
|
|
case 'Link':
|
|
decorateLink(node)
|
|
return
|
|
case 'Autolink':
|
|
if (!touches(node)) {
|
|
hide(node.from, node.from + 1)
|
|
hide(node.to - 1, node.to)
|
|
}
|
|
return
|
|
case 'Image':
|
|
decorateImage(node)
|
|
return false
|
|
case 'Escape':
|
|
if (!touches(node)) hide(node.from, node.from + 1)
|
|
return
|
|
case 'HorizontalRule':
|
|
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new HorizontalRuleWidget() }).range(node.from, node.to))
|
|
return
|
|
case 'Blockquote':
|
|
classLines(node.from, node.to, () => 'cm-rm-quote')
|
|
return
|
|
case 'QuoteMark':
|
|
if (!isActive(node.from)) hide(node.from, Math.min(node.to + 1, spacesAfter(node.to)))
|
|
return
|
|
case 'ListMark':
|
|
decorateListMark(node)
|
|
return
|
|
case 'TaskMarker':
|
|
if (!isActive(node.from)) {
|
|
const checked = state.sliceDoc(node.from + 1, node.from + 2) !== ' '
|
|
out.push(Decoration.replace({ widget: new CheckboxWidget(checked) }).range(node.from, node.to))
|
|
}
|
|
return
|
|
case 'FencedCode':
|
|
decorateFencedCode(node)
|
|
return false
|
|
case 'CodeBlock':
|
|
classLines(node.from, node.to, () => 'cm-rm-code')
|
|
return false
|
|
case 'Table':
|
|
// 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐
|
|
classLines(node.from, node.to, () => 'cm-rm-table')
|
|
return false
|
|
case 'HTMLBlock':
|
|
case 'CommentBlock':
|
|
return false
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
function decorateLink(node: SyntaxNode) {
|
|
const marks = node.getChildren('LinkMark')
|
|
if (marks.length < 2) return
|
|
out.push(linkText.range(marks[0].to, marks[1].from))
|
|
if (touches(node)) return
|
|
hide(marks[0].from, marks[0].to)
|
|
hide(marks[1].from, node.to)
|
|
}
|
|
|
|
function decorateImage(node: SyntaxNode) {
|
|
const url = node.getChild('URL')
|
|
const marks = node.getChildren('LinkMark')
|
|
if (!url || marks.length < 2) return
|
|
const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, ''))
|
|
const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from))
|
|
if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to))
|
|
else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to))
|
|
}
|
|
|
|
function decorateListMark(node: SyntaxNode) {
|
|
if (isActive(node.from)) return
|
|
const item = node.parent!
|
|
if (item.getChild('Task')) hide(node.from, spacesAfter(node.to))
|
|
else if (item.parent?.name === 'BulletList') out.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to))
|
|
}
|
|
|
|
function decorateFencedCode(node: SyntaxNode) {
|
|
const marks = node.getChildren('CodeMark')
|
|
const closed = marks.length > 1
|
|
classLines(node.from, node.to, (n, first, last) =>
|
|
n === first ? 'cm-rm-code cm-rm-code-begin' : n === last && closed ? 'cm-rm-code cm-rm-code-end' : 'cm-rm-code',
|
|
)
|
|
const info = node.getChild('CodeInfo')
|
|
const openLine = doc.lineAt(node.from)
|
|
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
|
|
if (isActive(node.from, node.to)) {
|
|
// 编辑中显示围栏源码,复制按钮浮在行尾
|
|
out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to))
|
|
return
|
|
}
|
|
out.push(lineDeco('cm-rm-code-fence').range(openLine.from))
|
|
out.push(Decoration.replace({ widget: new CodeHeaderWidget(lang, true, codeAt) }).range(marks[0].from, openLine.to))
|
|
if (closed) {
|
|
const closeMark = marks[marks.length - 1]
|
|
out.push(lineDeco('cm-rm-code-fence').range(doc.lineAt(closeMark.from).from))
|
|
hide(closeMark.from, closeMark.to)
|
|
}
|
|
}
|
|
|
|
return Decoration.set(out, true)
|
|
}
|
|
|
|
function codeAt(view: EditorView, pos: number): string | null {
|
|
const node = fencedCodeAt(view.state, pos)
|
|
if (!node) return null
|
|
const { from, to } = codeContentRange(view.state, node)
|
|
return view.state.sliceDoc(from, to)
|
|
}
|
|
|
|
function trailingSpaceStart(state: EditorState, pos: number): number {
|
|
let start = pos
|
|
while (start > 0 && state.sliceDoc(start - 1, start) === ' ') start--
|
|
return start
|
|
}
|
|
|
|
// Ctrl/Cmd + 点击打开链接
|
|
function linkAt(state: EditorState, pos: number): string | null {
|
|
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
|
|
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
|
|
if (node.name === 'Link' || node.name === 'Autolink') {
|
|
const url = node.getChild('URL')
|
|
if (url) return state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '')
|
|
const text = (n: SyntaxNode) => state.sliceDoc(n.from, n.to)
|
|
const label = node.getChild('LinkLabel')
|
|
const close = node.getChildren('LinkMark').find(mark => text(mark) === ']')
|
|
if (!close) return null
|
|
const normalize = (value: string) => value.replace(/^\[|\]$/g, '').trim().replace(/\s+/g, ' ').toUpperCase()
|
|
const key = normalize(label && text(label) !== '[]' ? text(label) : state.sliceDoc(node.from, close.to))
|
|
let destination: string | null = null
|
|
syntaxTree(state).iterate({ enter(ref) {
|
|
if (ref.name !== 'LinkReference') return
|
|
const refLabel = ref.node.getChild('LinkLabel'), refUrl = ref.node.getChild('URL')
|
|
if (destination === null && refLabel && refUrl && normalize(text(refLabel)) === key) destination = text(refUrl).replace(/^<|>$/g, '')
|
|
return false
|
|
} })
|
|
return destination
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const livePreviewPlugin = ViewPlugin.fromClass(
|
|
class {
|
|
decorations: DecorationSet
|
|
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
|
|
stale = false
|
|
|
|
constructor(view: EditorView) {
|
|
this.decorations = buildDecorations(view)
|
|
}
|
|
|
|
update(u: ViewUpdate) {
|
|
if (u.view.composing) {
|
|
this.decorations = this.decorations.map(u.changes)
|
|
this.stale = true
|
|
return
|
|
}
|
|
if (
|
|
this.stale ||
|
|
u.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshImages))) ||
|
|
u.docChanged ||
|
|
u.viewportChanged ||
|
|
u.selectionSet ||
|
|
u.focusChanged ||
|
|
syntaxTree(u.startState) !== syntaxTree(u.state)
|
|
) {
|
|
this.stale = false
|
|
this.decorations = buildDecorations(u.view)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
decorations: (v) => v.decorations,
|
|
eventHandlers: {
|
|
mousedown(e, view) {
|
|
if (!(e.ctrlKey || e.metaKey)) return false
|
|
const pos = view.posAtCoords(e)
|
|
const href = pos === null ? null : linkAt(view.state, pos)
|
|
if (!href) return false
|
|
e.preventDefault()
|
|
const handler = view.state.facet(openLink)
|
|
if (handler) handler(href)
|
|
else if (/^(https?:|mailto:|tel:)/i.test(href)) window.open(href, '_blank', 'noopener')
|
|
return true
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
export const livePreview = [livePreviewPlugin]
|