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

@@ -77,7 +77,7 @@ async function saveSnippet() {
if (existing) {
Object.assign(existing, draft.value, { name: draft.value.name.trim() })
} else {
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
// getRandomValues works on LAN HTTP development pages; randomUUID requires a secure context.
const id = [...crypto.getRandomValues(new Uint32Array(4))].map(n => n.toString(16).padStart(8, '0')).join('')
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
}
@@ -160,7 +160,7 @@ async function restore(kind: BackupKind, event: Event) {
if (((await getAuthStateApi()).data).setUp) {
location.reload(); return
}
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
} catch { /* Keep waiting while the service stops and rebinds; report a final timeout explicitly. */ }
}
throw new Error(translate('settings.restore_request_submitted_the_service_b1d2c2'))
}
@@ -23,7 +23,7 @@ const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: t
}, trigger: 'blur' }] }
const user = useUserStore()
const busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示
// Plaintext of the newly created token, shown only in this dialog session.
const created = ref<string | null>(null)
const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
+1 -1
View File
@@ -292,7 +292,7 @@ export interface SettingSearchResult {
description: string
}
/** 只搜索设置说明,不读取设置值、笔记或管理列表。 */
/** Search setting descriptions only, without reading values, notes, or management lists. */
export function searchSettings(query: string): SettingSearchResult[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!terms.length) return []
@@ -11,9 +11,9 @@ import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeVi
const host = ref<HTMLElement>()
const tab = computed(activeTab)
let view: EditorView
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置。
// 优先用滚动快照(锚定文档位置):重设状态后视口外的行高会重新估算,单靠 scrollTop 会偏移几行;
// 快照只对同一文档有效,笔记在后台被外部修改时退回 scrollTop
// The displayed note (identified by an identity object that survives renaming) and each note's scroll position.
// Prefer document-anchored scroll snapshots: resetting state reestimates offscreen line heights, so scrollTop alone may drift by several lines.
// Snapshots apply only to the same document; fall back to scrollTop when external changes update a background note.
interface ScrollPosition {
doc: Text
snapshot: StateEffect<unknown>
@@ -34,11 +34,11 @@ function show() {
shown = undefined
return
}
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
// Reset only on note switches or when another operation replaces the entire state, such as draft restoration.
if (shown !== ref || state.doc !== view.state.doc) {
view.setState(state)
const pos = scrollPositions.get(ref)
// 等一帧:v-show 在此回调之后才取消 display:none,隐藏状态下无法滚动和聚焦
// Wait one frame: v-show removes display:none after this callback, and hidden elements cannot scroll or focus.
requestAnimationFrame(() => {
if (shown !== ref || revealPending(view, ref.path)) return
if (pos?.doc === view.state.doc) view.dispatch({ effects: pos.snapshot })
@@ -46,13 +46,13 @@ async function submit() {
busy.value = true
error.value = ''
try {
// 支持修改普通 Markdown 的目标文件名,以便处理同名冲突。
// Allow changing plain Markdown destination filenames to resolve name conflicts.
const uploads = zipped.value ? files.value : files.value.map((file, i) => new File([file], names.value[i], { type: file.type }))
result.value = await importNotes(props.dir, zipped.value ? folder.value.trim() : '', uploads)
for (const p of result.value.paths) {
for (let d = zipped.value ? p : dirname(p); d; d = dirname(d)) expanded.add(d)
}
// 导入文件成功后释放浏览器持有的文件,结果仍保留在弹窗中。
// Release browser-held files after a successful import while retaining results in the dialog.
files.value = []
} catch (err) {
error.value = err instanceof ApiError ? err.message : translate('workspace.network_error_import_status_is_fb8726')
@@ -11,7 +11,7 @@ import { fuzzyMatch, segments } from '../fuzzy'
import { minimalPaths } from '../selection'
import { move, ws } from '../workspace'
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
// "Move to..." selects a destination folder, supporting phones without drag-and-drop and faster desktop moves.
const props = defineProps<{ path: string | string[] }>()
const emit = defineEmits<{ close: [] }>()
@@ -8,7 +8,7 @@ import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
// Outline: list current note headings, jump on click, and highlight the section containing the cursor.
interface Heading {
level: number
text: string
@@ -19,7 +19,7 @@ const headings = ref<Heading[]>([])
const current = ref(-1)
function collect(state: EditorState): Heading[] {
// 长文档的语法树可能尚未解析完,最多等 50ms,之后随编辑更新
// Long documents may not be fully parsed yet; wait up to 50ms and update on subsequent edits.
const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state)
const out: Heading[] = []
tree.iterate({
@@ -44,7 +44,7 @@ function refresh() {
lastDoc = undefined
return
}
// 只移动光标时不重新收集标题
// Do not recollect headings for cursor-only changes.
if (v.state.doc !== lastDoc) {
headings.value = collect(v.state)
lastDoc = v.state.doc
@@ -70,7 +70,7 @@ function jump(h: Heading) {
let unsubscribe: (() => void) | undefined
onMounted(() => {
unsubscribe = onEditorUpdate(scheduleRefresh)
// 切换标签页时编辑器整体替换状态,不经过更新监听;等编辑器面板换好状态后再读
// Tab switches replace editor state without an update notification; read after the editor pane installs the new state.
requestAnimationFrame(refresh)
})
onBeforeUnmount(() => {
@@ -17,9 +17,9 @@ interface Item {
key: string
icon: 'note' | 'noteAdd' | 'command'
title: string
// 笔记为所在目录,命令为快捷键
// Parent folder for notes; shortcut for commands.
detail: string
// 命中字符在标题、目录中的下标
// Indices of matched characters in the title and folder.
titleHits: Set<number>
detailHits: Set<number>
run: () => void
@@ -29,10 +29,10 @@ const input = ref<InputInstance>()
const list = ref<HTMLElement>()
const query = ref(ws.switcher ?? '')
const selected = ref(0)
// 以 > 开头时列出命令(与 VS Code 一致),删掉 > 回到找笔记
// A leading > lists commands, matching VS Code; remove it to search notes again.
const commandMode = computed(() => query.value.startsWith('>'))
// 取消时把焦点还给打开前的元素(通常是编辑器)
// On cancellation, return focus to the element that opened the switcher, usually the editor.
const previousFocus = document.activeElement as HTMLElement | null
function close() {
ws.switcher = null
@@ -59,7 +59,7 @@ function noteItems(q: string): Item[] {
}
}
if (!q) {
// 最近打开的在前(当前笔记除外,回车即回到上一篇),其余按修改时间
// Recently opened notes first, excluding the current note so Enter returns to the previous one; sort others by modification time.
const rank = (p: string) => recentRank.get(p) ?? Infinity
const mtime = new Map(notes.map(e => [e.path, e.mtime]))
return notes
@@ -79,7 +79,7 @@ function noteItems(q: string): Item[] {
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
)
const items = scored.slice(0, MAX_ITEMS).map(s => toItem(s.path, s.positions))
// 没有同名笔记时提供"新建";输入中的 / 表示目录
// Offer "Create" when no note has the same name; / in the input denotes folders.
const name = q.replace(/\.md$/i, '')
const path = name + NOTE_EXT
if (
@@ -115,7 +115,7 @@ function commandItems(q: string): Item[] {
.filter(c => c.id !== 'app.commandPalette')
.map((c, order) => ({ c, order, m: q ? fuzzyMatch(q, c.title, 0) : { score: 0, positions: [] } }))
.filter(x => x.m)
// 无输入时最近用过的在前,其余保持注册顺序
// With no query, show recently used commands first and preserve registration order for the rest.
scored.sort((a, b) => b.m!.score - a.m!.score || rank(a.c.id) - rank(b.c.id) || a.order - b.order)
return scored.map(({ c, m }) => ({
key: c.id,
@@ -21,7 +21,7 @@ const results = ref<SearchResult[] | null>(null)
const selected = ref(0)
const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
// 只采用最后一次请求的结果,避免慢请求覆盖新结果
// Use only the latest request's results so slow requests cannot overwrite newer results.
let seq = 0
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (q) => {
@@ -50,7 +50,7 @@ async function run(q: string, id: number, version: number) {
}
}
/** 把文本按搜索词(不区分大小写)切分,命中部分标记为高亮 */
/** Split text by case-insensitive search terms and mark matching fragments for highlighting. */
function segments(text: string): { text: string, hit: boolean }[] {
const lower = text.toLowerCase()
const words = terms.value.map(t => t.toLowerCase())
@@ -60,7 +60,7 @@ function change(patch: InlineStyles) {
run(applyInlineStyles(patch), true)
}
// 保留 CM 选区,允许面板输入框获得焦点;不用 pointerdown.prevent,以免 WebKit 丢 click。
// Preserve the CM selection while allowing panel inputs to focus; avoid pointerdown.prevent, which loses clicks in WebKit.
function sync() {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(async () => {
@@ -8,7 +8,7 @@ import { noteTitle } from '@@/utils/paths'
import { nextTick, ref } from 'vue'
import { closeTab, createNote, ws } from '../workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
// Drag sorting: dropping on a tab's left half inserts before it; its right half inserts after it.
const TAB_TYPE = 'application/x-rawmark-tab'
const dragging = ref<string | null>(null)
const dropAt = ref<{ path: string, after: boolean } | null>(null)
@@ -44,7 +44,7 @@ function onDragEnd() {
dragging.value = dropAt.value = null
}
// ---- 右键菜单 ----
// ---- Context menu ----
const menu = ref<{ tab: Tab, x: number, y: number } | null>(null)
const menuElement = ref<HTMLElement>()
@@ -23,7 +23,7 @@ const menuPosition = ref({ left: 0, top: 0 })
const input = ref<HTMLInputElement>()
const draft = ref('')
// 长按打开菜单:Android 会触发 contextmenu,iOS Safari 不会,统一用触摸计时
// Use a touch timer for long-press menus consistently: Android fires contextmenu, but iOS Safari does not.
const LONG_PRESS_MS = 500
let pressTimer: ReturnType<typeof setTimeout> | undefined
let pressStart: { x: number, y: number } | null = null
@@ -48,7 +48,7 @@ function cancelPress() {
}
function activate(event: MouseEvent) {
// 长按后松手产生的点击不打开笔记
// Do not open a note on the click generated when releasing a long press.
if (longPressed) {
longPressed = false
return
@@ -138,7 +138,7 @@ function cancelRename() {
ws.renaming = null
}
// ---- 拖放移动(桌面端) ----
// ---- Drag-and-drop moves (desktop) ----
function onDragStart(e: DragEvent) {
e.dataTransfer?.setData('application/x-rawmark-path', props.node.path)
e.dataTransfer!.effectAllowed = 'move'
+2 -2
View File
@@ -1,11 +1,11 @@
import { watchFontLoading } from '@@/appearance/font-loading'
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
// Unauthenticated editor demo for automated performance and interaction tests.
import { createEditorState, createEditorView } from '../editor'
import sample from './sample.md?raw'
import '@/i18n'
import '@@/appearance/base.css'
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
// ?repeat=N repeats the sample N times for large-document performance testing.
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
const doc = Array.from({ length: Math.max(1, repeat) }).fill(sample).join('\n')
+3 -3
View File
@@ -1,10 +1,10 @@
import { t as translate } from '@@/i18n/runtime'
// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
// Keep content not yet saved to the server in IndexedDB for restoration on the next startup.
export interface Draft {
path: string
content: string
// 本地修改所基于的服务器版本;新建文件时为空字符串
// Server revision underlying local changes; an empty string for new files.
baseRev: string
updatedAt: number
}
@@ -43,7 +43,7 @@ export const drafts = {
request.onsuccess = () => {
const moving = request.result.filter(draft => draft.path === from || draft.path.startsWith(`${from}/`))
for (const draft of moving) store.delete(draft.path)
// add 遇到目标路径已有草稿时中止整个事务,不能覆盖另一份未保存内容。
// add aborts the whole transaction if a draft already exists at the destination, preserving other unsaved contents.
for (const draft of moving) store.add({ ...draft, path: to + draft.path.slice(from.length) })
}
transaction.oncomplete = () => resolve()
+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
}
+8 -8
View File
@@ -1,9 +1,9 @@
// 快速切换的模糊匹配。目标形如"目录/标题",空白分隔的每个词都要命中;
// 连续命中标题 > 连续命中目录 > 按顺序零散命中(如用首字母找笔记)
// Quick-switcher fuzzy matching targets "folder/title" and requires every whitespace-separated term to match.
// Rank contiguous title matches above contiguous folder matches, then ordered scattered matches such as initials.
export interface FuzzyMatch {
score: number
// 命中字符在目标中的下标,用于高亮
// Matched character indices in the target, for highlighting.
positions: number[]
}
@@ -14,7 +14,7 @@ function isBoundary(target: string, i: number): boolean {
}
function matchTerm(term: string, target: string, lower: string, titleStart: number): FuzzyMatch | null {
// 取最后一处连续命中:标题在末尾,优先落在标题上
// Use the last contiguous match: the title is at the end and takes priority.
const at = lower.lastIndexOf(term)
if (at >= 0) {
let score = at >= titleStart ? 100 : 50
@@ -30,13 +30,13 @@ function matchTerm(term: string, target: string, lower: string, titleStart: numb
positions.push(i)
from = i + 1
}
// 越分散得分越低,命中词首的字符加分
// Scattered matches score lower; word-initial matches receive a bonus.
const spread = positions[positions.length - 1] - positions[0] + 1 - positions.length
const boundaries = positions.filter(i => isBoundary(target, i)).length
return { score: Math.max(1, 20 - spread + boundaries * 3), positions }
}
/** titleStart 为标题在 target 中的起始下标;不匹配时返回 null */
/** titleStart is the title's starting index in target; return null when unmatched. */
export function fuzzyMatch(query: string, target: string, titleStart: number): FuzzyMatch | null {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (!terms.length) return null
@@ -49,12 +49,12 @@ export function fuzzyMatch(query: string, target: string, titleStart: number): F
score += m.score
m.positions.forEach(p => positions.add(p))
}
// 标题与输入完全一致时排在最前
// Exact title/query matches rank first.
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
return { score, positions: [...positions].sort((a, b) => a - b) }
}
/** 按命中与否把文本切成连续片段,用于高亮 */
/** Split text into contiguous matched/unmatched fragments for highlighting. */
export function segments(text: string, hits: Set<number>) {
const out: { text: string, hit: boolean }[] = []
for (let i = 0; i < text.length; i++) {
+5 -5
View File
@@ -43,10 +43,10 @@ async function prepareMaintenance() {
}
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const resizingSidebar = ref(false)
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
// Custom fonts require authentication; preserve layout during initialization without briefly showing the default font.
const initializing = ref(true)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
// Drag the sidebar's right edge to resize: update only CSS variables while dragging, then save settings on release.
function startResize(e: PointerEvent) {
const version = user.getSessionVersion()
const handle = e.currentTarget as HTMLElement
@@ -88,16 +88,16 @@ async function logout() {
await user.logout()
} catch (error) {
if (isCancel(error)) return
// 注销错误已由请求层提示。
// The request layer already displays logout errors.
return
}
stopWorkspace()
await router.replace('/login')
}
// 移动端:从屏幕左边缘右滑打开侧栏
// Mobile: swipe right from the screen's left edge to open the sidebar.
let touchX: number | null = null
// 侧栏打开时向左滑关闭(以水平方向为主,避免与列表上下滚动冲突)
// Swipe left to close the open sidebar; require primarily horizontal movement to avoid conflicts with list scrolling.
let touchStart: { x: number, y: number } | null = null
function onTouchStart(e: TouchEvent) {
const t = e.touches[0]
+1 -1
View File
@@ -1,4 +1,4 @@
// 中文按字、其他文字按连续词计数;界面统计不改变 Markdown 原文。
// Count Chinese characters individually and other scripts as contiguous words; statistics do not change the original Markdown.
export function readingStats(text: string) {
const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
const words = han + (text.replace(/\p{Script=Han}/gu, ' ').match(/[\p{L}\p{N}]+/gu)?.length ?? 0)
+1 -1
View File
@@ -1,4 +1,4 @@
// 父目录已经选中时不重复操作其内部项。
// Do not operate on descendants when their parent folder is already selected.
export function minimalPaths(paths: Iterable<string>): string[] {
const out: string[] = []
const roots = new Set<string>()
+1 -1
View File
@@ -149,7 +149,7 @@ export const emojiCategories = [
}, icon: '🏳️' },
]
// 常见图标的显示标签在使用时翻译,避免缓存语言切换前的文案。
// Translate common icon labels at use time so locale changes do not retain cached text.
const keywordKeys: Record<string, string> = {
'📂': 'workspace.folder_directory_open_3744fe',
'📁': 'workspace.folder_directory_796213',
+3 -3
View File
@@ -18,7 +18,7 @@ const collator = computed(() => new Intl.Collator(locale.value, { numeric: true,
function build(entries: Entry[]): TreeNode[] {
const root: TreeNode[] = []
const dirs = new Map<string, TreeNode>()
// 先按路径排序,保证父目录先于子项出现
// Sort paths first so parents precede their children.
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
if (e.dir) dirs.set(e.path, node)
@@ -36,14 +36,14 @@ export const tree = computed(() => build(ws.entries.filter(entry => !hiddenAsset
export const treeIconTarget = shallowRef<{ path: string, anchor: HTMLElement } | null>(null)
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
// null means no drop target; an empty string means the root. File rows target their direct parent folder.
export const dropDirectory = ref<string | null>(null)
const EXPANDED_KEY = 'rawmark.expanded'
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
// 打开或新建笔记时展开其所在的各级目录
// Expand ancestor folders when opening or creating a note.
watch(
() => [ws.active, ws.renaming, ws.image],
(paths) => {
+82 -82
View File
@@ -9,8 +9,8 @@ import { restoreTrashApi } from '@@/apis/trash'
import { getTreeApi, setTreeIconApi } from '@@/apis/tree'
import { appearancePaused } from '@@/appearance/appearance'
import { locale, t as translate } from '@@/i18n/runtime'
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
// Workspace state and operations: tree, tabs, save queue, conflict handling, and live synchronization.
// File operations update the interface optimistically before requesting the server, rolling back on failure for immediate feedback.
import { decodeLinkDestination } from '@@/markdown/links'
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '@@/plugins/host'
@@ -45,18 +45,18 @@ export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
export interface Tab {
path: string
pinned?: boolean
// 最近一次与服务器一致的版本
// Last revision synchronized with the server.
rev: string
status: TabStatus
// 有尚未保存成功的修改
// Changes not yet saved successfully.
dirty: boolean
saveError?: string
// 冲突时服务器上的版本;为空字符串表示服务器上已不存在
// Server revision during conflict; an empty string means the file no longer exists there.
conflictRev?: string
}
const RECENT_KEY = 'rawmark.recent'
// 本机的界面状态(不属于设置):侧栏折叠、大纲面板
// Local interface state outside settings: sidebar collapse and outline panel.
const LAYOUT_KEY = 'rawmark.layout'
const layout = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') as { sidebarCollapsed?: boolean, outlineOpen?: boolean }
const RECENT_MAX = 30
@@ -65,32 +65,32 @@ export const ws = reactive({
entries: [] as Entry[],
tabs: [] as Tab[],
active: null as string | null,
// 目录树中正在行内重命名的路径
// Path currently being renamed inline in the tree.
renaming: null as string | null,
// 移动端侧栏抽屉
// Mobile sidebar drawer.
sidebarOpen: false,
// 侧栏显示搜索而非目录树
// Show search instead of the tree in the sidebar.
searchOpen: false,
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
// Initial quick-switcher/command-palette query; null closes it, and leading > lists commands.
switcher: null as string | null,
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
// Open settings category; null closes it. index is the general entry, showing categories on phones and the first category on desktop.
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
// 「移动到…」对话框(笔记或文件夹路径)
// "Move to..." dialog paths for notes or folders.
moving: null as string | string[] | null,
// 导入目标目录,空字符串为根目录
// Import destination folder; an empty string denotes the root.
importing: null as string | null,
// 图片预览与管理,独立于笔记标签和草稿
// Image preview and management, independent of note tabs and drafts.
image: null as string | null,
images: null as string | null,
imagesAll: false,
// 电脑上侧栏折叠
// Desktop sidebar collapse state.
sidebarCollapsed: layout.sidebarCollapsed ?? false,
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
// Outline panel: persistent on the right on desktop, an overlay on phones.
outlineOpen: layout.outlineOpen ?? false,
sourceMode: false,
focusMode: false,
view: 'notes' as 'notes' | 'graph' | 'tags',
// 最近打开的笔记,最新的在前
// Recently opened notes, newest first.
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
})
@@ -98,15 +98,15 @@ const SAVE_DELAY = 500
const RETRY_DELAY = 5000
const MAX_RETRIES = 12
// 以下状态不放进 reactive:EditorState 体积大且不可变,交给 Vue 代理没有意义
// Keep the following state outside reactive: large immutable EditorState objects gain nothing from Vue proxies.
const editorStates = new Map<string, EditorState>()
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
// Each open note has a mutable reference through which editor extensions read its current path, updated on rename.
const noteRefs = new Map<string, { path: string }>()
interface SaveSlot {
timer?: ReturnType<typeof setTimeout>
inflight?: Promise<void>
// 修改序号与已保存序号,二者相等即没有待保存的修改
// Change and saved sequence numbers; equality means no pending changes.
seq: number
savedSeq: number
uploads: number
@@ -121,12 +121,12 @@ let workspaceEpoch = 0
let draftPreserved: Promise<void> = Promise.resolve()
let view: EditorView | null = null
// 进行中的本地文件操作数;期间收到的目录事件不用于判断标签页是否被删除
// In-flight local file operations; tree events during these operations must not determine whether tabs were deleted.
let opsInFlight = 0
const externalChange = Annotation.define<boolean>()
// 编辑器内容或光标变化的订阅者(大纲面板);不经过 Vue 响应式,避免每次按键都触发组件更新
// Editor content/cursor subscribers, such as the outline, bypass Vue reactivity to avoid component updates on every keystroke.
const editorListeners = new Set<() => void>()
export function onEditorUpdate(fn: () => void): () => void {
editorListeners.add(fn)
@@ -145,13 +145,13 @@ function errorText(err: unknown): string {
return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064')
}
// ---- 编辑器状态 ----
// ---- Editor state ----
export function attachView(v: EditorView | null) {
view = v
}
/** 当前显示的编辑器视图(手机工具条等需要直接操作编辑器) */
/** Current editor view, accessed directly by controls such as the mobile toolbar. */
export function editorView(): EditorView | null {
return view
}
@@ -169,7 +169,7 @@ export function toggleSourceMode() {
}
}
/** 从相册/文件中选择图片插入到光标处 */
/** Choose an image from photos/files and insert it at the cursor. */
export function insertImagesFromPicker() {
const v = view
const tab = activeTab()
@@ -188,12 +188,12 @@ export function editorStateOf(path: string): EditorState | undefined {
return editorStates.get(path)
}
// 笔记的身份对象:重命名/移动后保持不变,用于判断编辑器中显示的是否仍是同一篇笔记
// Note identity survives rename/move and determines whether the editor still displays the same note.
export function noteRefOf(path: string): { path: string } | undefined {
return noteRefs.get(path)
}
// 切换标签页前由编辑器面板调用,保存当前视图状态(含光标、滚动无关的选区与撤销历史)
// Called by the editor pane before tab switches to save view state, including cursor, non-scroll selection state, and undo history.
export function storeViewState(path: string, state: EditorState) {
if (editorStates.has(path)) editorStates.set(path, state)
}
@@ -271,7 +271,7 @@ function buildState(path: string, doc: string): EditorState {
function reconfigurePluginStates() {
const effects = [languageCompartment.reconfigure(pluginLanguage()), pluginCompartment.reconfigure(pluginEditorExtensions()), pluginRefresh.of(null)]
// 先计算全部新状态,失败时让注册方撤回;不让后台标签保留已停用扩展。
// Compute every new state first so the registering party can roll back on failure; background tabs must not retain disabled extensions.
const updates = [...editorStates].map(([path, state]) => {
const current = path === ws.active && view ? view.state : state
return { path, transaction: current.update({ effects }) }
@@ -290,7 +290,7 @@ const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options)
const initialRevision = tab?.rev
const initialSequence = saved?.seq ?? 0
const rev = (await writeFileApi({ path, content, ifMatch: 'create' in options ? '' : options.ifMatch })).data.rev
// 用户可能已完成下一次保存,或已收到更新版本;迟到响应不能退回正文与 rev。
// A later save or newer revision may already exist; late responses must not revert contents or rev.
if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) {
replaceDoc(path, content); tab.rev = rev
}
@@ -298,7 +298,7 @@ const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options)
return { rev }
}
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
// Replace document contents with server text by changing only the difference between shared prefix/suffix, preserving the cursor where possible and bypassing undo history.
function replaceDoc(path: string, content: string) {
const state = path === ws.active && view ? view.state : editorStates.get(path)
if (!state) return
@@ -325,7 +325,7 @@ function docOf(path: string): string | undefined {
return editorStates.get(path)?.doc.toString()
}
// ---- 保存队列 ----
// ---- Save queue ----
function slot(path: string): SaveSlot {
let s = slots.get(path)
@@ -342,7 +342,7 @@ function scheduleSave(path: string, delay = SAVE_DELAY) {
s.timer = setTimeout(() => void flush(path), delay)
}
// 立即保存一篇笔记的待保存修改;keepalive 用于页面隐藏/关闭时尽力发出请求
// Immediately save pending changes for one note; keepalive attempts delivery when the page hides or closes.
export async function flush(path: string, keepalive = false): Promise<void> {
const version = user.getSessionVersion()
const s = slots.get(path)
@@ -353,7 +353,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
const tab = tabOf(path)
if (!tab || tab.status !== 'ready' || s.seq === s.savedSeq) return
if (s.paused || s.uploads) {
// 图片上传中或文件正在移动,稍后再存,避免写入临时 blob: 地址或写到旧路径
// Defer saves during image uploads or file moves to avoid temporary blob: URLs or writes to old paths.
s.timer = setTimeout(() => void flush(path), SAVE_DELAY)
return
}
@@ -362,8 +362,8 @@ export async function flush(path: string, keepalive = false): Promise<void> {
const seq = s.seq
const baseRev = tab.rev
s.inflight = (async () => {
// 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出
// 草稿只是兜底,写入失败不影响保存本身
// Run draft persistence and the request in parallel; waiting for IndexedDB first often prevents the request from starting during page close.
// Drafts are a recovery fallback; draft-write failures do not block saving itself.
const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch(err => console.warn('写入本地草稿失败', err))
try {
const rev = (await writeFileApi({ path, content, ifMatch: baseRev, keepalive })).data.rev
@@ -384,7 +384,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
tab.status = serverRev ? 'conflict' : 'deleted'
tab.conflictRev = serverRev
} else if (!(err instanceof ApiError) || err.status >= 500) {
// 网络错误与服务器错误可重试,草稿已在本地,不会丢
// Network/server failures can be retried; the local draft preserves contents.
tab.saveError = s.retries < MAX_RETRIES ? translate('workspace.save_failed_retrying_d62793') : translate('workspace.save_failed_check_your_connection_c0913f')
if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY)
} else {
@@ -407,15 +407,15 @@ export async function flushAll(keepalive = false) {
await Promise.all([...slots.keys()].map(p => flush(p, keepalive)))
}
// 保存失败会留在标签页状态中,flushAll 完成并不意味着可以安全刷新。
// Save failures remain in tab state; completion of flushAll does not guarantee a safe refresh.
export function readyToReload(): boolean {
return opsInFlight === 0 && !ws.tabs.some(t => t.dirty || t.status === 'loading')
&& [...slots.values()].every(s => !s.uploads && !s.paused && !s.inflight)
}
// ---- 冲突处理 ----
// ---- Conflict handling ----
// 放弃本地修改,载入服务器版本
// Discard local changes and load the server version.
export async function useServerVersion(path: string) {
const tab = tabOf(path)
if (!tab) return
@@ -432,7 +432,7 @@ export async function useServerVersion(path: string) {
}
}
// 保留本地修改,覆盖服务器版本(服务器上已删除时重新创建)
// Keep local changes and overwrite the server version, recreating the file if deleted there.
export async function keepLocalVersion(path: string) {
const version = user.getSessionVersion()
const tab = tabOf(path)
@@ -471,17 +471,17 @@ export async function keepLocalVersion(path: string) {
if (tab.dirty && tab.status === 'ready') void flush(path)
}
// ---- 打开并聚焦(快速切换、搜索结果跳转) ----
// ---- Open and focus (quick switcher and search results) ----
interface RevealTarget {
path: string
line: number
terms: string[]
}
// 笔记可能尚未载入或编辑器尚未显示,由编辑器面板在显示该笔记后执行
// The note may not be loaded or its editor visible yet; the editor pane performs this after displaying the note.
let pendingReveal: RevealTarget | null = null
/** 打开笔记并聚焦编辑器;line > 0 时定位到该行(从 1 开始)并选中该行第一个搜索词 */
/** Open a note and focus the editor; line > 0 targets that 1-based line and selects its first search-term match. */
export async function openAt(path: string, line = 0, terms: string[] = []) {
pendingReveal = { path, line, terms }
if (path === ws.active && view && editorStates.has(path)) {
@@ -492,12 +492,12 @@ export async function openAt(path: string, line = 0, terms: string[] = []) {
}
}
/** 编辑器面板显示笔记后调用;返回 true 表示已定位到指定行(不应再恢复原来的滚动位置) */
/** Called after the editor pane displays the note; true means the requested line was reached, so do not restore the old scroll position. */
export function revealPending(v: EditorView, path: string): boolean {
const r = pendingReveal
if (!r || r.path !== path) return false
pendingReveal = null
// 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
// Focus opens software keyboards on touch devices and covers content; focus only devices with a mouse.
if (!matchMedia('(pointer: coarse)').matches) v.focus()
if (r.line === 0) return false
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
@@ -513,14 +513,14 @@ export function revealPending(v: EditorView, path: string): boolean {
return true
}
// ---- 标签页 ----
// ---- Tabs ----
export async function openNote(path: string) {
const version = user.getSessionVersion()
ws.view = 'notes'
ws.images = null
ws.sidebarOpen = false
// 目录中的新笔记先乐观显示,读取前须等它真正创建成功。
// New tree notes appear optimistically; wait for actual creation before reading them.
const pending = creating.get(path)
if (pending && !(await pending)) return
user.assertSession(version)
@@ -591,7 +591,7 @@ function forget(path: string) {
slots.delete(path)
}
// 路径前缀替换(移动文件夹时其下所有路径一起变化)
// Replace path prefixes; moving a folder changes every descendant path together.
function remapPath(p: string, from: string, to: string): string | null {
if (p === from) return to
if (p.startsWith(`${from}/`)) return to + p.slice(from.length)
@@ -625,11 +625,11 @@ function affected(path: string): string[] {
return ws.tabs.map(t => t.path).filter(p => p === path || p.startsWith(`${path}/`))
}
// ---- 目录树 ----
// ---- Directory tree ----
// 目录刷新:同一时间只有一个请求;请求期间又有刷新请求或本地操作时,结果可能已过时,丢弃后重新获取。
// 本地操作会先乐观修改目录,过时的结果会抹掉刚加入的条目(例如新建笔记的重命名框随之消失)。
// 等待中的调用者在下一次被采用的结果(或请求失败)后统一完成
// Tree refresh permits one request at a time; discard and refetch if another refresh or local operation makes its result stale.
// Local operations update the tree optimistically; stale results would erase new entries, such as a new note's rename input.
// Waiting callers settle together after the next accepted result or request failure.
let treeTimer: ReturnType<typeof setTimeout> | undefined
let treeFetching = false
let treeAgain = false
@@ -669,7 +669,7 @@ async function fetchTree() {
}
treeFetching = false
if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) {
// 有操作进行中时由 withOp 在全部结束后重新调度
// During active operations, withOp reschedules once all operations finish.
if (opsInFlight === 0) scheduleTreeRefresh()
return
}
@@ -730,10 +730,10 @@ export async function createNote(dir: string) {
await createNoteAt(path)
}
// 进行中的新建:新建后立即出现重命名输入框,重命名须等文件真正创建后再执行
// In-flight creation: immediately show the rename input, but wait for actual file creation before renaming.
const creating = new Map<string, Promise<boolean>>()
/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
/** Create and open an empty note at the requested path and report success; the server creates missing parent folders. */
export async function createNoteAt(path: string, content = ''): Promise<boolean> {
ws.view = 'notes'
ws.images = null
@@ -769,7 +769,7 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
}
export async function importNotes(dir: string, folder: string, files: File[]) {
// refreshTree 必须等 withOp 结束,否则会等待自身释放操作计数。
// refreshTree must wait until withOp ends, or it would wait for its own operation count to be released.
const result = await withOp(() => importNotesApi({ dir, folder, files }).then(({ data }) => data))
ws.searchOpen = false
try {
@@ -820,16 +820,16 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
return operationFailed(translate('workspace.already_exists_6af434', { p0: basename(to) }), errorOwner)
}
const result = await withOp<FileResult>(async () => {
// 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。
// Moves update relative vault links; save all open notes first, retaining revision-conflict protection.
const impacted = () => ws.tabs.map(t => t.path)
const busy = () => impacted().some(p => slots.get(p)?.uploads || slots.get(p)?.paused)
if (busy()) {
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
}
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
// Save pending edits first so the latest contents move; pause saving during the move to avoid writing to old paths.
await Promise.all(impacted().map(p => flush(p)))
user.assertSession(version)
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
// Pasting may start while awaiting saves; check again before acquiring the move lock.
if (busy()) {
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
}
@@ -844,7 +844,7 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
let draftsMoved = false
let remapped = false
try {
// 先确认持久草稿能够迁移;失败时服务器与界面均保留原路径。
// Verify persistent drafts can migrate first; on failure, keep original paths on both the server and interface.
await drafts.move(from, to)
user.assertSession(version)
draftsMoved = true
@@ -875,12 +875,12 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
} finally {
paused.forEach(s => s.paused--)
}
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
// The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization.
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
const tab = tabOf(p)
if (!tab) return
if (tab.dirty) {
// 移动期间又有输入:本地内容仍含旧链接,交给用户选择
// Input during the move still contains old links; let the user resolve it.
const { rev } = await readNoteFile(p)
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
} else {
@@ -912,7 +912,7 @@ export async function remove(path: string, errorOwner: 'operation' | 'caller' =
return operationFailed(translate('workspace.save_your_notes_and_resolve_5981a5'), errorOwner)
}
const snapshot = ws.entries
ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // Remove the item and all descendants.
for (const p of paths) {
if (!(await closeTab(p))) {
ws.entries = snapshot; return operationFailed(translate('workspace.could_not_close_the_note_cd2a80'), errorOwner)
@@ -949,7 +949,7 @@ export async function restore(id: string) {
}
}
// 版本只用于刷新浏览器图片,不改变 Markdown 中保存的相对路径。
// The version refreshes browser images only; relative Markdown paths remain unchanged.
export function imageUrl(path: string): string {
const entry = ws.entries.find(e => e.path === path)
return imageResourceRoot() + encodePath(path) + (entry ? `?v=${entry.mtime}-${entry.size}` : '')
@@ -965,7 +965,7 @@ export async function replaceImage(path: string, file: File) {
}
}
// ---- 实时同步 ----
// ---- Live synchronization ----
async function syncOpenTab(tab: Tab) {
const s = slots.get(tab.path)
@@ -1002,7 +1002,7 @@ async function handleEvents(evs: VaultEvent[]) {
await refreshPlugins()
} else if (ev.type === 'changed') {
const tab = tabOf(ev.path!)
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
// Own-save events match the local rev; do not overwrite local edits, and let the next save detect conflicts.
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image
@@ -1010,14 +1010,14 @@ async function handleEvents(evs: VaultEvent[]) {
const paused = affected(ev.from!).map(slot)
paused.forEach(s => s.paused++)
try {
// 已发出的保存仍可能写入旧键,先等其草稿事务结束。
// In-flight saves may still write the old key; wait for their draft transactions first.
await Promise.all(paused.map(s => s.inflight))
user.assertSession(version)
if (epoch !== workspaceEpoch) return
try {
await drafts.move(ev.from!, ev.path!)
} catch (cause) {
// 外部文件已移动,草稿失败不能阻止编辑器跟随;保留原记录并明确提示。
// The external file already moved, so draft failures must not prevent editor tracking; retain the original record and report failure explicitly.
toast(translate('workspace.could_not_migrate_the_local_4c513a', { p0: cause instanceof Error ? cause.message : errorText(cause) }))
}
user.assertSession(version)
@@ -1056,11 +1056,11 @@ async function resync() {
await refreshPlugins()
}
// ---- 启动 ----
// ---- Startup ----
const TABS_KEY = 'rawmark.tabs'
// 恢复未保存成功的草稿:服务器版本未变则继续保存,已变则进入冲突状态由用户选择
// Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution.
async function recoverDrafts() {
const version = user.getSessionVersion()
for (const d of await drafts.all()) {
@@ -1100,7 +1100,7 @@ export async function startWorkspace() {
workspaceRunning = false
await prepareResources()
user.assertSession(version)
// 设置读取失败不影响使用,沿用本地缓存的值
// Settings-read failures do not prevent use; retain locally cached values.
const settingsLoaded = loadSettings().catch((err) => {
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
})
@@ -1123,7 +1123,7 @@ export async function startWorkspace() {
let eventQueue = Promise.resolve()
let stopped = false
const enqueue = (work: () => Promise<void>) => {
// 同步读取是异步的,后续批次不能越过前一批次的移动和正文更新。
// Synchronization reads are asynchronous; later batches must not overtake earlier moves or content updates.
eventQueue = eventQueue.then(() => {
if (!stopped) return work()
}).catch((err) => {
@@ -1147,7 +1147,7 @@ export async function startWorkspace() {
return { path: tab.path, content: state.doc.toString(), rev: tab.rev, dirty: tab.dirty }
},
})
// 重新登录后工作区会再次启动,以下全局监听只注册一次
// The workspace starts again after signing in; register the following global listeners only once.
if (listenersInstalled) return
listenersInstalled = true
watch(
@@ -1155,7 +1155,7 @@ export async function startWorkspace() {
(tabs) => {
if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs))
},
// 启动期间(如等待目录刷新时)打开的标签页也要立即记录
// Record tabs opened during startup immediately, including while waiting for tree refresh.
{ immediate: true },
)
watch(
@@ -1173,7 +1173,7 @@ export async function startWorkspace() {
() => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen, focusMode: ws.focusMode }),
(l) => {
localStorage.setItem(LAYOUT_KEY, JSON.stringify(l))
// 编辑区宽度变化,需要重新测量
// Editor width changed; request remeasurement.
requestAnimationFrame(() => view?.requestMeasure())
},
)
@@ -1188,7 +1188,7 @@ export async function startWorkspace() {
}
}
})
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
// Font-size and text-width changes affect line heights; request editor remeasurement.
watch(
() => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight],
() => view?.requestMeasure(),
@@ -1212,7 +1212,7 @@ export async function startWorkspace() {
)
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
window.addEventListener('rawmark-plugin-notice', event => toast((event as CustomEvent<string>).detail))
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
// Attempt to send pending saves before page hide/close; some browsers fire only pagehide on tab close.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void flushAll(true)
})
@@ -1223,13 +1223,13 @@ export async function startWorkspace() {
window.addEventListener('keydown', (event) => {
if (workspaceRunning) handleShortcut(event)
}, true)
// 软键盘弹出、编辑区变矮后,让光标保持可见
// Keep the cursor visible when the software keyboard reduces editor height.
trackVisualViewport(() => {
if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) })
})
}
// 内置命令不依赖工作区状态,模块载入时注册,排在组件注册的命令(如退出登录)之前
// Built-in commands do not depend on workspace state; register at module load before component commands such as sign-out.
registerBuiltinCommands()
function registerBuiltinCommands() {
@@ -1313,13 +1313,13 @@ function registerBuiltinCommands() {
}
}
// 手机上侧栏是抽屉,电脑上折叠/展开
// The sidebar is a drawer on phones and collapsible on desktop.
function toggleSidebar() {
if (matchMedia('(max-width: 768px)').matches) ws.sidebarOpen = !ws.sidebarOpen
else ws.sidebarCollapsed = !ws.sidebarCollapsed
}
// 需要在目录树中操作的命令:切回目录树,移动端打开侧栏
// For tree operations, switch back to the tree and open the sidebar on mobile.
function withSidebar(fn: () => unknown) {
ws.searchOpen = false
ws.sidebarOpen = true
@@ -1331,7 +1331,7 @@ export function openSearch() {
ws.searchOpen = true
ws.sidebarOpen = true
ws.sidebarCollapsed = false
// 已打开时重新聚焦并全选,便于直接输入新的搜索词
// If already open, refocus and select all so users can immediately enter a new query.
void nextTick(() => {
const input = document.querySelector<HTMLInputElement>('.rm-search-input')
input?.focus()