

前言



什么是 Monarch
Monarch 是 Monaco Editor 自带的一个语法高亮库,通过它,我们可以用类似 JSON 的语法来实现自定义语言的语法高亮功能。这里不做过多的介绍,只介绍在本文中使用到的那部分内容。
JSON值,部分通用属性如下:
tokenizer
(必填项,带状态的对象)这个定义了tokenization
的规则。Monaco Editor 中用于定义语言语法高亮和解析的一个核心组件。它的主要功能是将输入的代码文本分解成一个个的 token,以便于编辑器能够根据这些 token 进行语法高亮、错误检查和其他编辑功能。ignoreCase
(可选项=false
,布尔值)语言是否大小写不敏感?tokenizer
(分词器)中的正则表达式使用这个属性去进行大小写(不)敏感匹配,以及case
场景中的测试。brackets
(可选项,括号定义的数组)tokenizer
使用这个来轻松的定义大括号匹配,更多信息详见 @brackets和 bracket部分。每个方括号定义都是一个由3个元素或对象组成的数组,描述了open左大括号
、close右大括号
和token令牌
类。默认定义如下:
[ ['{','}','delimiter.curly'],['[',']','delimiter.square'],['(',')','delimiter.parenthesis'],['<','>','delimiter.angle'] ]
1、tokenizer
identifier entity constructoroperators tag namespacekeyword info-token typestring warn-token predefinedstring.escape error-token invalidcomment debug-tokencomment.doc regexpconstant attributedelimiter .[curly,square,parenthesis,angle,array,bracket]number .[hex,octal,binary,float]variable .[name,value]meta .[content]
editor.defineTheme("vs", {base: "vs",inherit: true,rules: [{token: "token-name",foreground: "#117700",}],colors: {},});
root就是 tokenizer 定义的第一个状态,就是初始状态。同理,如果把
afterIf和
root两个状态调换位置,那么
afterIf就是初始状态。
monaco.languages.setMonarchTokensProvider('myLanguage', {tokenizer: {root: [// 初始状态的规则[/\d+/, 'number'], 识别数字[/\w+/, 'keyword'], 识别关键字// 转移到下一个状态[/^if$/, { token: 'keyword', next: 'afterIf' }],],afterIf: [// 处理 if 语句后的内容[/\s+/, ''], 忽略空白[/[\w]+/, 'identifier'], 识别标识符// 返回初始状态[/;$/, { token: '', next: 'root' }],]}});
class MonarchTokenizer {...public getInitialState(): languages.IState {const rootState = MonarchStackElementFactory.create(null, this._lexer.start!);return MonarchLineStateFactory.create(rootState, null);}...}
function compile() {...for (const key in json.tokenizer) {if (json.tokenizer.hasOwnProperty(key)) {if (!lexer.start) {lexer.start = key;}const rules = json.tokenizer[key];lexer.tokenizer[key] = new Array();addRules('tokenizer.' + key, lexer.tokenizer[key], rules);}}...}
整数键:如果属性名是一个整数(如 "1"、"2"等),这些属性会按照数值的升序排列。 字符串键:对于非整数的字符串键,属性的顺序是按照它们被添加到对象中的顺序。 Symbol 键:如果属性的键是 Symbol 类型,这些属性会按照它们被添加到对象中的顺序。
for...in循环遍历对象的属性时,属性的顺序如下:
首先是所有整数键,按升序排列。 然后是所有字符串键,按添加顺序排列。 最后是所有 Symbol 键,按添加顺序排列。

[regex, action]
{regex: regex, action: action}形式的简写。[regex, action, next]
{ regex: regex, action: action{ next: next} }形式的简写。
monaco.languages.setMonarchTokensProvider('myLanguage', {tokenizer: {root: [// [regex, action][/\d+/, 'number'],/*** [regex, action, next]* [/\w+/, { token: 'keyword', next: '@pop' }] 的简写*/[/\w+/, 'keyword', '@pop'],]}});
string
{ token: string } 的简写[action, ..., actionN]
多个 action 组成的数组。这仅在正则表达式恰好由 N 个组(即括号部分)组成时才允许。举个例子:
[/(\d)(\d)(\d)/, ['string', 'string', 'string']
{ token: tokenClass }
这个 tokenClass 可以是内置的 css token,也可以是自定义的 token。同时,还规定了一些特殊的 token 类:
"@rematch"
备份输入并重新调用 tokenizer 。这只在状态发生变化时才有效(或者我们进入了无限的递归),所以这个通常和 next 属性一起使用。例如,当你处于特定的 tokenizer 状态,并想要在看到某些结束标记时退出,但是不想在处于该状态时使用它们,就可以使用这个。例如:
monaco.languages.setMonarchTokensProvider('myLanguage', {tokenizer: {root: [[/\d+/, 'number', 'word'],],word: [[/\d/, '@rematch', '@pop'],[/[^\d]+/, 'string']]}});

"@pop"
弹出 tokenizer 栈以返回到之前的状态。"@push"推入当前状态,并在当前状态中继续。
monaco.languages.setMonarchTokensProvider('myLanguage', {tokenizer: {root: [// 当匹配到开始标记时,推送新的状态[/^\s*function\b/, { token: 'keyword', next: '@function' }],],function: [// 在 function 状态下的匹配规则[/^\s*{/, { token: 'delimiter.bracket', next: '@push' }],[/[^}]+/, 'statement'],[/^\s*}/, { token: 'delimiter.bracket', next: '@pop' }],],}});
$n
匹配输入的第n组,或者是$0代表这个匹配的输入。$Sn状态的第 n 个部分,比如,状态 @tag.foo,用 $S0 代表整个状态名(即 tag.foo ),$S1 返回 tag,$S2 返回 foo 。

Monaco Editor 让日志实现不同主题
/*** 日志构造器* @param {string} log 日志内容* @param {string} type 日志类型*/export function createLog(log: string, type = '') {let now = moment().format('HH:mm:ss');if (process.env.NODE_ENV == 'test') {now = 'test';}return `[${now}] <${type}> ${log}`;}
[xx:xx:xx]开头,紧跟着
<日志类型>,后面的是日志内容。(日志类型:info 、success、error、warning。)
realTimeLog作为实时日志的一个
language。
token,然后通过
next携带匹配的引用标识( $1 表示正则分组中的第1组)进入下一个状态
consoleLog,在状态
consoleLog中,匹配日志内容,并打上
token,直到遇见终止条件(日志日期)。
import { languages } from "monaco-editor/esm/vs/editor/editor.api";import { LanguageIdEnum } from "./constants";languages.register({ id: LanguageIdEnum.REALTIMELOG });languages.setMonarchTokensProvider(LanguageIdEnum.REALTIMELOG, {keywords: ["error", "warning", "info", "success"],date: \[[0-9]{2}:[0-9]{2}:[0-9]{2}\]/,tokenizer: {root: [[/@date/, "date-token"],[<(\w+)>/,{cases: {"$1@keywords": { token: "$1-token", next: "@log.$1" },"@default": "string",},},],],log: [[/@date/, { token: "@rematch", next: "@pop" }],[/.*/, { token: "$S2-token" }],],},});// ===== 日志样式 =====export const realTimeLogTokenThemeRules = [{token: "date-token",foreground: "#117700",},{token: "error-token",foreground: "#ff0000",fontStyle: "bold",},{token: "info-token",foreground: "#999977",},{token: "warning-token",foreground: "#aa5500",},{token: "success-token",foreground: "#669600",},];

2、普通日志
普通日志与实时日志有些许不同,他的日志类型是不展示出来的,没有一个起始/结束
标识符供Monarch
高亮规则匹配。所以需要一个在文本中不展示,又能作为起始/结束
的标识符。
// 使用零宽字符作为不同类型的日志标识// U+200Bconst ZeroWidthSpace = '';// U+200Cconst ZeroWidthNonJoiner = '';// U+200Dconst ZeroWidthJoiner = '';// 不同类型日志的起始 结束标识,用于 Monarch 语法文件的解析const jobTag = {info: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthSpace}`,warning: `${ZeroWidthNonJoiner}${ZeroWidthSpace}${ZeroWidthNonJoiner}`,error: `${ZeroWidthJoiner}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,success: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,};
import { languages } from "monaco-editor/esm/vs/editor/editor.api";import { LanguageIdEnum } from "./constants";languages.register({ id: LanguageIdEnum.NORMALLOG });languages.setMonarchTokensProvider(LanguageIdEnum.NORMALLOG, {info: /\u200b\u200c\u200b/,warning: /\u200c\u200b\u200c/,error: /\u200d\u200c\u200d/,success: /\u200b\u200c\u200d/,tokenizer: {root: [[/@success/, { token: "success-token", next: "@log.success" }],[/@error/, { token: "error-token", next: "@log.error" }],[/@warning/, { token: "warning-token", next: "@log.warning" }],[/@info/, { token: "info-token", next: "@log.info" }],],log: [[/@info|@warning|@error|@success/,{ token: "$S2-token", next: "@pop" },],[/.*/, { token: "$S2-token" }],],},});// ===== 日志样式 =====export const normalLogTokenThemeRules = [{token: "error-token",foreground: "#BB0606",fontStyle: "bold",},{token: "info-token",foreground: "#333333",fontStyle: "bold",},{token: "warning-token",foreground: "#EE9900",},{token: "success-token",foreground: "#669600",},];


在 Monaco Editor 中支持a元素
const linkProvider = {provideLinks: function(model, position) {// 返回链接数组return [{range: new monaco.Range(1, 1, 1, 5), // 链接的范围url: 'https://example.com', // 链接的 URLtooltip: '点击访问示例' // 悬停提示}];}};monaco.languages.registerLinkProvider('javascript', linkProvider);
provideLinks。
在生成文本时,在需要展示为 a 元素的地方使用 #link#${JSON.stringify(attrs)}#link#
包裹,attrs 是一个对象,其中包含了 a 元素的attribute
。在文本内容传递给 Monaco Editor 之前,解析文本的内容,利用正则将 a 元素标记
匹配出来,使用attrs
的链接文本
替换标记文本
,并记录替换后链接文本
在文本内容中的索引位置。利用 Monaco Editor 的getPositionAt
获取链接文本在编辑器中的位置(起始/结束行列信息),生成Range
。使用一个容器收集对应的日志中的 Link
信息。在通过linkProvider将编辑器中对应的链接文本
识别为链接高亮。给 editor 实例绑定点击事件 onMouseDown
,如果点击的内容位置在收集的 Link 中时,触发对外提供的自定义链接点击函数。
生成 a 元素标记。
interface IAttrs {attrs: Record<string, string>;props: {innerHTML: string;};}/**** @param attrs* @returns*/export function createLinkMark(attrs: IAttrs) {return `#link#${JSON.stringify(attrs)}#link#`;}
解析文本内容
getLinkMark(value: string, key?: string) {if (!value) return value;const links: ILink[] = [];const logRegexp = /#link#/g;const splitPoints: any[] = [];let indexObj = logRegexp.exec(value);/*** 1. 正则匹配相应的起始 / 结束标签 #link# , 两两为一组*/while (indexObj) {splitPoints.push({index: indexObj.index,0: indexObj[0],1: indexObj[1],});indexObj = logRegexp.exec(value);}/*** 2. 根据步骤 1 获取的 link 标记范围,处理日志内容,并收集 link 信息*//** l为起始标签,r为结束标签 */let l = splitPoints.shift();let r = splitPoints.shift();/** 字符串替换中移除字符个数 */let cutLength = 0;let processedString = value;/** link 信息集合 */const collections:[number, number, string, ILink['attrs']][] = [];while (l && r) {const infoStr = value.slice(l.index + r[0].length, r.index);const info = JSON.parse(infoStr);/*** 手动补一个空格是由于后面没有内容,导致点击链接后面的空白处,光标也是在链接上的,* 导致当前的range也在link的range中,触发自定义点击事件*/const splitStr = info.props.innerHTML + ' ';/** 将 '#link#{"attrs":{"href":"xxx"},"props":{"innerHTML":"logDownload"}}#link#' 替换为 innerHTML 中的文本 */processedString =processedString.slice(0, l.index - cutLength) +splitStr +processedString.slice(r.index + r[0].length - cutLength);collections.push([/** 链接的开始位置 */l.index - cutLength,/** 链接的结束位置 */l.index + splitStr.length - cutLength - 1,/** 链接地址 */info.attrs.href,/** 工作流中应用,点击打开子任务tab */info.attrs,]);/** 记录文本替换过程中,替换文本和原文本的差值 */cutLength += infoStr.length - splitStr.length + r[0].length * 2;l = splitPoints.shift();r = splitPoints.shift();}/*** 3. 处理收集的 link 信息*/const model = editor.createModel(processedString, 'xxx');for (const [start, end, url, attrs] of collections) {const startPosition = model.getPositionAt(start);const endPosition = model.getPositionAt(end);links.push({range: new Range(startPosition.lineNumber,startPosition.column,endPosition.lineNumber,endPosition.column),url,attrs,});}model.dispose();return processedString;}
使用一个容器存储解析出来的 link
const value = `这是一串带链接的文本:${createLinkMark({props: {innerHTML: '链接a'},attrs: {href: 'http://www.abc.com'}})}`const links = getLinkMark(value)
利用存储的 links 注册 LinkProvider
languages.registerLinkProvider('taskLog', {provideLinks() {return { links: links || [] };},});
绑定自定义事件
onMouseDown,在其中可以获取当前点击位置的
Range信息,循环遍历收集的所有 Link,判断当前点击位置的
Range是否在其中。
containsRange方法可以判断一个
Range是否在另一个
Range中。
useEffect(() => {const disposable = logEditorInstance.current?.onMouseDown((e) => {const curRange = e.target.range;if (curRange) {const link = links.find((e) => {return (e.range as Range)?.containsRange(curRange);});if (link) {onLinkClick?.(link);}}});return () => {disposable?.dispose();};}, [logEditorInstance.current]);



文章转载自数栈研习社,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。




