@one2x/medeo-rte
v0.3.1
Published
自绘文档渲染引擎:字符流模型,排版输出纯几何,绘制后端可替换(HTML / Canvas)
Keywords
Readme
medeo-rte
自绘的文档渲染引擎。不使用 contenteditable —— 文档是一条字符流,排版自己算出每个字的坐标,绘制后端可替换。
同一份排版结果可以画到 DOM,也可以一键切到 Canvas,光标和选区不变。
在线 demo:https://editor-html-demo.pages.dev/
仓库结构
src/ SDK。发布出去的就是这部分
demo/ 演示应用。同时是 SDK 的用法样例,不参与发布SDK 只负责编辑区本身。工具栏、状态栏、小地图、基线网格这些宿主外壳全在 demo/,
产品接入时应自己实现——照着 demo/main.ts 抄即可。模板混排样例在 demo/vblocks.ts。
在其它项目里使用
npm i @one2x/medeo-rteimport { Editor } from '@one2x/medeo-rte';
import '@one2x/medeo-rte/style.css';
const editor = new Editor(document.getElementById('app')!);
// 下命令
editor.commands.toggleBold();
// 同步自己的工具栏
editor.on('statechange', () => renderToolbar(editor.getState()));
// 存取内容(Slice 可 JSON 序列化)
const json = editor.getContent();
editor.setContent(json);公式需要单独引入,因为它依赖 KaTeX + MathLive,是包体积的大头:
import { builtinEmbedPlugins, Editor } from '@one2x/medeo-rte';
import { formulaPlugin } from '@one2x/medeo-rte/formula';
new Editor(el, { plugins: [...builtinEmbedPlugins, formulaPlugin] });不引入就完全不会打进产物(katex / mathlive 是可选 peer 依赖)。
主要能力
文字与段落样式、列表、表格、链接、图文绕排、Markdown 边打边转、撤销重做、中文 IME。
嵌入对象走同一套插件接口:图片、视频、代码块、公式、同步块引用、以及下面三类「槽 / 胶囊」。名字都带 slot,用途完全不同,不要混用。
三类 slot 对照(接入必读)
| | internalSlot | hostSlot | virtualSlot(+ region) |
| --- | --- | --- | --- |
| 是什么 | SDK 内置芯片(slotPlugin) | 宿主自绘芯片 | 虚拟块文本槽;外层皮是 region |
| 落在哪 | EmbedEntity,流上一个 * | 同上 | virtualBlocks 区间,不占额外字符 |
| 内容 | 实体字段(label / slotValue 等) | hostType + data,DOM 由宿主画 | 就是那段 spacers 文字 |
| 折行 | 整颗盒子,放不下换行 | 同左 | 槽里的字跟外层句子一起按字折 |
| 谁用 | demo / 简单内置控件 | 产品业务胶囊(下拉、@、上传等) | 模板里可填灰字、外层底色分组 |
| 插入 | commands.insertSlot('text'\|'image'\|'select') | commands.insertHostEmbed(...) 或 insertSlice 的 entities | insertSlice 的 virtualBlocks,或 insertVirtualBlock |
另外还有 同步块(fragment / ref):选区提成可复用内容,改一处全变——和上面三类无关。
产品模板句里「可填写的那截灰字」用 virtualSlot,不要用 internalSlot / hostSlot。后两者是另一颗盒子,点进去才填,不能按字切开。
虚拟块怎么用(业务侧)
虚拟块是盖在字符流上的假块:负责皮(背景、圆角)和「整段选中」交互。区间里的字仍是文档正文。用户打字不会新建虚拟块,只能由业务 API 插入。
三种 kind:
mark:纯样式叠层(高亮底色、边框、文字缺省色等)。交互与普通文本流完全一致:点击落光标、方向键逐字走、能选中、能编辑;不进选中/进出状态机,不参与嵌套(parentId恒为null),可与其他块任意叠放。右边缘继续输入算区间内(背景跟着延伸),遇到换行就断;区间内文字删光后自动移除。样式只需配chrome.background、chrome.radius(圆角,默认 3;折行时只有首行的上两角和末行的下两角是圆角,其余角为直角,单行自然四角全圆)、chrome.paddingX(水平内边距)和chrome.hoverBackground(鼠标悬停时整段含折行各行换成这个底色):editor.insertVirtualBlock({ kind: 'mark', chrome: { background: '#fff3bf', hoverBackground: '#ffe066', radius: 4, paddingX: 5 } });paddingX只外扩底色/边框,不参与排版(字形位置、折行、行高都不变),行首/行尾也会外扩,所以相邻文字和相邻 mark 的底色可能互相重叠;行首行尾的外扩部分画在内容区外:SDK 默认让画布和.mini-page-wrap左右各多出 5px(负 margin 抵消,文字位置不变),所以paddingX在 5px 以内开箱即用,更大就要靠编辑器padding.x补足。宿主若覆盖.mini-page-wrap的padding,请保留padding-inline: 5px; margin-inline: -5px;,否则文字会左移 5px、外扩被裁。不支持垂直内边距。region:一层皮 / 分组。整段选中时打字、删除会被忽略(保住模板壳)。没有editing模式。virtualSlot:最内层文本槽。可进editing;空着时显示placeholder,填了就是用户字;整段删回 placeholder,槽本身留下。
嵌套规则(针对 region / virtualSlot):只允许包含、不允许交叉;virtualSlot 必须是叶子。mark 不受此限。日常编辑里业务不用维护 [start, end),插删字时 SDK 自动挪区间。
路径 A:整段模板一次插入(推荐)
文案 + 槽 + 胶囊一起走 insertSlice。virtualBlocks 里的下标是相对这段 text 的半开区间:
editor.insertSlice({
text: 'Write a prompt for Input a theme.\n',
styles: [],
entities: [],
virtualBlocks: [
{
id: 'region',
parentId: null,
kind: 'region',
start: 0,
end: 33,
chrome: { background: 'rgba(43,108,255,0.08)', radius: 8 }
},
{
id: 'slot',
parentId: 'region',
kind: 'virtualSlot',
start: 19,
end: 32,
placeholder: 'Input a theme',
empty: true,
chrome: { background: 'rgba(16,185,129,0.18)', placeholderColor: '#98a2b3' }
}
]
});完整样例见 demo/vblocks.ts。
路径 B:给已有字套皮
// 不传 start/end → 用当前选区
editor.insertVirtualBlock({
kind: 'region',
chrome: { background: 'rgba(43,108,255,0.08)', radius: 8 }
});
editor.insertVirtualBlock({
kind: 'virtualSlot',
placeholder: 'Input a theme',
empty: true,
chrome: { background: 'rgba(16,185,129,0.18)', placeholderColor: '#98a2b3' }
});
// 也可显式下标
editor.insertVirtualBlock({ kind: 'region', start: 0, end: 20, chrome: { ... } });可配的 chrome 字段:background、hoverBackground(仅 mark)、radius、borderColor / borderWidth、paddingX(仅水平)、文字缺省(color、fontSize、fontFamily、fontWeight、bold、italic)、placeholderColor(仅 virtualSlot)。
改皮 / 拆皮:updateVirtualBlock(id, chrome)、removeVirtualBlock(id)(只拆皮,字和胶囊留下)。
业务浮层(Sample / AI 等)
SDK 不画业务菜单。宿主自己:
- 听
statechange,看getState().vblockStack栈顶是否是某槽的editing(点击或键盘进入都会触发) editor.getVirtualBlockRect(id)拿屏幕坐标(clientX/Y,含滚动)定位浮层:left/top/width/height是外接矩形,折行的块还有lines;下拉放在left/top + height即可。滚动或重排后(layoutchange)重新取- 写入:槽正处于
editing时直接editor.insertText(text)——空槽会整段覆盖 placeholder,不会进段落 - 关闭浮层:栈顶不再是该槽,或点到编辑器外(宿主自己监听)
和 host 胶囊一起用
胶囊是流上的 *,可以落在 region 覆盖范围内,但不是虚拟块的子节点。自定义芯片:
const editor = new Editor(el, {
hostEmbed: {
mount(container, ctx) {
// 按 ctx.entity.hostType 画自己的组件;实体 kind 为 'hostSlot'
return { update(next) {}, destroy() {} };
}
}
});
editor.commands.insertHostEmbed('Upscale', { label: '@Upscale' }, { width: 88, height: 24 });更细的模型与选区栈见 virtual-blocks.md。
API 一览
| 成员 | 说明 |
| --- | --- |
| new Editor(el, options?) | doc / paint / plugins / hostEmbed / width / padding / placeholder / textDefaults / shortcuts / autofocus / chrome |
| commands | 公开编辑命令(加粗、插表、插图、insertHostEmbed、同步块等),宿主工具栏调这些 |
| on(event, fn) | change / selectionchange / statechange / layoutchange,返回退订函数 |
| getState() | 当前状态快照(含 vblockStack),用于同步 UI |
| getContent() / setContent() | 整篇内容读写,Slice 结构(含 virtualBlocks) |
| insertSlice(slice, at?) | 插入切片;模板混排走这条 |
| insertVirtualBlock / updateVirtualBlock / removeVirtualBlock | 套皮、改 chrome、拆皮 |
| insertHostEmbed(hostType, data?, size?, at?) | 插入宿主自绘胶囊 |
| getSelection() / setSelection() | 选区读写 |
| getLayout() | 排版几何(只读),做标尺、小地图、槽上浮层用 |
| getDocument() | 文档模型,做批量操作或自定义命令用 |
| setPaintMode('html'\|'canvas') | 切换绘制后端 |
| setChrome / setTextDefaults | 光标选区外观;默认字号/颜色(主题切换) |
| root / scroller / pageElement | 根节点 / 滚动容器 / 内容定位上下文 |
| focus() / destroy() | |
| setPlaceholder(text) | 空态占位;组合输入一开始即隐藏 |
| composing() / setDisabled() | 组合态查询;禁用输入与快捷键 |
| INPUT_SHORTCUTS / inputShortcuts() | 输入框快捷键;默认 Enter 换行,Cmd+Enter 留给宿主 |
脚本化改文档也可 import { operations, runOperation } from '@one2x/medeo-rte',不要直接改 DocumentModel 的内部表。
性能探针(开发用)
默认关闭、零开销。在浏览器控制台里:
editor.perf.start() // demo 里 window.editor 已暴露;业务里用自己的 editor 实例
// 连续打字 / 按住方向键 / 中文输入法连续上屏 / 按快捷键……
editor.perf.report() // 输出各阶段耗时(平均 / p95 / 最大 ms)和帧率
editor.perf.stop()详细读法与历次优化记录见 docs/performance.md。输出包含:insertText、handleComposition、moveCaret 等入口的同步耗时;relayout、paintDoc、paintChrome、scrollCaret 等阶段耗时;xxx→下一帧(处理开始到下一帧开始,近似输入延迟);以及帧率、帧间隔 p95、掉帧数。120Hz 屏的帧预算是 8.3ms。
设计文档
docs/ 下是分层架构与技术方案:
| 文档 | 内容 | | --- | --- | | architecture.md | 分层结构、五条不变式、样式模型 | | mutations.md | 原子操作的类型、坐标语义、三套 handler 契约 | | operations.md | 编排层规范与 Operation 清单 | | edits.md | 事务边界、撤销栈、片段坐标空间、协同预留 | | rendering.md | 块级增量排版、脏区带、绘制后端 | | virtual-blocks.md | 虚拟块:模板混排、文本槽、按层选区 | | agent-segment-mapping.md | 面向 Agent:用户的 segment 概念如何对应到真实结构与 API | | performance.md | 性能目标、探针用法、长文档优化记录与待办 |
改动模型前先读 architecture.md 的不变式那一节。
本地开发
npm install
npm run dev # http://localhost:5273/ 跑 demo| 命令 | 作用 |
| ------------------- | ----------------------------- |
| npm run dev | 起 demo 开发服务器 |
| npm run build | 构建 demo 站点 → dist-demo/ |
| npm run build:lib | 构建 SDK → dist/(含 .d.ts) |
| npm run selfcheck | 分层自检:编辑语义、变换算术、撤销栈、快捷键表、增量排版 |
发布到 npm
包名 @one2x/medeo-rte。在 main 上打与版本号一致的 tag,Actions 会自动发布。
- 把要发的改动合进 main
- 改
package.json的version(例如0.1.1),一并合进 main - 在 main tip 打同名 tag 并推送:
git checkout main && git pull
git tag v0.1.1
git push origin v0.1.1tag 必须指向 main 上的提交,且 v* 与 package.json 的 version 一致,否则 Publish 会失败。
