用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全
用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全
Monaco Editor的基本使用
我使用的是Vite,因此本文只讨论Vite环境下的Monaco Editor。
在Vite中使用monaco可以参考官方提供的示例代码:
monaco-editor/docs/integrate-esm.md at main · microsoft/monaco-editor
初始化Monaco Editor
src/editor/init.ts
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor-core/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import typescriptWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
function initMonaco() {
self.MonacoEnvironment = {
getWorker: function(_workerId: string, label: string) {
switch (label) {
case 'json':
return new jsonWorker({ name: label })
case 'css':
return new cssWorker({ name: label })
case 'html':
case 'xhtml':
case 'xml':
return new htmlWorker({ name: label })
case 'javascript':
return new typescriptWorker({ name: label })
default:
return new editorWorker({ name: label })
}
},
}
return monaco
}
export { initMonaco }
monaco提供了各种语言服务,它们是通过web worker实现的。
对于EPUB文件来说,绝大多数情况下,我们只需支持HTML和CSS两种语言即可,但也不能排除极少数情况下会有JS和JSON,所以我针对上面这些语言注册了对应的服务。
值得注意的是,monaco并没有单独提供XML、XHTML的服务,所以统一用HTML。
CodeEditor组件:
<script setup lang="ts">
import type { Ref } from 'vue'
import { initMonaco } from 'src/editor/init'
const editor = ref() as Ref<HTMLElement>
const monaco = initMonaco()
const textModel = monaco.editor.createModel('', 'html')
onMounted(() => {
monaco.editor.create(editor.value, {
model: textModel,
automaticLayout: true,
cursorSmoothCaretAnimation: 'on',
scrollBeyondLastLine: false,
bracketPairColorization: { enabled: true },
fontLigatures: true,
fontSize: 18,
})
})
</script>
<template>
<div
ref="editor"
style="width: 100%; height: 100%;"
/>
</template>
后续我们可以通过monaco
和textModel
修改编辑器的语言、代码、状态等等,这里列举一些我在Ebook Code中用到的方法:
let lang = 'css'
monaco.editor.setModelLanguage(textModel, lang) // 设置语言
let code = 'console.log("hello world!")'
textModel.setValue(code) // 设置代码
code = textModel.getValue()// 获取当前编辑器里的代码
// 获取编辑器实例,注意,这个editor不等同于monaco.editor
// getEditors()会返回所有通过monaco.editor.create创建的编辑器实例构成的数组
// 上面我们只创建了一个实例,所以取[0]即可
let editor = monaco.editor.getEditors()[0]
let pst = eidtor.getScrollTop() // 获取滚动条的位置
editor.setScrollTop(pst) // 滚动到指定位置
let lnum = 10
editor.revealLineNearTop(lnum) // 滚动到指定的代码行
editor.updateOptions({ fontSize: 18 }) // 设置字体大小
// 监听代码发生变动
textModel.onDidChangeContent(e => {
console.log("代码发生变动")
})
// 监听语言发生变动
textModel.onDidChangeLanguage(e => {
console.log("语言发生变动")
})
给Monaco设置代码高亮、主题颜色
设置主题颜色我用的是@shikijs/monaco,它提供了40多种主题。
首先获取shiki提供的全部主题。
src/editor/shiki.ts
import type { LanguageRegistration, ThemeRegistrationRaw } from 'shiki/core'
import { getHighlighterCore } from 'shiki/core'
import getWasmInlined from 'shiki/wasm'
// 根据我的测试,这三个主题目前版本会报错,只能排除掉。
const NOT_SUPPORTED_THEMES = [ 'github-dark-default', 'github-dark-dimmed','vesper']
// 将shiki提供的所有主题,利用vite的glob import全部导入
const themes_imorts = import.meta.glob<ThemeRegistrationRaw>('../../node_modules/shiki/dist/themes/*.mjs', { import: 'default' })
const langs_imports = import.meta.glob<LanguageRegistration>('../../node_modules/shiki/dist/langs/{css,javascript,json,html,xml}.mjs', { import: 'default' })
async function getLighter() {
const langs = await Promise.all(Object.values(langs_imports).map(t => t()))
const themes = await Promise.all(Object.values(themes_imorts).map(t => t()))
const hightlighter = await getHighlighterCore({
langs,
themes: themes.filter(t => !NOT_SUPPORTED_THEMES.includes(t.name!)),
loadWasm: getWasmInlined,
})
return hightlighter
}
export { getLighter }
由于我这是个桌面端应用,因此选择了一次性导入全部主题,但如果开发的是普通的Web项目,那还是应该考虑按需动态导入。
将主题注入monaco
<script setup lang="ts">
import type { Ref } from 'vue'
import type { HighlighterCore } from 'shiki/index.mjs'
import { initMonaco } from 'src/editor/init'
import { getLighter } from 'src/editor/shiki'
const editor = ref() as Ref<HTMLElement>
const monaco = initMonaco()
const textModel = monaco.editor.createModel('', 'html')
let hightlighter: HighlighterCore
onMounted(async() => {
monaco.editor.create(editor.value, {
model: textModel,
automaticLayout: true,
cursorSmoothCaretAnimation: 'on',
scrollBeyondLastLine: false,
bracketPairColorization: { enabled: true },
fontLigatures: true,
fontSize: 18,
})
if (!hightlighter) {
hightlighter = await getLighter()
// @ts-expect-error shikiToMonaco forced import monaco from 'monaco-editor-core', but here is 'monaco-editor'.
shikiToMonaco(hightlighter, monaco)
}
})
</script>
<template>
<div
ref="editor"
style="width: 100%; height: 100%;"
/>
</template>
调用setTheme方法即可修改主题:
monaco.editor.setTheme('night-owl') // 参数是主题名称
我写了一段脚本,用来自动获取shiki提供的主题名称:
scripts/get-themes.ts
import { basename } from 'node:path'
import { writeFileSync } from 'node:fs'
import fg from 'fast-glob'
function get_all_themes() {
const themes = fg.sync('./node_modules/shiki/dist/themes/*.mjs').map(path => basename(path, '.mjs'))
writeFileSync('./src/editor/themes.ts', `
// This file is generated by 'scripts/get-themes.ts'
const themes = ${JSON.stringify(themes, null, 4)} as const
export type ShikiTheme = typeof themes[number]
export { themes }
`.trim())
}
get_all_themes()
使用esno
执行脚本:
npx esno scripts/gen-themes.ts
输出结果保存于src/editor/themes.ts
:
// This file is generated by 'scripts/get-themes.ts'
const themes = [
'andromeeda',
'aurora-x',
'ayu-dark',
'catppuccin-frappe',
'catppuccin-latte',
'catppuccin-macchiato',
'catppuccin-mocha',
'dark-plus',
'dracula-soft',
'dracula',
'github-dark-default',
'github-dark-dimmed',
'github-dark',
'github-light-default',
'github-light',
'houston',
'light-plus',
'material-theme-darker',
'material-theme-lighter',
'material-theme-ocean',
'material-theme-palenight',
'material-theme',
'min-dark',
'min-light',
'monokai',
'night-owl',
'nord',
'one-dark-pro',
'one-light',
'poimandres',
'red',
'rose-pine-dawn',
'rose-pine-moon',
'rose-pine',
'slack-dark',
'slack-ochin',
'snazzy-light',
'solarized-dark',
'solarized-light',
'synthwave-84',
'tokyo-night',
'vesper',
'vitesse-black',
'vitesse-dark',
'vitesse-light',
] as const
export type ShikiTheme = typeof themes[number]
export { themes }
汉化Monaco Editor
如果你是通过CDN方式引入Monaco,官方提供了一个本地化的方案,官方示例:monaco-editor/samples/browser-amd-localized/index.html
但遗憾的是,我使用的是Vite,通过ESM方式导入的Monaco目前并没有官方的本地化方案。
有个第三方Vite插件可以对monaco进行汉化:vite-plugin-monaco-editor-nls,但是它已经很久没有更新,新版本的monaco用不了。于是我研究了一下它的源码,发现它的原理其实很简单,就是将汉化文本注入到monaco源码的nls.js
文件中,目前我用的monaco版本是0.48.0,npm安装完后,我们可以在node_modules/monaco-editor/esm/vs/nls.js
找到它,我们只需依样画葫芦,以相同的方式给其注入汉化文本即可。
具体操作是——
首先,去VS Code的语言包仓库手动下载对应的语言包:vscode-loc/i18n/vscode-language-pack-zh-hans,里面的translations/main.i18n.json
就是我要的语言包,复制或下载到本地,我将其改名为zh-hans.json
。
这里只能手动下载或者手动复制到本地,因为官方并没有将其发布到npm上。
然后,参照vite-plugin-monaco-editor-nls,写一个支持最新版的Vite插件,完整代码有点长,我在这里就不贴了,可以去我Ebook Code的仓库查看:eb-code/plugins/nls/index.ts,里面分别导出了rollup和esbuild的插件。
最后在vite的设置中导入插件(这里我省略了其他Vite设置):
import nlsPlugin, { Languages, esbuildPluginMonacoEditorNls } from './nls'
import zh_hans from './nls/zh-hans.json'
// 注意只在生产环境下添加rollup插件,开发模式下会报错
const plugins = []
if (process.env.NODE_ENV !== 'development') {
plugins.push(nlsPlugin({
locale: Languages.zh_hans,
localeData: zh_hans,
}))
}
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': resolve('./src'),
},
},
build: {
sourcemap: true,
},
optimizeDeps: {
esbuildOptions: {
plugins: [
// 开发环境下通过esbuild插件进行汉化
esbuildPluginMonacoEditorNls({
locale: Languages.zh_hans,
localeData: zh_hans,
}),
],
},
},
plugins,
});
这样就能完成汉化了
这里提供一个最简单的汉化示例:taiyuuki/monaco-zh-sample
给Monaco配置emmet实现代码提示
Monaco Editor内置的语言服务已经提供了部分代码补全功能,但是对HTML没有任何代码提示。所以我还得给其配置emmet,以达到在VS Code上类似的效果,事实上,VS Code对HTML和CSS的代码提示功能也是emmet插件提供的。
给Monaco配置emmet十分简单,因为有人写了现成的库emmet-monaco-es:
src/editor/init.ts
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor-core/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import typescriptWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { emmetHTML, emmetCSS } from 'emmet-monaco-es'
function initMonaco() {
self.MonacoEnvironment = {
getWorker: function(_workerId: string, label: string) {
switch (label) {
case 'json':
return new jsonWorker({ name: label })
case 'css':
return new cssWorker({ name: label })
case 'html':
case 'xhtml':
case 'xml':
return new htmlWorker({ name: label })
case 'javascript':
return new typescriptWorker({ name: label })
default:
return new editorWorker({ name: label })
}
},
}
emmetHTML(monaco)
emmetCSS(monaco)
return monaco
}
export { initMonaco }
唯一需要注意的是,emmetHTML和emmetCSS的调用需要在monaco.editor.create之前,因此这里将其放在init函数中调用。
html和xhtml的closing tag问题
由于EPUB内部使用的文件以XHTML为主,所以当输入img、br之类的标签时,我需要它是自闭合的,例如<br/>,<img src=“” alt=“” />,但是由于emmet默认提供的html代码补全,不是自闭合的,尖括号>前没有斜杠/。
虽然这不是什么严重问题,但xhtml毕竟是比html更严谨的标准,例如在sigil中,如果img标签没有自闭合是会报错的,所以还是得处理一下。
原本emmet是可以通过设置让其自闭合的,但emmet-monaco-es这个库却并没有提供可配置的方式。
那怎么办呢?遇到这类问题,我们通常有以下途径:
- 寻找其他可替代的库。
- 给仓库提feature request或pull request,请作者提供一个可配置的方式。
- 像上面汉化插件那样,写一个Vite插件注入代码。
- 通过pnpm patch,手动修改源码,给其打个补丁。
1是我没找到可替代的库,2也不太考虑,先不说作者会不会回应,就算愿意理你,也不是一时半会能解决的,3倒是个可行的方案,但我要改的代码总共也没几行,特意通过插件来解决感觉很没必要。
所以我最终采用的是最后一种方案,我改动代码如下:eb-code/patches/emmet-monaco-es@5.3.2.patch
diff --git a/dist/emmet-monaco.esm.js b/dist/emmet-monaco.esm.js
index 27db3ee035ab7fa3f627ee715305eea5886012fd..1699d2a9c978475ada3545fb566669612b496396 100644
--- a/dist/emmet-monaco.esm.js
+++ b/dist/emmet-monaco.esm.js
@@ -2341,6 +2341,7 @@ function selfClose(config) {
switch (config.options['output.selfClosingStyle']) {
case 'xhtml': return ' /';
case 'xml': return '/';
+ case 'html': return '/';
default: return '';
}
}
@@ -4796,7 +4797,7 @@ const defaultOptions$1 = {
'required', 'reversed', 'selected', 'typemustmatch'
],
'output.reverseAttributes': false,
- 'output.selfClosingStyle': 'html',
+ 'output.selfClosingStyle': 'xhtml',
'output.field': (index, placeholder) => placeholder,
'output.text': text => text,
'markup.href': true,
@@ -4831,6 +4832,7 @@ const defaultConfig = {
* Default per-syntax config
*/
const syntaxConfig = {
+ html: 'xhtml',
markup: {
snippets: parseSnippets(markupSnippets),
},
@@ -6016,7 +6018,7 @@ function registerProvider(monaco, languages, syntax) {
}
const providers = languages.map((language) => monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: LANGUAGE_MODES[MAPPED_MODES[language] || language],
- provideCompletionItems: (model, position) => isValidLocationForEmmetAbbreviation(model, position, syntax, language)
+ provideCompletionItems: (model, position) => syntax === 'html'
? doComplete(monaco, model, position, syntax, DEFAULT_CONFIG)
: undefined,
}));
这样一来,代码提示就完成了,且标签都是自闭合的。
转载自:https://juejin.cn/post/7376514713742819378