chore: standardize project comments in English

This commit is contained in:
eezd committed 2026-10-26 22:52:12 +08:00
1 parent a874a39ea5
commit ccb16acbea
167 files changed
+820 -820

No files matched your search

+3 -3
View File
@@ -10,10 +10,10 @@ import { editorFocused, setEditorFocus } from './focus'
import { editorLocale } from './locale'
import { CodeRendererWidget, MathWidget } from './widgets'
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
// Math and Mermaid blocks render as replacements while inactive; show source and a live preview below when the cursor enters the block.
// Multiline block replacements must come from StateField; handle only top-level blocks, as with tables.
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
/** Return block source and its offset from the block start so clicking rendered output places the cursor at the content start. */
function blockContent(state: EditorState, node: SyntaxNode): { text: string, offset: number, language?: string } | null {
if (node.name === 'BlockMath') {
const marks = node.getChildren('BlockMathMark')
+8 -8
View File
@@ -8,7 +8,7 @@ import { StateEffect, StateField } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
interface CodeBlock {
// 代码内容(不含围栏行)在文档中的范围
// Document range of code contents, excluding fence lines.
from: number
to: number
lang: string
@@ -17,7 +17,7 @@ interface CodeBlock {
const setBlockTokens = StateEffect.define<{ from: number, to: number, decorations: Range<Decoration>[] }>()
// 高亮结果作为 StateField 保存并随编辑映射位置:Worker 返回新结果前沿用旧颜色,避免闪烁
// Keep highlighting in a StateField and map positions through edits; retain old colors until the Worker responds to avoid flashing.
const codeTokenField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
@@ -72,7 +72,7 @@ export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: numb
return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase()
}
// 围栏代码块的内容范围(不含围栏行);有闭合围栏时到闭合行之前为止,否则到块尾
// Fenced-code content range, excluding fence lines; stop before the closing line if present, otherwise at the block end.
export function codeContentRange(state: EditorState, node: SyntaxNode): { from: number, to: number } {
const open = state.doc.lineAt(node.from)
const marks = node.getChildren('CodeMark')
@@ -87,7 +87,7 @@ export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null
return null
}
// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选
// Ctrl/Cmd+A first selects the current code block's contents; when already selected, defer to normal select-all.
export function selectCodeBlock(view: EditorView): boolean {
const { state } = view
const main = state.selection.main
@@ -124,7 +124,7 @@ function visibleCodeBlocks(view: EditorView): CodeBlock[] {
const codeHighlighter = ViewPlugin.fromClass(
class {
// 已应用到各代码块的结果键,按块起点记录
// Result keys applied to code blocks, indexed by block start.
applied = new Map<number, string>()
destroyed = false
@@ -141,7 +141,7 @@ const codeHighlighter = ViewPlugin.fromClass(
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) this.schedule()
}
// 不能在 update 中同步 dispatch,推迟到微任务
// Do not dispatch synchronously inside update; defer to a microtask.
schedule() {
queueMicrotask(() => this.sync())
}
@@ -161,9 +161,9 @@ const codeHighlighter = ViewPlugin.fromClass(
this.applied.set(block.from, key)
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
} else {
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
// Run sync again when results arrive to hit the cache; changed document keys naturally trigger recalculation.
requestTokens(block.lang, block.code).then(() => this.schedule()).catch((error) => {
// 本次内容失败后不随每次光标移动重试,修改代码或语言后重新请求。
// Do not retry failed content on every cursor movement; request again after code or language changes.
if (!this.destroyed) this.applied.set(block.from, key)
console.error('代码高亮失败', error)
})
+4 -4
View File
@@ -2,9 +2,9 @@ import type { EditorView, ViewUpdate } from '@codemirror/view'
import { StateEffect, StateField } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view'
// 编辑器焦点状态。块级渲染组件(表格、公式块、Mermaid)由 StateField 提供,拿不到视图的焦点,
// 由视图插件把焦点变化同步进状态:获得焦点且光标在块内时显示源码,否则渲染
// (打开笔记时光标默认在文首,不应因此展开开头的块)
// Editor focus state. StateField-based block widgets (tables, math, Mermaid) cannot access view focus directly,
// so a view plugin syncs focus into state: show source only when focused with the cursor inside the block; otherwise render.
// Opening a note places the cursor at the start and must not expand its first block by itself.
export const setEditorFocus = StateEffect.define<boolean>()
export const editorFocused = StateField.define<boolean>({
@@ -27,7 +27,7 @@ const focusTracker = ViewPlugin.fromClass(
if (u.focusChanged) this.sync()
}
// 不能在 update 中同步 dispatch,推迟到微任务
// Do not dispatch synchronously inside update; defer to a microtask.
sync() {
queueMicrotask(() => {
const focused = this.view.hasFocus
@@ -3,7 +3,7 @@ import { EditorSelection, EditorState } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic } from './format'
// 用 | 标记光标、[ ] 标记选区,执行命令后以同样的形式返回结果
// Mark the cursor with | and selection with [ ]; return the result in the same notation after executing the command.
function run(cmd: FormatCommand, doc: string): string {
const ranges: { anchor: number, head: number }[] = []
let text = ''
@@ -59,7 +59,7 @@ describe('行首标记', () => {
it('多行:有序列表按行编号,保留缩进', () => {
expect(run(toggleBlock('ordered'), '[a\n b\nc]')).toBe('[1. a\n 2. b\n3. c]')
expect(run(toggleBlock('ordered'), '[1. a\n2. b]')).toBe('[a\nb]')
// 部分行已是该类型时统一加上
// Apply the type consistently when only some lines already have it.
expect(run(toggleBlock('bullet'), '[- a\nb]')).toBe('[- a\n- b]')
})
it('标题循环', () => {
+12 -12
View File
@@ -1,8 +1,8 @@
import type { ChangeSpec, EditorState, TransactionSpec } from '@codemirror/state'
// Markdown 格式命令:快捷键与选区工具栏共用
// Markdown formatting commands shared by shortcuts and the selection toolbar.
import { EditorSelection } from '@codemirror/state'
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
/** Depend only on state and dispatch to allow testing without a DOM. */
export interface FormatTarget {
readonly state: EditorState
dispatch: (spec: TransactionSpec) => void
@@ -10,13 +10,13 @@ export interface FormatTarget {
export type FormatCommand = (target: FormatTarget) => boolean
/**
* 切换行内标记(粗体 **、斜体 *、删除线 ~~、行内代码 `):选区两侧或选区首尾已有该标记时去掉,否则加上;
* 没有选中文字时插入一对标记,光标放在中间
* Toggle inline markers (bold **, italic *, strikethrough ~~, inline code `): remove matching surrounding or edge
* markers, otherwise add them. With no selection, insert a pair and place the cursor between them.
*/
export function toggleInline(mark: string): FormatCommand {
const n = mark.length
const ch = mark[0]
// 选区两侧连续的标记字符数;* 同时用于斜体与粗体:*** 两者都有,单数个才有斜体
// Count adjacent marker characters around the selection; * serves italic and bold: *** means both, and only odd counts imply italic.
const runLength = (state: EditorState, pos: number, dir: 1 | -1) => {
let k = 0
while (state.sliceDoc(dir > 0 ? pos + k : pos - k - 1, dir > 0 ? pos + k + 1 : pos - k) === ch) k++
@@ -54,7 +54,7 @@ export function toggleInline(mark: string): FormatCommand {
}
}
// 行首的块标记:缩进 + 标题 / 任务 / 无序列表 / 有序列表 / 引用(每行只识别一个)
// Leading block marker: indentation plus heading/task/bullet/ordered-list/quote (recognize one per line).
const LINE_PREFIX = /^(\s*)(#{1,6} |[-*+] \[[ x]\] |[-*+] |\d+[.)] |> )?/i
type PrefixKind = 'heading' | 'task' | 'bullet' | 'ordered' | 'quote'
@@ -67,7 +67,7 @@ function kindOf(prefix: string): PrefixKind | null {
return prefix.includes('[') ? 'task' : 'bullet'
}
/** 改写选区涉及的每一行的行首标记;next 返回新的标记 */
/** Rewrite leading markers on every selected line; next returns the new marker. */
function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean {
const lineNumbers = new Set<number>()
for (const r of state.selection.ranges) {
@@ -83,7 +83,7 @@ function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKi
return { from, to: from + current.length, insert: next(kinds, i) }
})
const changes = state.changes(specs)
// 光标在行首时随新插入的标记后移;选区则向两端扩展,把新标记包含在内
// Move a line-start cursor after the inserted marker; expand selections outward to include new markers.
const selection = EditorSelection.create(
state.selection.ranges.map((r) => {
if (r.empty) return EditorSelection.cursor(changes.mapPos(r.head, 1))
@@ -96,7 +96,7 @@ function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKi
return true
}
/** 切换列表或引用:所选各行都已是该类型时去掉,否则全部改为该类型(替换原有的其他块标记) */
/** Toggle a list or quote: remove the type if all selected lines have it; otherwise apply it to all, replacing other block markers. */
export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand {
return target =>
editPrefixes(target, (kinds, i) => {
@@ -105,7 +105,7 @@ export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand
})
}
/** 标题级别循环:正文 → 一级 → 二级 → 三级 → 正文;以第一行为准 */
/** Cycle heading levels based on the first line: body -> level 1 -> level 2 -> level 3 -> body. */
export const cycleHeading: FormatCommand = (target) => {
const first = LINE_PREFIX.exec(target.state.doc.lineAt(target.state.selection.main.from).text)![2] ?? ''
const level = kindOf(first) === 'heading' ? first.length - 1 : 0
@@ -113,7 +113,7 @@ export const cycleHeading: FormatCommand = (target) => {
return editPrefixes(target, () => prefix)
}
/** 全部选中行已是指定标题时恢复正文,否则设置为指定级别。 */
/** Return to body text if every selected line already has the requested heading level; otherwise apply that level. */
export function toggleHeading(level: number): FormatCommand {
return (target) => {
const prefix = `${'#'.repeat(level)} `
@@ -128,7 +128,7 @@ export function toggleHeading(level: number): FormatCommand {
}
}
/** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */
/** Insert a link using selected text as its label and focus the destination; without a selection, focus the label. */
export const insertLink: FormatCommand = ({ state, dispatch }) => {
const tr = state.changeByRange((r) => {
const text = state.sliceDoc(r.from, r.to)
+4 -4
View File
@@ -1,9 +1,9 @@
import { t as translate } from '@@/i18n/runtime'
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
/* eslint-disable no-control-regex -- Pasted links must detect and remove control characters in schemes. */
import TurndownService from 'turndown'
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
// template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
// Keep external resources and scripts inert inside template; clipboard HTML must not enter the active page.
const template = document.createElement('template')
template.innerHTML = html
const fragment = template.content
@@ -32,7 +32,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
images.set(src, group)
}
const sources = [...images]
// 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
// Web pages often contain many images; bound concurrency so one paste cannot exhaust browser connections or server download memory.
for (let offset = 0; offset < sources.length; offset += 3) {
await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
try {
@@ -61,7 +61,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
return `\n\n${[line(rows[0]), line(Array.from<string>({ length: columns }).fill('---')), ...rows.slice(1).map(line)].join('\n')}\n\n`
},
})
// 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
// Wrap destinations in angle brackets so URL parentheses do not truncate Markdown; discard executable schemes.
service.addRule('link', {
filter: 'a',
replacement: (content, node) => {
+7 -7
View File
@@ -20,8 +20,8 @@ import { markdownTheme } from './theme'
export { insertImageFiles, pasteImages } from './paste'
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
// Add Typora extensions to lang-markdown's syntax set (CommonMark, GFM, superscript/subscript, and emoji).
// Reuse its language data so markdownKeymap commands recognize Markdown through isActiveAt.
export const pluginCompartment = new Compartment()
export const languageCompartment = new Compartment()
export const preferencesCompartment = new Compartment()
@@ -49,20 +49,20 @@ export interface EditorStateOptions {
shortcuts?: KeyBinding[]
resolveImage?: (src: string) => string
openLink?: (href: string) => void
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
// Notify document changes only for the state mounted in the view; callers update inactive-tab states directly.
onChange?: (update: ViewUpdate) => void
extensions?: Extension[]
}
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
// Each open note owns an EditorState with undo history; replace the entire state on tab switches.
export function createEditorState(opts: EditorStateOptions): EditorState {
const extensions: Extension[] = [
history(),
localeCompartment.of(editorLocale.of(locale.value)),
drawSelection(),
EditorView.lineWrapping,
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
// Avoid lang-markdown's markdown(): its static lang-html/css/javascript imports enable nested HTML highlighting,
// increasing the initial gzip bundle from roughly 108 KB to 180 KB, while Live Preview does not need HTML highlighting.
languageCompartment.of(pluginLanguage()),
pluginCompartment.of(pluginEditorExtensions()),
EditorView.exceptionSink.of(reportEditorFailure),
@@ -72,7 +72,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
codeBlockHighlight,
editorFocus,
previewCompartment.of(previewExtensions(opts.sourceMode ?? false)),
// 可配置操作不再通过 CM 的默认绑定重复注册。
// Do not register configurable actions again through CodeMirror's default bindings.
keymap.of(defaultKeymap.filter(binding => binding.run !== selectAll && binding.run !== indentLess && binding.run !== indentMore && binding.run !== selectParentSyntax && binding.run !== toggleComment)),
preferencesCompartment.of([
EditorState.tabSize.of(opts.tabSize ?? 4),
@@ -24,16 +24,16 @@ const MARK_NODES: Record<InlineMark, string> = { StrongEmphasis: 'EmphasisMark',
function formattingTree(state: EditorState) {
const to = state.selection.ranges.at(-1)!.to
// 后台标签页或长文末尾的选区可能尚未完成增量解析;操作不能悄悄漏掉文字。
// Selections in background tabs or near long-document ends may not be fully parsed; operations must not silently skip text.
return ensureSyntaxTree(state, to, 50) ?? state.facet(language)!.parser.parse(state.doc.toString())
}
/** 从当前语法树取文字片段,保留 Markdown 的标记、链接地址和块级结构。 */
/** Extract text fragments from the current syntax tree, preserving Markdown markers, link destinations, and block structure. */
function segments(state: EditorState): Segment[] {
const result: Segment[] = []
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink', 'Escape', 'Entity'].includes(node.name)) {
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
// Do not split atomic syntax; when changing sibling text under the same outer tag, preserve styles on these unselected fragments.
result.push({ from: node.from, to: node.to, atomic: true, styles, underline, formats, wrappers })
return
}
@@ -54,7 +54,7 @@ function segments(state: EditorState): Segment[] {
for (const match of state.sliceDoc(from, to).matchAll(/[^\n]+/g)) {
const start = from + match.index; const end = start + match[0].length
const line = state.doc.lineAt(start)
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
// Continuation lines can still have list indentation or quote prefixes; insert tags after the prefix.
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
const contentFrom = Math.max(start, prefixEnd)
if (contentFrom < end) result.push({ from: contentFrom, to: end, atomic: false, styles, underline, formats, wrappers })
@@ -116,7 +116,7 @@ function wrap(text: string, styles: InlineStyles, underline: boolean, formats: s
return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '</u>' : '') + (css ? '</span>' : '') }
}
/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */
/** Rewrite only selected text and surrounding appearance tags; retain styles on remaining split fragments. */
function editAppearance(update: (styles: InlineStyles, underline: boolean, formats: string[]) => { styles: InlineStyles, underline: boolean, formats: string[] }): FormatCommand {
return ({ state, dispatch }) => {
if (!canFormatSelection(state)) return false
@@ -130,14 +130,14 @@ function editAppearance(update: (styles: InlineStyles, underline: boolean, forma
}
for (const run of runs) {
if (!selected(state, run) && !run.wrappers.some(wrapper => wrappers.has(wrapper))) continue
// 同一外层标签内的兄弟标签也要展开,重新写回合并后的有效样式。
// Expand sibling tags within the same outer tag too, then write back merged effective styles.
for (const wrapper of run.wrappers) {
if (!wrappers.has(wrapper)) {
wrappers.add(wrapper)
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
}
}
// 部分选中转义字符等语法单元时,格式应用于整个单元,不能把标签插进语法内部。
// Partial selections of atomic syntax, such as escapes, format the whole unit; never insert tags inside the syntax.
const cuts = run.atomic ? [run.from, run.to] : [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b)
let insert = ''
for (let i = 0; i < cuts.length - 1; i++) {
@@ -184,7 +184,7 @@ export const toggleUnderline: FormatCommand = (target) => {
return editAppearance((styles, _underline, formats) => ({ styles, underline: !remove, formats }))(target)
}
/** 跨段落格式化按语法片段处理,不把列表标记或链接地址包进 Markdown 定界符。 */
/** Format across paragraphs by syntax fragment, without wrapping list markers or link destinations in Markdown delimiters. */
export function toggleSelectionMark(mark: InlineMark): FormatCommand {
return (target) => {
const runs = segments(target.state).filter(run => selected(target.state, run))
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
describe('快捷键', () => {
it('windows 上 Ctrl+Y 为重做', async () => {
// CodeMirror 在模块加载时读取平台,需先设置再导入
// CodeMirror reads the platform at module load; configure it before importing.
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
const { undo } = await import('@codemirror/commands')
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
+11 -11
View File
@@ -21,14 +21,14 @@ import {
TocWidget,
} from './widgets'
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
// Resolve note image paths to browser-loadable URLs, using the note's folder for relative paths.
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] })
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
// Rebuild previews after in-place image replacement without changing text, selection, or undo history.
export const refreshImages = StateEffect.define<null>()
const hidden = Decoration.replace({})
@@ -46,8 +46,8 @@ function lineDeco(cls: string): Decoration {
return deco
}
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
// Expand block markers (headings, quotes, lists, fences) by line; expand inline syntax only around the cursor,
// matching Typora/Obsidian and avoiding whole-line jumps in long paragraphs. Render the entire document when the editor loses focus.
function activeLines(view: EditorView): Set<number> {
const lines = new Set<number>()
if (!view.hasFocus) return lines
@@ -74,7 +74,7 @@ function buildDecorations(view: EditorView): DecorationSet {
}
const ranges = view.hasFocus ? state.selection.ranges : []
const touches = (node: SyntaxNode) => ranges.some(r => r.from <= node.to && r.to >= node.from)
// 视图插件提供的替换装饰不能跨越换行
// Replacement decorations from view plugins cannot cross newlines.
const hide = (from: number, to: number) => {
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
}
@@ -93,7 +93,7 @@ function buildDecorations(view: EditorView): DecorationSet {
return end
}
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
// Compute footnote definitions (label -> content) and table-of-contents entries only when the relevant syntax is visible.
let footnoteMap: Map<string, string> | undefined
function footnotes() {
if (!footnoteMap) {
@@ -195,7 +195,7 @@ function buildDecorations(view: EditorView): DecorationSet {
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 整块替换为渲染结果,这里只负责编辑时的源码样式
// blocks.ts replaces inactive blocks with rendered output; this code handles source styling only while editing.
classLines(node.from, node.to, () => 'cm-rm-math-source')
return false
case 'InlineCode':
@@ -244,7 +244,7 @@ function buildDecorations(view: EditorView): DecorationSet {
classLines(node.from, node.to, () => 'cm-rm-code')
return false
case 'Table':
// 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐
// Show monospaced source while the cursor is in a table (otherwise table.ts renders an editable table); preserve internal syntax to keep columns aligned.
classLines(node.from, node.to, () => 'cm-rm-table')
return false
case 'HTMLBlock':
@@ -290,7 +290,7 @@ function buildDecorations(view: EditorView): DecorationSet {
const openLine = doc.lineAt(node.from)
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
if (isActive(node.from, node.to)) {
// 编辑中显示围栏源码,复制按钮浮在行尾
// Show fence source while editing, with the copy button floating at line end.
out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to))
return
}
@@ -319,7 +319,7 @@ function trailingSpaceStart(state: EditorState, pos: number): number {
return start
}
// Ctrl/Cmd + 点击打开链接
// Ctrl/Cmd-click opens links.
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, '')
@@ -348,7 +348,7 @@ function linkAt(state: EditorState, pos: number): string | null {
const livePreviewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
// During IME composition, map positions without rebuilding; rebuild once composition ends.
stale = false
constructor(view: EditorView) {
+10 -10
View File
@@ -5,15 +5,15 @@ import { Facet, StateEffect, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 文件移动期间不能开始异步粘贴或上传,避免响应中的资源链接属于旧路径。
// Do not start asynchronous paste or uploads during file moves; returned resource links could target the old path.
canPaste: () => boolean
// 上传文件,返回写入 Markdown 的链接
// Upload a file and return its Markdown link.
upload: (file: File) => Promise<string>
importImage: (url: string) => Promise<string>
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
// Update by note identity; the note may be in the background when asynchronous paste or upload completes.
update: (change: (state: EditorState) => TransactionSpec | undefined) => void
onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
// Notify upload start/end so the save queue can pause and avoid writing temporary blob: URLs to files.
onPending: (delta: 1 | -1) => void
}
@@ -37,8 +37,8 @@ const pendingPastes = StateField.define<Map<object, { from: number, to: number }
transaction.changes.iterChangedRanges((from, to) => {
if ((from < range.to && to > range.from) || (from === to && from > range.from && from < range.to)) edited = true
})
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
// Edits or undo while waiting must not be overwritten by a late conversion result.
// Locate successive pastes at the same position in initiation order, independent of image-download completion order.
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
}
@@ -85,8 +85,8 @@ function insertHTML(view: EditorView, html: string, text: string, opts: PasteIma
}
/**
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
* 返回是否已处理图片插入(包括因操作冲突被拒绝);未配置上传时不处理
* Insert image files from paste, drop, or photo selection: immediately show blob: placeholders, then replace them with
* uploaded links. Return whether insertion was handled, including operation-conflict rejection; do nothing if upload is unconfigured.
*/
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
const opts = view.state.facet(uploadOptions)
@@ -120,7 +120,7 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
return true
}
// 粘贴或拖入图片
// Paste or drop images.
export function pasteImages(opts: PasteImagesOptions): Extension {
let plain = false
return [
@@ -144,7 +144,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
e.preventDefault()
return true
}
// 个别浏览器/来源只在 items 中提供图片
// Some browsers/sources expose images only through items.
let files = [...(e.clipboardData?.files ?? [])]
if (!files.length) {
files = [...(e.clipboardData?.items ?? [])].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(f => f !== null)
+20 -20
View File
@@ -23,13 +23,13 @@ interface TableModel {
from: number
to: number
aligns: Align[]
// rows[0] 为表头,其余为表体;分隔行单独由 aligns 表示
// rows[0] is the header; remaining rows form the body. aligns separately represents the delimiter row.
rows: RowLine[]
}
// ---- 解析与序列化 ----
// ---- Parsing and serialization ----
// 按未转义的 | 切分一行,去掉首尾可选的竖线
// Split a line on unescaped | characters, removing optional leading/trailing pipes.
function splitRow(line: string): string[] {
const cells: string[] = []
let cur = ''
@@ -100,7 +100,7 @@ function findTable(state: EditorState, pos: number): SyntaxNode | null {
return null
}
// ---- 渲染组件 ----
// ---- Rendered widget ----
function renderCell(cell: HTMLElement, raw: string) {
cell.innerHTML = renderInlineMarkdown(raw)
@@ -109,9 +109,9 @@ function renderCell(cell: HTMLElement, raw: string) {
interface WidgetState {
model: TableModel
// 最近编辑的单元格,工具栏操作以它为准
// The most recently edited cell, used as the target for toolbar actions.
current: { r: number, c: number }
// 结构变更后需要聚焦的单元格(DOM 重建后生效)
// Cell to focus after a structural change, once the DOM is rebuilt.
pendingFocus: { r: number, c: number } | null
}
@@ -139,7 +139,7 @@ class TableWidget extends WidgetType {
wrap.className = 'cm-rm-table-widget'
wrap.lang = this.locale
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
// Keep horizontal scrolling inside so the absolutely positioned toolbar is not clipped.
const scroller = document.createElement('div')
scroller.className = 'cm-rm-table-scroll'
scroller.append(this.buildTable(view, wrap))
@@ -159,7 +159,7 @@ class TableWidget extends WidgetType {
const sameShape
= old.rows.length === this.model.rows.length && old.aligns.join() === this.model.aligns.join()
if (!sameShape || st.pendingFocus) {
// 重建前记下正在编辑的单元格(如撤销改变了行数),重建后恢复焦点
// Remember the active cell before rebuilding, such as after undo changes row count, then restore focus.
const editing = dom.querySelector<HTMLElement>('[contenteditable]')
const focus
= st.pendingFocus ?? (editing ? { r: Number(editing.dataset.r), c: Number(editing.dataset.c) } : null)
@@ -173,7 +173,7 @@ class TableWidget extends WidgetType {
}
return true
}
// 形状不变:只刷新内容变化的单元格;正在编辑的单元格若与文档一致则不动,以免打断输入
// With the same shape, refresh only changed cells; leave the active cell untouched when it matches the document to preserve input.
dom.querySelectorAll<HTMLElement>('[data-r]').forEach((cell) => {
const r = Number(cell.dataset.r)
const c = Number(cell.dataset.c)
@@ -226,7 +226,7 @@ class TableWidget extends WidgetType {
if (danger) b.className = 'cm-rm-danger'
b.innerHTML = svg(icon)
b.append(label)
// mousedown 阻止默认行为,保持当前单元格的焦点与光标
// Prevent default on mousedown to preserve the active cell's focus and cursor.
b.addEventListener('mousedown', (e) => {
e.preventDefault()
run()
@@ -266,7 +266,7 @@ function svg(body: string): string {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
}
// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格
// For structural changes, regenerate the table's source from the latest document and return the cell to focus afterward.
function mutate(
view: EditorView,
wrap: HTMLElement,
@@ -278,14 +278,14 @@ function mutate(
const rows = model.rows.map(r => [...r.cells])
const aligns = [...model.aligns]
st.pendingFocus = f(rows, aligns, st.current)
// 结构变更单独成为一步撤销,不与前后的单元格输入合并
// Keep structural changes as a separate undo step, not merged with surrounding cell edits.
view.dispatch({
changes: { from: model.from, to: model.to, insert: serializeTable(aligns, rows) },
annotations: isolateHistory.of('full'),
})
}
// focusColumn 未指定时聚焦新行的同一列
// Without focusColumn, focus the same column in the new row.
function insertRow(view: EditorView, wrap: HTMLElement, focusColumn?: number) {
return mutate(view, wrap, (rows, aligns, cur) => {
rows.splice(cur.r + 1, 0, aligns.map(() => ''))
@@ -304,7 +304,7 @@ function insertColumn(view: EditorView, wrap: HTMLElement) {
function deleteRow(view: EditorView, wrap: HTMLElement) {
return mutate(view, wrap, (rows, _, cur) => {
// 表头行不能删除
// The header row cannot be deleted.
if (cur.r === 0) return cur
rows.splice(cur.r, 1)
return { r: Math.min(cur.r, rows.length - 1), c: cur.c }
@@ -339,7 +339,7 @@ function placeCaretAtEnd(el: HTMLElement) {
sel.addRange(range)
}
// 单元格编辑时显示该格的 Markdown 源码,失焦后恢复渲染
// Show a cell's Markdown source while editing; restore rendered output on blur.
function startEdit(cell: HTMLElement, st: WidgetState) {
const r = Number(cell.dataset.r)
const c = Number(cell.dataset.c)
@@ -416,7 +416,7 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
e.preventDefault()
moveTo(r() + 1, c())
} else if (e.key === 'Escape') {
// 退出表格,光标放到表格之后
// Exit the table and place the cursor after it.
e.preventDefault()
const model = currentModel(view, wrap)
if (model) view.dispatch({ selection: { anchor: Math.min(model.to + 1, view.state.doc.length) } })
@@ -425,14 +425,14 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
})
}
// ---- 装饰 ----
// ---- Decorations ----
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
// Multiline block replacements must come from StateField, not ViewPlugin.
// Match other syntax: show source only when the editor is focused and the cursor is inside the table (see focus.ts).
function buildTables(state: EditorState): DecorationSet {
const out: Range<Decoration>[] = []
const sel = state.field(editorFocused) ? state.selection.ranges : []
// 只渲染顶层表格;引用、列表内的表格以源码显示
// Render only top-level tables; keep tables inside quotes or lists as source.
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
if (node.name !== 'Table') continue
if (sel.some(r => r.from <= node.to && r.to >= node.from)) continue
+1 -1
View File
@@ -1,7 +1,7 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
// 编辑器排版样式在 themes/base.css 中以 CSS 变量定义,这里只做语法元素到类名的映射
// Editor typography uses CSS variables in common/appearance/base.css; this module maps syntax elements to class names only.
const markdownHighlight = HighlightStyle.define([
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: '600' },
+15 -15
View File
@@ -32,7 +32,7 @@ export class CheckboxWidget extends WidgetType {
el.type = 'checkbox'
el.className = 'cm-rm-task'
el.checked = this.checked
// 用 mousedown 并阻止默认行为:否则编辑器先移动光标,行被展开为源码,点击落空
// Use mousedown with preventDefault; otherwise the editor moves the cursor, expands the line to source, and loses the click.
el.addEventListener('mousedown', (e) => {
e.preventDefault()
const pos = view.posAtDOM(el)
@@ -65,13 +65,13 @@ const COPY_ICON
const CHECK_ICON
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
// 代码块右上角:语言标签(未激活时)+ 复制按钮
// Code-block top right: language label when inactive, plus a copy button.
export class CodeHeaderWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly lang: string,
readonly showLang: boolean,
// 根据组件所在位置取出代码块内容,由调用方结合语法树实现
// Read code contents at the widget position; the caller implements this using the syntax tree.
readonly codeAt: (view: EditorView, pos: number) => string | null,
) {
super()
@@ -131,12 +131,12 @@ export class ImageWidget extends WidgetType {
img.alt = this.alt
img.loading = 'lazy'
img.decoding = 'async'
// 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位
// Image loading changes height; request editor remeasurement to keep cursor positioning correct.
img.addEventListener('load', () => view.requestMeasure())
// 组件内的事件默认被编辑器忽略,需手动把光标移到图片语法处以展开源码
// The editor ignores widget events by default; move the cursor to image syntax manually to expand source.
wrap.addEventListener('mousedown', (e) => {
e.preventDefault()
// 先设光标再聚焦:focus() 会把编辑器选区写回 DOM,反之会读到残留在别处(如表格单元格)的 DOM 选区
// Set the cursor before focusing: focus() writes editor selection to the DOM; reversing this reads a stale DOM selection elsewhere, such as a table cell.
view.dispatch({ selection: { anchor: view.posAtDOM(wrap) } })
view.focus()
})
@@ -153,9 +153,9 @@ export class ImageWidget extends WidgetType {
}
}
// ---- 扩展语法(公式、Mermaid、emoji、脚注、目录) ----
// ---- Extended syntax (math, Mermaid, emoji, footnotes, table of contents) ----
// 点击渲染结果时把光标移到源码中(offset 为相对组件起点的位置)以展开编辑,做法同图片
// Clicking rendered output moves the cursor into source at offset relative to the widget start, expanding editing as with images.
function editOnMousedown(el: HTMLElement, view: EditorView, offset: number) {
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return
@@ -173,8 +173,8 @@ function showRenderError(el: HTMLElement, err: unknown) {
}
/**
* 公式。editOffset 为 null 时是编辑中显示在源码下方的预览(不响应点击,内容变化时原地更新),
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
* Math. A null editOffset means a preview below source during editing: no click handling, update in place
* on content changes. Otherwise it replaces source; clicking moves the cursor to editOffset in the source.
*/
export class MathWidget extends WidgetType {
readonly locale = locale.value
@@ -199,7 +199,7 @@ export class MathWidget extends WidgetType {
return el
}
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
// Reuse preview elements only: switching rendered output to editing may update an old element that still has click-to-edit listeners.
updateDOM(dom: HTMLElement, view: EditorView) {
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
this.render(dom, view)
@@ -213,7 +213,7 @@ export class MathWidget extends WidgetType {
return
}
el.classList.remove('cm-rm-placeholder')
// 加载 KaTeX 前先显示源码
// Show source until KaTeX loads.
if (!el.firstChild) el.textContent = this.tex
void renderMath(el, this.tex, this.display).then(() => view.requestMeasure())
}
@@ -225,7 +225,7 @@ export class MathWidget extends WidgetType {
const rendererMounts = new WeakMap<HTMLElement, () => void>()
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
/** Generic code-block preview; editOffset follows the math widget's click-to-edit contract. */
export class CodeRendererWidget extends WidgetType {
readonly locale = locale.value
constructor(
@@ -306,7 +306,7 @@ export class EmojiWidget extends WidgetType {
toDOM(view: EditorView) {
const el = document.createElement('span')
el.className = 'cm-rm-emoji'
// 名称表加载前或名称未知时显示原文
// Show original text until the name table loads or when the name is unknown.
el.textContent = `:${this.name}:`
void emojiFor(this.name).then((e) => {
if (e) el.textContent = e
@@ -376,7 +376,7 @@ export class TocWidget extends WidgetType {
})
el.append(item)
}
// 点击目录空白处展开 [TOC] 源码
// Clicking empty table-of-contents space expands [TOC] source.
editOnMousedown(el, view, 0)
return el
}