
本文介绍一种健壮、无越界风险的方案,用于在 TipTap(基于 ProseMirror)中为纯 Markdown 文本实现 **bold** 或 *italic* 等手动标记的包裹与取消包裹操作,避免因边界计算错误导致的 Cannot read properties of undefined (reading 'nodeSize') 异常。
本文介绍一种健壮、无越界风险的方案,用于在 tiptap(基于 prosemirror)中为纯 markdown 文本实现 `**bold**` 或 `*italic*` 等手动标记的包裹与取消包裹操作,避免因边界计算错误导致的 `cannot read properties of undefined (reading 'nodesize')` 异常。
在使用 TipTap 编辑纯 Markdown(而非 HTML 渲染)时,许多开发者选择绕过内置的 Bold、Italic 等扩展,转而直接操作文本内容——通过 state.doc.textBetween() 获取上下文,并用 tr.insertText() 和 tr.delete() 手动插入或移除标记(如 **、*)。这种做法虽灵活,但极易因未校验文档边界而触发 ProseMirror 的底层异常,典型报错为:
TypeError: Cannot read properties of undefined (reading 'nodeSize')
该错误本质源于对 state.doc.textBetween(from - len, from) 等越界调用的非法访问:当光标位于文档开头(from === 0)或结尾(to === doc.size)时,from - len 或 to + len 可能为负数或超出文档总长度 state.doc.content.size,导致 ProseMirror 尝试访问不存在的节点。
✅ 正确解法是显式校验索引合法性,而非依赖 try/catch 捕获底层异常(后者无法预防节点访问失败,且掩盖了根本问题):
const wrapUnwrap = (marker: string) => {
const { state, view, tr } = buildTr(); // 假设 buildTr() 返回 { state, view, tr, from, to }
const { from, to } = state.selection;
const len = marker.length;
if (from === to) return; // 无选区,直接退出
let textBefore = "";
let textAfter = "";
// ✅ 安全获取前置标记:仅当 from - len ≥ 0 且 ≥ 1(避免从位置 0 向前取)时尝试
if (from - len >= 0) {
textBefore = state.doc.textBetween(from - len, from);
}
// ✅ 安全获取后置标记:仅当 to + len ≤ 文档总长度时尝试
if (to + len <= state.doc.content.size) {
textAfter = state.doc.textBetween(to, to + len);
}
if (textBefore === marker && textAfter === marker) {
// 取消包裹:删除前后标记
tr.delete(to, to + len);
tr.delete(from - len, from);
} else {
// 添加包裹:在选区前后插入标记
tr.insertText(marker, to, to); // 插入到末尾
tr.insertText(marker, from, from); // 插入到开头
}
view.dispatch(tr);
};
// 使用示例
const makeBold = () => wrapUnwrap("**");
const makeItalic = () => wrapUnwrap("*");? 关键改进点说明:
- 边界防御更精准:from - len >= 0 保证不越界左端;to + len <= state.doc.content.size 保证不越界右端(doc.content.size 是文档总字符长度,非节点数);
- 移除危险 try/catch:不再依赖捕获底层 ProseMirror 异常,而是从源头杜绝非法索引;
- 空字符串兜底:textBefore/textAfter 初始化为空字符串,确保比较逻辑始终安全("" === marker 为 false,不会误触发取消包裹);
- 语义清晰:buildTr() 应封装获取 state、view、tr 及 selection 的逻辑(例如从 editor.value 提取),保持主函数职责单一。
⚠️ 注意事项:
- 此方案适用于纯文本 Markdown 编辑场景(标记可见、不解析渲染)。若需支持嵌套格式(如 ***bold italic***),需额外处理重叠标记逻辑;
- state.doc.textBetween() 返回纯文本,不包含节点结构,因此无需担心 HTML 转义或节点类型问题;
- 若编辑器启用了协作编辑(Collaborative Editing)或自定义 schema,需确保 tr 操作与当前文档版本兼容,建议在 dispatch 前检查 tr.docChanged。
通过以上改造,你的 Markdown 包裹功能将彻底规避 nodeSize 类型错误,在单字、词尾、行首/尾等边界场景下均稳定可靠。


















