likes
comments
collection
share

用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全

作者站长头像
站长
· 阅读数 59

用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>

后续我们可以通过monacotextModel修改编辑器的语言、代码、状态等等,这里列举一些我在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多种主题。

用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全

首先获取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,
});

这样就能完成汉化了

用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全

用Tauri开发一个EPUB编辑器(二) Monaco Editor的汉化、代码高亮、设置主题、代码补全

这里提供一个最简单的汉化示例: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这个库却并没有提供可配置的方式。

那怎么办呢?遇到这类问题,我们通常有以下途径:

  1. 寻找其他可替代的库。
  2. 给仓库提feature request或pull request,请作者提供一个可配置的方式。
  3. 像上面汉化插件那样,写一个Vite插件注入代码。
  4. 通过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
评论
请登录