feat(i18n): add English and Simplified Chinese

This commit is contained in:
eezd committed 2026-10-26 22:04:33 +08:00
1 parent 38c4c8b781
commit 1e9e0678ee
149 files changed
+5356 -1144

No files matched your search

+3 -1
View File
@@ -7,6 +7,7 @@ import { StateField } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view'
import { codeContentRange, fenceLanguage } from './codeblocks'
import { editorFocused, setEditorFocus } from './focus'
import { editorLocale } from './locale'
import { CodeRendererWidget, MathWidget } from './widgets'
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
@@ -50,7 +51,8 @@ export const renderedBlocks = StateField.define<DecorationSet>({
create: buildBlocks,
update(value, tr) {
if (
tr.docChanged
tr.startState.facet(editorLocale) !== tr.state.facet(editorLocale)
|| tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus) || e.is(pluginRefresh))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
+2 -1
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
import TurndownService from 'turndown'
@@ -14,7 +15,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
try {
node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href)
} catch (error) {
onError(new Error('来源基址无效,已保留原链接', { cause: error }))
onError(new Error(translate('workspace.invalid_source_base_url_original_f0ec05'), { cause: error }))
}
}
}
+3
View File
@@ -1,6 +1,7 @@
import type { Extension } from '@codemirror/state'
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown'
import { locale } from '@@/i18n/runtime'
import { typoraExtensions } from '@@/markdown/extensions'
import { editorExtensions, markdownSyntax, reportEditorFailure } from '@@/plugins/registry'
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
@@ -12,6 +13,7 @@ import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
import { livePreview, openLink, resolveImageUrl } from './livepreview'
import { editorLocale, localeCompartment } from './locale'
import { defaultEditorKeys } from './shortcuts'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
@@ -56,6 +58,7 @@ export interface EditorStateOptions {
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 高亮,
@@ -7,6 +7,7 @@ import { syntaxTree } from '@codemirror/language'
import { Facet, StateEffect } from '@codemirror/state'
import { Decoration, ViewPlugin } from '@codemirror/view'
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
import { editorLocale } from './locale'
import {
BulletWidget,
CheckboxWidget,
@@ -363,6 +364,7 @@ const livePreviewPlugin = ViewPlugin.fromClass(
if (
this.stale
|| u.transactions.some(tr => tr.effects.some(effect => effect.is(refreshImages)))
|| u.startState.facet(editorLocale) !== u.state.facet(editorLocale)
|| u.docChanged
|| u.viewportChanged
|| u.selectionSet
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { applyLocale } from '@@/i18n/runtime'
import { undo, undoDepth } from '@codemirror/commands'
import { afterEach, expect, it, vi } from 'vitest'
import { createEditorState, createEditorView } from './index'
import { editorLocale, localeCompartment } from './locale'
const views: ReturnType<typeof createEditorView>[] = []
afterEach(() => {
views.splice(0).forEach(view => view.destroy())
document.body.replaceChildren()
applyLocale('en')
})
it('切换语言更新表格和代码控件,保留正文、选区、撤销历史及缓存状态', () => {
applyLocale('en')
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nlet x = 1\n```\n\nEnd'
const host = document.createElement('div')
document.body.append(host)
const view = createEditorView(host, createEditorState({ doc }))
views.push(view)
view.dispatch({ changes: { from: doc.length, insert: '!' }, selection: { anchor: doc.length + 1 } })
const content = view.state.doc.toString()
const selection = view.state.selection
const history = undoDepth(view.state)
expect(view.dom.querySelector('.cm-rm-code-copy')?.getAttribute('title')).toBe('Copy code')
expect(view.dom.querySelector('.cm-rm-table-toolbar')?.textContent).toContain('Source')
let cached = createEditorState({ doc: 'Background note' })
applyLocale('zh-CN')
const effects = localeCompartment.reconfigure(editorLocale.of('zh-CN'))
view.dispatch({ effects })
cached = cached.update({ effects }).state
expect(view.state.doc.toString()).toBe(content)
expect(view.state.selection.eq(selection)).toBe(true)
expect(undoDepth(view.state)).toBe(history)
expect(cached.doc.toString()).toBe('Background note')
expect(cached.facet(editorLocale)).toBe('zh-CN')
expect(view.dom.querySelector('.cm-rm-code-copy')?.getAttribute('title')).toBe('复制代码')
expect(view.dom.querySelector('.cm-rm-table-toolbar')?.textContent).toContain('源码')
expect(undo(view)).toBe(true)
expect(view.state.doc.toString()).toBe(doc)
})
it('空 Mermaid 提示随语言双向更新,不改正文或撤销历史', async () => {
applyLocale('en')
const doc = '```mermaid\n\n```\n\nEnd'
const host = document.createElement('div')
document.body.append(host)
const view = createEditorView(host, createEditorState({ doc }))
views.push(view)
view.dispatch({ changes: { from: doc.length, insert: '!' } })
const content = view.state.doc.toString()
const history = undoDepth(view.state)
await vi.waitFor(() => expect(view.dom.querySelector('.cm-rm-mermaid')?.textContent).toBe('Empty diagram'))
for (const [language, label] of [['zh-CN', '空图表'], ['en', 'Empty diagram']] as const) {
applyLocale(language)
view.dispatch({ effects: localeCompartment.reconfigure(editorLocale.of(language)) })
await vi.waitFor(() => expect(view.dom.querySelector('.cm-rm-mermaid')?.textContent).toBe(label))
expect(view.state.doc.toString()).toBe(content)
expect(undoDepth(view.state)).toBe(history)
}
})
+5
View File
@@ -0,0 +1,5 @@
import { locale } from '@@/i18n/runtime'
import { Compartment, Facet } from '@codemirror/state'
export const editorLocale = Facet.define<string, string>({ combine: values => values[0] ?? locale.value })
export const localeCompartment = new Compartment()
+2 -1
View File
@@ -1,4 +1,5 @@
import type { EditorState, Extension, TransactionSpec } from '@codemirror/state'
import { t as translate } from '@@/i18n/runtime'
import { isolateHistory } from '@codemirror/commands'
import { Facet, StateEffect, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
@@ -78,7 +79,7 @@ function insertHTML(view: EditorView, html: string, text: string, opts: PasteIma
})
}
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
opts.onError(new Error(translate('workspace.could_not_convert_the_web_4f0e6a'), { cause: error }))
return text
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
}
+51 -14
View File
@@ -1,4 +1,5 @@
import type { Command, KeyBinding } from '@codemirror/view'
import { t as translate } from '@@/i18n/runtime'
import { typoraKeys } from '@@/utils/typora-keys'
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import { selectCodeBlock } from './codeblocks'
@@ -6,20 +7,56 @@ import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleI
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
export const editorCommands: { id: string, title: string, keys: string[], run: Command }[] = [
{ id: 'editor.bold', title: '粗体', keys: typoraKeys('editor.bold'), run: toggleBold },
{ id: 'editor.italic', title: '斜体', keys: typoraKeys('editor.italic'), run: toggleItalic },
{ id: 'editor.underline', title: '下划线', keys: typoraKeys('editor.underline'), run: toggleUnderline },
{ id: 'editor.strike', title: '删除线', keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
{ id: 'editor.highlight', title: '高亮', keys: [], run: toggleSelectionMark('Highlight') },
{ id: 'editor.code', title: '行内代码', keys: typoraKeys('editor.code'), run: toggleCode },
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, title: { task: '任务列表', bullet: '无序列表', ordered: '有序列表', quote: '引用' }[kind], keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
{ id: 'editor.link', title: '插入链接', keys: typoraKeys('editor.link'), run: insertLink },
{ id: 'editor.undo', title: '撤销', keys: typoraKeys('editor.undo'), run: undo },
{ id: 'editor.redo', title: '重做', keys: typoraKeys('editor.redo'), run: redo },
{ id: 'editor.indent', title: '增加缩进', keys: typoraKeys('editor.indent'), run: indentMore },
{ id: 'editor.outdent', title: '减少缩进', keys: typoraKeys('editor.outdent'), run: indentLess },
{ id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
{ id: 'editor.bold', get title() {
return translate('workspace.bold_f6da93')
}, keys: typoraKeys('editor.bold'), run: toggleBold },
{ id: 'editor.italic', get title() {
return translate('workspace.italic_217901')
}, keys: typoraKeys('editor.italic'), run: toggleItalic },
{ id: 'editor.underline', get title() {
return translate('workspace.underline_d3b4a2')
}, keys: typoraKeys('editor.underline'), run: toggleUnderline },
{ id: 'editor.strike', get title() {
return translate('workspace.strikethrough_5b0508')
}, keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
{ id: 'editor.highlight', get title() {
return translate('workspace.highlight_05f954')
}, keys: [], run: toggleSelectionMark('Highlight') },
{ id: 'editor.code', get title() {
return translate('workspace.inline_code_6711d9')
}, keys: typoraKeys('editor.code'), run: toggleCode },
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, get title() {
return { get task() {
return translate('workspace.task_list_c5ced4')
}, get bullet() {
return translate('workspace.bulleted_list_9ee577')
}, get ordered() {
return translate('workspace.numbered_list_5d0bb2')
}, get quote() {
return translate('workspace.block_quote')
} }[kind]
}, keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
{ id: 'editor.link', get title() {
return translate('workspace.insert_link_1ad3ca')
}, keys: typoraKeys('editor.link'), run: insertLink },
{ id: 'editor.undo', get title() {
return translate('workspace.undo_926a50')
}, keys: typoraKeys('editor.undo'), run: undo },
{ id: 'editor.redo', get title() {
return translate('workspace.redo_03717b')
}, keys: typoraKeys('editor.redo'), run: redo },
{ id: 'editor.indent', get title() {
return translate('workspace.increase_indent_cfd42c')
}, keys: typoraKeys('editor.indent'), run: indentMore },
{ id: 'editor.outdent', get title() {
return translate('workspace.decrease_indent_efd955')
}, keys: typoraKeys('editor.outdent'), run: indentLess },
{ id: 'editor.selectAll', get title() {
return translate('workspace.select_all_select_code_inside_8dfb27')
}, keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, get title() {
return translate('workspace.heading_a4f510', { p0: i + 1 })
}, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
]
export const defaultEditorKeys: KeyBinding[] = editorCommands.flatMap(command => command.keys.map(key => ({ key: key.replaceAll('+', '-').replace(/-[A-Z]$/, letter => letter.toLowerCase()), run: command.run, preventDefault: true })))
+16 -7
View File
@@ -1,6 +1,7 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { locale, t as translate } from '@@/i18n/runtime'
import { renderInlineMarkdown } from '@@/markdown/html'
import { hydrate } from '@@/markdown/render'
import { isolateHistory, redo, undo } from '@codemirror/commands'
@@ -8,6 +9,7 @@ import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { editorFocused, setEditorFocus } from './focus'
import { editorLocale } from './locale'
type Align = '' | 'left' | 'center' | 'right'
@@ -116,6 +118,7 @@ interface WidgetState {
const widgetStates = new WeakMap<HTMLElement, WidgetState>()
class TableWidget extends WidgetType {
readonly locale = locale.value
readonly key: string
constructor(readonly model: TableModel) {
@@ -124,7 +127,7 @@ class TableWidget extends WidgetType {
}
eq(other: TableWidget) {
return other.key === this.key
return other.locale === this.locale && other.key === this.key
}
get estimatedHeight() {
@@ -134,6 +137,7 @@ class TableWidget extends WidgetType {
toDOM(view: EditorView) {
const wrap = document.createElement('div')
wrap.className = 'cm-rm-table-widget'
wrap.lang = this.locale
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
const scroller = document.createElement('div')
@@ -146,6 +150,10 @@ class TableWidget extends WidgetType {
updateDOM(dom: HTMLElement, view: EditorView) {
const st = widgetStates.get(dom)
if (!st) return false
if (dom.lang !== this.locale) {
dom.querySelector('.cm-rm-table-toolbar')!.replaceWith(this.toolbar(view, dom))
dom.lang = this.locale
}
const old = st.model
st.model = this.model
const sameShape
@@ -230,13 +238,13 @@ class TableWidget extends WidgetType {
sep.className = 'cm-rm-sep'
tools.append(sep)
}
button(ICONS.addRow, '行', '在下方插入行', () => insertRow(view, wrap))
button(ICONS.addColumn, '列', '在右侧插入列', () => insertColumn(view, wrap))
button(ICONS.addRow, translate('workspace.line_4cc7b0'), translate('workspace.insert_row_below_7c131f'), () => insertRow(view, wrap))
button(ICONS.addColumn, translate('workspace.column_c06d21'), translate('workspace.insert_column_to_the_right_8ae98f'), () => insertColumn(view, wrap))
separator()
button(ICONS.deleteRow, '行', '删除当前行', () => deleteRow(view, wrap), true)
button(ICONS.deleteColumn, '列', '删除当前列', () => deleteColumn(view, wrap), true)
button(ICONS.deleteRow, translate('workspace.line_4cc7b0'), translate('workspace.delete_current_row_3bc4de'), () => deleteRow(view, wrap), true)
button(ICONS.deleteColumn, translate('workspace.column_c06d21'), translate('workspace.delete_current_column_435bde'), () => deleteColumn(view, wrap), true)
separator()
button(ICONS.source, '源码', '以 Markdown 源码编辑', () => {
button(ICONS.source, translate('workspace.source_41b50a'), translate('workspace.edit_as_markdown_source_9375e8'), () => {
const model = currentModel(view, wrap)
if (!model) return
view.dispatch({ selection: { anchor: model.from } })
@@ -439,7 +447,8 @@ export const tableField = StateField.define<DecorationSet>({
create: buildTables,
update(value, tr) {
if (
tr.docChanged
tr.startState.facet(editorLocale) !== tr.state.facet(editorLocale)
|| tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
+15 -9
View File
@@ -1,4 +1,5 @@
import type { OwnedRenderer } from '@@/plugins/registry'
import { locale, t as translate } from '@@/i18n/runtime'
import { emojiFor, renderMath } from '@@/markdown/render'
import { mountContent } from '@@/plugins/mount'
import { copyText } from '@@/utils/clipboard'
@@ -66,6 +67,7 @@ const CHECK_ICON
// 代码块右上角:语言标签(未激活时)+ 复制按钮
export class CodeHeaderWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly lang: string,
readonly showLang: boolean,
@@ -76,7 +78,7 @@ export class CodeHeaderWidget extends WidgetType {
}
eq(other: CodeHeaderWidget) {
return other.lang === this.lang && other.showLang === this.showLang
return other.locale === this.locale && other.lang === this.lang && other.showLang === this.showLang
}
toDOM(view: EditorView) {
@@ -91,7 +93,7 @@ export class CodeHeaderWidget extends WidgetType {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'cm-rm-code-copy'
btn.title = '复制代码'
btn.title = translate('workspace.copy_code_420c20')
btn.innerHTML = COPY_ICON
btn.addEventListener('mousedown', e => e.preventDefault())
btn.addEventListener('click', async () => {
@@ -175,6 +177,7 @@ function showRenderError(el: HTMLElement, err: unknown) {
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
*/
export class MathWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly tex: string,
readonly display: boolean,
@@ -184,7 +187,7 @@ export class MathWidget extends WidgetType {
}
eq(other: MathWidget) {
return other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
return other.locale === this.locale && other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
}
toDOM(view: EditorView) {
@@ -205,7 +208,7 @@ export class MathWidget extends WidgetType {
private render(el: HTMLElement, view: EditorView) {
if (!this.tex.trim()) {
el.textContent = '空公式'
el.textContent = translate('workspace.empty_formula_fd803b')
el.classList.add('cm-rm-placeholder')
return
}
@@ -224,6 +227,7 @@ const rendererMounts = new WeakMap<HTMLElement, () => void>()
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
export class CodeRendererWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly code: string,
readonly editOffset: number | null,
@@ -235,7 +239,7 @@ export class CodeRendererWidget extends WidgetType {
}
eq(other: CodeRendererWidget) {
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
return other.locale === this.locale && other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
}
toDOM(view: EditorView) {
@@ -313,6 +317,7 @@ export class EmojiWidget extends WidgetType {
}
export class FootnoteRefWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly label: string,
readonly definition: string | undefined,
@@ -321,14 +326,14 @@ export class FootnoteRefWidget extends WidgetType {
}
eq(other: FootnoteRefWidget) {
return other.label === this.label && other.definition === this.definition
return other.locale === this.locale && other.label === this.label && other.definition === this.definition
}
toDOM(view: EditorView) {
const el = document.createElement('sup')
el.className = 'cm-rm-footnote-ref'
el.textContent = this.label
el.title = this.definition ?? '未找到脚注定义'
el.title = this.definition ?? translate('workspace.footnote_definition_not_found_1c0993')
editOnMousedown(el, view, 2)
return el
}
@@ -341,19 +346,20 @@ export interface TocEntry {
}
export class TocWidget extends WidgetType {
readonly locale = locale.value
constructor(readonly entries: TocEntry[]) {
super()
}
eq(other: TocWidget) {
return JSON.stringify(other.entries) === JSON.stringify(this.entries)
return other.locale === this.locale && JSON.stringify(other.entries) === JSON.stringify(this.entries)
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = 'cm-rm-toc'
if (!this.entries.length) {
el.textContent = '目录(还没有标题)'
el.textContent = translate('workspace.table_of_contents_no_headings_c24b5d')
el.classList.add('cm-rm-placeholder')
}
const minLevel = Math.min(...this.entries.map(e => e.level))