这是一篇水文, 可是为了解决 mpMath 与微信公众号新版编辑器不兼容的原因, 我浪费了我美丽的周日下午, 不把这个经历水出来我实在意难平啊!意难平!! 前景提要: mpMath: 与微信公众号的斗智之旅(1)
事情好像到这里就要结束了? 但我想着趁热打铁, 总有一天我还是会面对基于 ProseMirror 的新编辑器, 这一关迟早要过的. 简单看了下 ProseMirror 文档, 看起来 ProseMirror 比 ueditor 抽象更好一点, 理论上我只要将现在公式转换中将文本替换为 svg node 那块代码使用 ProseMirror 接口重写下就行:
if (formula.inline && formula.range.startContainer != formula.range.endContainer) {
// 我们在这里处理如下情况, 即 `$` 跨越了多个元素.
// <p>明显 $</p>
// <p>|I| = |I_1| + |I_2| + |I_3|</p>
// <p>$. <svg></svg></p>
// 如果不做任何处理, 则输出结果丑了一点.
let startTextNode = formula.range.startContainer; // 一定是 text.
let endTextNode = formula.range.endContainer; // 这也是个 text node.
let startNode = startTextNode.parentNode;
let endNode = endTextNode.parentNode;
formula.range.deleteContents();
startNode.appendChild(outputNode);
mergeNode(startNode, endNode);
} else {
formula.range.deleteContents();
formula.range.insertNode(outputNode);
}
所以现在最关键的是找到 ProseMirror 的 EditorView 对象:
// https://res.wx.qq.com/mpres/zh_CN/htmledition/js/default~media/appmsg_edit_v2_gray~media/msg_modify_fe.956f1c09.js
handleDOMEvents: {
compositionstart(Se) {
// 这里 Se 看起来像是 view.
const Ae = Se.state.selection.$from;
if (!Ae.parentOffset) {
Se.dispatch(Se.state.tr.insertText("\u200B", Ae.pos).setMeta("preserveSafariComposingTextStyle", {
pos: Ae.pos
}))
}
},
}
// o 一定是 view.
ln.compositionstart = ln.compositionupdate = o => {
if (!o.composing) {
o.domObserver.flush();
let {state: n} = o, a = n.selection.$to;
}
}
可是真不好找啊! 后来换个思路, EditorView 对象最终一定还是要保存在 window 对象中的, 就像 ueditor 一样, 那么我遍历 window 对象树, 找到看起来像是 EditorView 的对象打印出来, 比如如果我发现这个对象具有 state, composing 等属性时我便认为她可能是 EditorView. 让 Qwen 帮忙生成段代码:
(function findEditorView() {
const seen = new WeakSet();
const keyPaths = new Map();
function scan(obj, path = 'window') {
if (!obj || seen.has(obj)) return;
seen.add(obj);
// 检测目标对象特征
if (typeof obj === 'object' &&
'state' in obj &&
'composing' in obj &&
obj.state?.doc !== undefined) {
console.log('Found candidate:', obj);
console.log('Access path:', path);
console.log('State snapshot:', JSON.stringify(obj.state, (k, v) =>
k.startsWith('_') ? undefined : v, 2));
return;
}
// 广度优先搜索对象树
Object.getOwnPropertyNames(obj).forEach(key => {
try {
const val = obj[key];
if (val && typeof val === 'object') {
const newPath = `${path}.${key}`;
if (!keyPaths.has(val)) keyPaths.set(val, newPath);
scan(val, newPath);
}
} catch (e) { /* 忽略权限限制 */ }
});
}
console.warn('开始深度扫描window对象... (可能需要10-30秒)');
scan(window);
console.info('扫描完成,检查上方输出的候选对象');
})();
还真找到了, 与此同时我也发现了一段 WARNING:
appmsg_edit_v2_gray_fe.ffc01b69.js:15 【警告】
公众平台编辑器正逐步从UEditor迁移至ProseMirror,请勿获取UEditor相关对象及调用相关接口!
详细接入文档可见:https://developers.weixin.qq.com/doc/offiaccount/MP_Editor_JsApi/mp_editor_jsapi.html
哎呀妈呀! 我是真没有想到微信同学居然这么正经, 还有一个正儿八经的接口文档. 唉!
可是翻了下接口发现提供了选择, 插入; 就是没有提供删除操作! 这样我就没法实现我想要的替换效果啊! 不过倒是提供了一个 set_content 接口, 直接根据用户提供的内容覆盖当前文档中内容. 这样倒也可以, 大不了我拷贝一下 DOM 树, 在这个副本上应用传统的替换方法, 之后将替换后的 DOM 再传给 set_content. 于是开始基于 get_content/set_content 改造下 mpMath 一键公式转换功能. 理所当然的, 又陆陆续续地踩了一些坑:
- 插件的 content-script.js 访问不到
window.__MP_Editor_JSAPI__
对象, 一键公式转换功能主要逻辑之前一直是在 content-script.js 中, 其将待转换的公式文本通过 chrome.runtime.sendMessage 发送给 popup.js. popup.js 进行转换之后再吐给 content-script.js.
// content-script.js
// 发送消息到扩展,并等待响应
return chrome.runtime.sendMessage({
action: 'convert',
input: latexText,
display: !formula.inline
}).then(response => {
// 收到响应后,使用响应结果
let parser = new DOMParser();
let doc = parser.parseFromString(response.result, 'text/html');
let outputNode = doc.body.firstChild;
});
// popup.js
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
if (message.action === 'convert') {
console.log("receiver convert message", message);
var result = formula2Svg(message.input, message.display);
var outerHTML = svg2outHTML(result, message.input, message.display);
sendResponse({result: outerHTML});
}
});
是不是很好奇为啥 formula2Svg 不直接放在 content-script.js 中? 嘿嘿:
<meta charset="utf-8" />
<!--
俺不知道为啥 tex-svg-full.js 必须放在一个 html 中生效. 我在 content-script.js
通过 createElement('script') 将 tex-svg-full 加载到微信公众号编辑页面, 但 formula2Svg
会报错: Uncaught TypeError: Cannot set properties of undefined (setting '_nodes')
正好这个界面可以与 hidva.com _layouts/default.html 保持一致.
-->
<script id="MathJax-script" async src="../assets/js/tex-svg-full.js"></script>
- 既然 content-script 访问不了
window.__MP_Editor_JSAPI__
, 那么公式转换逻辑不得不放入 mpm-inject.js 中了, 但这样就用不了 chrome.runtime 了. 只能换成 contentWindow.postMessage 方式. 而 postMessage 只能 post message, 不能接收响应, 不得以不得不实现一个简短的 rpc 框架:
// client.js
// key: reqid, value: (resolve, reject)
var pendingRpcRequests = {};
function formula2SvgPromise(latexText, display) {
let reqid = 'req_' + Math.random().toString(36);
let { promise, resolve, reject } = Promise.withResolvers();
pendingRpcRequests[reqid] = { resolve, reject };
var outputNode = getMathOutputNode();
let options = MathJax.getMetricsFor(outputNode);
options.display = display;
srvframe = document.getElementById('hidva_com_mathjax_server');
srvframe.contentWindow.postMessage({
reqid: reqid,
type: 'convert',
latex_input: latexText,
options: options
}, '*');
return promise;
}
window.addEventListener('message', function (event) {
if (!event.data.type || !event.data.reqid || !event.data.resp || event.data.type != 'convert') {
console.log("hidva.com: unknown event", event);
return;
}
console.log("hidva.com: rece convert resp: ", event.data);
pendingRpcRequests[event.data.reqid].resolve(event.data.resp);
delete pendingRpcRequests[event.data.reqid];
});
// server.js
window.addEventListener('message', function (event) {
if (!event.data.type || !event.data.reqid) {
console.log("hidva.com: unknown event", event);
return;
}
var reqid = event.data.reqid;
if (event.data.type == 'convert') {
console.log("hidva.com: receive convert", event);
var latex_node = formula2Svg(event.data.latex_input, event.data.options);
event.source.postMessage({
reqid: reqid,
type: 'convert',
resp: latex_node.outerHTML
}, '*');
return;
}
console.log("hidva.com: unknown event", event);
return;
});
P.S. 我在 PD 分离中也实现了类似的 rpc 框架, 因为 RDMA Send 也类似这里的 post message 都是一种单向的数据流.
- 以及最后一个坑.
MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
processEscapes: true
},
svg: {
// 非常重要, 不能使用 global! 否则将生成包含 <use data-c="1D457" xlink:href="#MJX-TEX-I-1D457"></use>
// 类似 svg, 而这种内容展示是空白!
// 在这个页面中 https://developers.weixin.qq.com/community/develop/doc/0004eacae04e884a5af23b2496bc00?jumpto=comment& commentid=000244ddd747202669f247bec668
// 社区提到微信公众号的文章中的公式可以复制. 但我 blog.hidva.com 的公式则无法复制!
// 原因也是这个, hidva.com fontCache 配置为 global...
fontCache: 'none'
}
};
等我完成 mpMath 一键公式转换功能适配之后. 我忽然想到! 我都能 set content 了! 我为啥不拿 blog.hidva.com 直接渲染好的内容 set content!????? 我还折腾这玩意半天….