@xnary/iris-common
v0.1.0
Published
Iris 系列(IrisEditor/IrisBoard/IrisExcel)共享内核:文档内核、空间索引、协同 op、纯工具与设计令牌。直出 TS 源码,无构建。
Readme
IrisCommon
Iris 系列(IrisEditor / IrisBoard / 未来 IrisExcel)共享的统一内核与纯工具库。直出 TS 源码,无构建。
内容
core/统一文档内核:通用Record(recordId/type/attrs/inline/children/parentId/rev)、CommonDoc(不可变 + 结构共享)、Transaction(变更唯一入口,产出 ChangeSet + 细粒度 Op)、History(before/after 双快照)、ops(Path 统一寻址 + Op 编码 + applyOps 有序回放/幂等/pending 补序)spatial/空间索引(均匀网格:queryRect 视口剔除 / queryPoint 命中粗查)+ TileCache(世界磁贴离屏缓存,画布工厂注入)collab/awareness 光标协议(CursorState 编解码 + CursorStore 聚合)elements/scene/引擎层(自 IrisBoard 下沉):元素体系(spec/registry/shape*/connector/mindmap…)+ 场景模型与渲染核心(renderer/overlays/hit-test/text-edit…)ui/通用 DOM 部件(视觉样式由组件自注入,宿主只负责排布):HistoryPanel/AnimPanel/ShapePicker/Dialog
数学公式内核 math/(M8/M9)
KaTeX 是唯一的公式实现(依赖装在 IrisCommon),五个应用共用同一份内核,不存在「预览一种、导出另一种」:
render.ts出口:mathHtmlOf(DOM 出 KaTeX HTML,未加载时先给降级文本 +ensureMathReady回调重渲)、measureMath/mathNaturalSize(排版测量,DOM 不可用时按字符宽度估算)、mathImageOf/requestMathImage/ensureMathImage(位图:KaTeX HTML → SVGforeignObject→<img>→ PNG, 字体用font-inline.ts内联 woff2 base64,避免离屏渲染缺字形)、mathScaleFor(按 zoom×DPR 量化档位, 档位变化才重新栅格化)、mathSvgFragment/mathFallbackText/mathDrawSize;spec.ts:元素类型MATH_TYPE='math',MATH_ATTRS(tex/display/fontSize/textColor/align/background)、mathAttrsOf(脏数据收敛)、newMathAttrs/mathDefaultSize(插入时的自然盒)、mathPlacement(盒内 contain + 对齐)、createMathSpec()(画布 spec,未就绪先画降级文本并请求重绘)、mathElementSvg(record, zoom)(局部坐标 SVG 片段,Board/PPT 的 SVG 导出共用);atom.ts(scene/atom.ts的 math 分支):行内公式复用 atom token 的可选载荷InlineAtomPayload(token.ts),不新增 token 种类——Board 脑图的 atom 行为不受影响;- 缓存
cache.ts按「tex + 显示模式 + 字号 + 颜色 + 缩放档」键,font-inline.ts只在内联渲染时按需读取。
统一图表内核 charts/(M9)
一份数据契约同时产出画布与 SVG,五个应用(Excel 单元格 / Board 元素 / PPT 元素 / Editor 块 / Base 仪表盘) 外观一致:
types.ts:ChartKind = stat | bar | barH | line | area | pie | scatter | radar | map+CHART_KINDS/CHART_KIND_TITLES/CHART_AXIS_KINDS;ChartView(完整绘图视图,含colorBy: 'series' | 'data'与map?: MapAttrs)、ChartSeries、MapAttrs+DEFAULT_MAP_ATTRS、emptyChartView();scene.ts:图元中间层。buildChartScene({view,w,h,measure,palette})→ChartPrim[](rect/poly/circle/sector/text/path),paintChartScene(g, prims)落画布、chartSceneSvg(prims)出 SVG——「画布上有的导出里也有」是结构保证。地图走path图元 (Path2D/<path>,evenodd表达内环挖洞);palette.ts/measure.ts:调色板与色阶、默认文本测量(chartMeasurer);geo.ts:GeoJSON 宽容解析(Polygon / MultiPolygon / FeatureCollection)、区域名候选匹配 (REGION_KEY_CANDIDATES)、外接盒自适应、等距圆柱 / 墨卡托投影、按屏幕像素 RDP 抽稀、按尺寸缓存;spec.ts:元素类型CHART_TYPE='chart'、CHART_ATTRS(含MAP_KEYS六个地图键)、chartViewOfElement/chartAttrsOf(视图 ⇄ attrs 双向收敛)、chartSceneOfElement/chartElementSvg、createChartSpec({paletteOf, defaultKind, atomic})(各应用注入自己的调色板与默认图型);sample-geo.ts:虚构 9 区域示例 GeoJSON(≈1.8KB,含内环演示),供 demo 与单测兜底。
共享图表配置面板 ui/chart-panel(M9)
new ChartPanel({view, onChange, hint?, editableData?}):图型下拉 + 标题 + 数据(类目 textarea、
系列名/数值/系列色/增删)+ 地图区(createMapSection(doc, map, onPatch, onChanged):GeoJSON 导入/清空、
投影、色阶档数、未匹配区底色、区域标签),样式自注入一次(CHART_PANEL_STYLE_ID)。
面板只改本地视图并回调宿主,落库由宿主决定(Board 写元素 attrs、Excel 写单元格值、Editor 写行 attrs);
update(view) 供外部同步(宿主比对后再调,避免「面板自己提交 → 重建 → 输入框掉焦点」)。
提及内核 mention/ + 画布胶囊(M11)
统一口径:一个提及就是一段带实体的「块」,文本形态恒为「@显示名」(tokenText(mention)),
所以显示、测量、导出、比较都只有一处真相;画布/网页/导出都把这段文本画成胶囊。
token.ts:MentionEntity { type, id, label, meta? }、mention token、mentionDisplayText;mention/style.ts:五类色板MENTION_TYPES = user | doc | date | record | custom(未知类型落custom灰)、mentionStyleOf/MENTION_TYPE_TITLES/mentionKey/filterMentions; doc 候选行图形按meta.docType取与宿主目录树逐一对应的图标/颜色(MENTION_DOC_GLYPHS/mentionDocGlyphOf:icon-park outline 原始 path + 目录树docKindMeta类型色,裸图标无底色芯片; 缺省/未知 docType 兜底目录树缺省项 doc-detail/#3370ff),createMentionGlyph的 doc 分支消费;mention/trigger.ts:mentionTriggerAt(text, caret)(@触发位 + 查询串)、isBareMentionTrigger;mention/sample.ts:SAMPLE_MENTIONS/sampleMentionProvider(虚构示例候选,demo 共用);scene/mention-draw.ts:mentionChipWidth/Height/PadX、mentionChipText、paintMentionChip(画布胶囊的唯一实现:圆角底 + 类型色文本,宽度与排版同源);scene/text-draw.ts/text-layout.ts:排版与绘制都把提及当不可拆单元(宽度 = 胶囊宽);scene/atom-dom.ts/text-merge.ts:浮层编辑把提及渲染成contenteditable=false胶囊, 提交时按段序列回写 mention token(不会被退化成纯文本);scene/atom-dom.ts的触发扫描:mentionTriggerProbe/mentionTriggerRanges只看「光标所在的 连续文本段」(胶囊 / atom /<br>都是边界),并把触发区间同时换算到editorPlainText空间 ——胶囊自己的@不算触发符(否则插入胶囊后紧跟的 keyup/input 同步会把候选菜单又弹出来, 菜单随后吞掉回车,宿主再也提交不了;这是 M11 浏览器走查抓到的真 bug,五个宿主同一口径);mention/caret-anchor.ts:caretLineAnchor(编辑框, 挂载容器)—— 光标行盒 → 容器坐标的唯一换算口径 (取 Range 的行矩形减容器矩形;拿不到选区/无布局环境回落编辑框),lineHeightOf附带行高兜底; 四个宿主此前各写一套(电子表格漏减容器偏移、多维表格用输入框整框、画布浮层用行盒、演示文稿写死坐标), 口径不一正是「弹窗离光标很远 / 压住光标行」的根因;ui/mention-menu.ts:MentionMenu(上下键/回车/Tab/Esc/空结果文案/边界翻转定位)+MentionProvider类型(契约 =「查询串 → 过滤后候选」,菜单不做二次子串过滤, 远程/模糊匹配结果才留得住;分类 tab 以provider('')全量归纳,输入关键字时 tab 不塌缩), 五个应用共用同一份菜单; 定位契约(M11 修正):- 锚点 = 光标所在行盒(
x/y左缘/上缘 +h行高),坐标一律相对菜单挂载的那个容器; - 优先落在行盒下方
y + h + 4,下方空间不够才翻到上方y - 菜单高 - 4(翻上去不再重复减行高); - 尺寸用实测矩形,测不到按候选条数估算(不再用固定 60 兜底);左右与上下都钳进容器可视范围;
- 容器比菜单矮时先收住
max-height(宁可滚动,也不越出可视区被裁); - 传
anchorSource后菜单自己订阅「窗口改大小 / 容器滚动 / 容器尺寸变化」并逐帧节流重定位, 宿主只提供「当前锚点怎么取」;样式自带pointer-events: auto(宿主浮层多为pointer-events:none)。
- 锚点 = 光标所在行盒(
各应用接法
| 应用 | 注入 | 触发点 | 提交通道 |
| --- | --- | --- | --- |
| IrisEditor | EditorOptions.mentionProvider / editor.mentionProvider | 正文输入 @、/ 菜单 | insertTokens(trigger, [mentionToken]) |
| IrisExcel | ExcelOptions.mentionProvider / setMentionProvider | 单元格编辑器输入 @、工具条「插入 → 提及…」 | setCellInline(tokens) |
| IrisBoard | BoardOptions.mentionProvider / setMentionProvider | 文本浮层输入 @、右键「插入提及…」 | commitTextEditSegments |
| IrisPPT | PPTOptions.mentionProvider / setMentionProvider | 文本浮层输入 @、插入菜单「提及」 | commitTextEditSegments |
| IrisBase | BaseOptions.mentionProvider / setMentionProvider | 文本格编辑器输入 @ | setCellValue(tokens) |
菜单挂载容器与锚点由各宿主给出(同一契约):Editor 用 editor.renderer.root(斜杠/提及菜单是它自己的实现)、
Excel 用 .ir-excel__overlay、Base 用 .ir-base__overlay、Board/PPT 用 board.container / ppt.container;
PPT 的插入菜单锚点在容器坐标系内自算(选中元素 → 幻灯片左上角),不从工具条按钮取(按钮在容器之外)。
已知限制(提及,M11)
- 胶囊只承载「实体引用」,不支持加粗/字号等 marks 编辑;提及文本不可局部编辑(要么整体删,要么整段替换);
- 导出分流:Markdown/HTML/CSV/SVG 是纯文本「@显示名」(HTML 与 SVG 额外带胶囊样式), PPTX 与 DOCX 一律纯文本(没有胶囊样式通道,宁可可选中可搜索);PDF/图片走整页位图,胶囊随画布;
- 候选数据源由宿主注入,内核不做「@所有人」「按部门过滤」这类业务规则;
- 触发扫描以「光标所在的连续文本段」为界:光标紧贴胶囊之后时不会因为胶囊的
@显示名而弹菜单 (想再提一个实体,先敲空格或直接在胶囊后面输入@); - 提及不参与协同合并的语义校验:并发改动同一段时按字符级三方合并,提及块可能被移动但仍保持实体;
- 菜单挂载容器必须是能覆盖可视区的定位祖先:容器的
clientHeight同时是钳制边界,挂到一条窄浮层上 (例如把菜单挂进只有几十像素高的 overlay)会被钳成很小的一条并把触发行盖住。
公式/图表内核的已知限制
- 公式:位图是栅格化产物(按 tex + 显示模式 + 字号 + 颜色 + 缩放档缓存),画布与导出里都不可选、
不可搜索;docx 侧没有 OMML(是等宽 LaTeX 源串);行内公式在 Markdown/IR 里是
$tex$源串。 位图需要 canvas + 字体度量(node/jsdom 判为「不支持栅格化」,一切请求立刻走降级文本)。 - 图表:图元层是「绘图」而非「图表对象」——没有悬浮提示、没有点击钻取、没有动画; 数值轴按 nice ticks 取整,多系列同点重叠的数据标签不做避让。
- 地图:只画 GeoJSON 轮廓 + 分级设色(+ 可选区域标签与色阶图例);没有瓦片底图、没有投影之外的
几何纠正(跨 180° 经线不特判)、没有交互;区域数据靠「labels + 单系列 values」,
名称匹配不到就落
unmatchedFill;sample-geo.ts是虚构区划,只为演示与测试兜底。
通用居中弹窗 ui/dialog(P8)
createDialog(opts?) → DialogHandle。零领域依赖(只操作 DOM 与宿主给的标题字符串,不引用任何编辑器概念),
Board / Excel / Editor / PPT 任一消费方都能直接用,不需要 Host 或回调注入。
interface DialogOptions {
title?: string; // 标题栏文案(setTitle 可改)
width?: number | string; // 卡片宽度(数字 = px;实际取 min(该值, 100%))
maxHeight?: number | string; // 卡片最大高度(实际取 min(100%, 该值))
closeOnMask?: boolean; // 点遮罩是否关闭(缺省 true)
initialFocus?: 'first' | 'card' | 'none'; // 打开时焦点落到哪(缺省 'first')
onClose?: () => void; // ✕ / 遮罩 / Esc / close() 同口;先于焦点归还执行
}
interface DialogHandle {
readonly el: HTMLElement; // 根(遮罩 + 卡片;hidden = 关闭)
readonly body: HTMLElement; // 内容容器:宿主把面板挂进来
readonly isOpen: boolean;
setTitle(t: string): void;
open(): void; close(): void; toggle(): void; destroy(): void;
}- 形态:
position: fixed遮罩(rgba(12,14,18,.38))+ 居中圆角卡片(标题栏 + ✕);padding: 24px的 flex 居中容器 +width: min(--ir-dialog-w, 100%)/max-height: min(100%, …)保证窗口变化不溢出, 内容超高由body滚动。类名ir-dialog__*,样式由ensureDialogStyles()幂等自注入一次(#ir-dialog-styles)。 - Esc:
window捕获阶段监听 +stopPropagation,只关最上层那一个(支持多弹窗堆叠), 从而抢占宿主既有的按键分层(画布 Esc 分层 / 快捷键不会被顺手触发);无弹窗在开时不占用全局按键。 - 堆叠:
z-index按打开顺序递增(基数 100 + 栈内下标,关闭后重排),上层必然更高。 - 焦点:打开时记录
document.activeElement,并试聚焦正文内首个可聚焦元素(浏览器会拒绝display:none子树里的元素,故逐个试到真拿到焦点为止),都不行才落到卡片自身(tabindex="-1"); 关闭时把焦点还给打开前的元素(已从文档移除则不动)。onClose先于焦点归还执行,宿主在回调里同步 自身态或顺手 focus 别处都不会抢走焦点归还。initialFocus: 'none'供「聚焦会改变既有语义」的宿主 (如 PPT 备注面板:textarea 聚焦后文档变更不再回读进输入框)。 - [hidden] 兜底(必须):组件自身写了
display: flex,故显式声明.ir-dialog[hidden] { display: none !important }; 漏掉会出现「已 hidden 却仍盖住整页且关不掉」(本仓历史上踩过这个坑),单测锁住这条规则的存在。 AnimPanel的弹窗形态:AnimPanelOptions.dialog(true或DialogOptions)开时面板自带弹窗 (el仍挂进dialog.body,默认关闭,setVisible/el.hidden语义不变);缺省关着,宿主自行排布 (IrisBoard 的贴边面板走这条,行为零变化)。
图片模型与形状面板(P3b)
- image attrs 扩展(
elements/image.ts):clip(归一化源坐标{x,y,w,h}∈ [0,1],默认整图;脏值逐字段夹取、退化回整图)/cornerRadius/stroke+strokeWidth+dash(盒内框描边,线宽内缩一半保证不越界)。 imagePlacement(geom, fit, natural, clip):源矩形 → 目标矩形(画布 / SVG / PPTX 的同源出口)。映射规则 = 裁剪先发生,fit的比例判据取「裁剪后的有效自然尺寸」(nw·clip.w, nh·clip.h):fill:有效源等比拉伸铺满geom盒(dst= 盒),无目标裁切;contain:按有效源等比缩放居中(backdrop = true,留白由调用方铺底色);cover:按有效源等比放大居中 +clip=geom盒;- 源是否被裁(
cropped)决定draw用 9 参还是 5 参drawImage(整图时与旧实现逐调用一致); natural缺失/非法 →src = null、dst=geom盒(按fit兜底)。 旧函数imageDrawBox保留为「无源裁剪的盒折算」内部件(既有单测锚定其行为)。
ui/shape-picker.ts(ShapePicker):弹出式形状选择面板(分类标签 + 网格),ShapePickerHost{ kinds, onPick, onClose? }+shapeKindList(registry, {labels, categoryLabels})构造清单;图标零维护——格子用 kind 自身draw实时渲到小 canvas(drawShapeKindIcon),新增 kind 无需登记图标资源。关闭语义:选中即回调并关闭 / Esc / 点击面板外(均回调onClose),Esc 用捕获阶段 +stopPropagation免得穿透到画布的分层 Esc。
文本排版(P3a:段落级排版 + 自动调整 + 垂直对齐)
- 数据表示:段落级属性(行距倍数
lineHeightMul、段前/段后spaceBefore/spaceAfter、首行缩进indent、 列表listType/listLevel/orderedStart/bulletChar)以元素级 attrs 为「全框统一默认」; 单段覆盖写在该段每个 token 的listmark 上,值<type>[:<level>[:<start>]](scene/text-layout.ts)。 选它的理由:纯文本编辑提交走位置式 marks 继承(mergeTokensWithOldMarks),故「新增段落继承前一段落的 列表样式、删除段落不影响其余编号」无需提交路径特判,且段落样式与字符同住 inline(序列化/协同/撤销零新字段)。 编号在排版期按段序计算(paragraphNumbers:同级连续、更浅级别重置更深计数),PPTX 导出复用同一函数写a:buAutoNum@startAt。 - 段间距语义:段 i 与 i+1 之间 =
spaceAfter + spaceBefore(相加,PowerPoint 语义);首段之前不加段前, 末段之后计入排版高度。列表段正文列左缩进 =(level+1) × 1.5 × fontSize(选定 1.5em:随字号等比), 标记悬挂在正文列左侧(软折行与首行同列 = 悬挂缩进);标记不计入lineWidths,绘制信息在layout.markers。 - 一致口径:画布绘制、编辑 overlay、远程光标定位三处都用
layout.lineTops/lineHeights/layoutOffsetX/Y(不再按i × lineHeight推算),scene/text-draw-golden.test.ts用录制型 ctx 固化 「默认样式下绘制调用序列」以守卫零回归。 - 文本自动调整(
scene/text-fit.ts,attrs.autoFit === 'shrink'):按盒高 / 当前排版高等比缩小字号后重排, 最多 3 次迭代,下限0.4 × 原字号;未启用/无盒高(attrs.h ≤ 0)/原字号装得下 → 原样返回。 溢出策略:已到下限仍装不下时不静默截断,画布与 SVG 都在盒右下角画同色同尺寸的小三角 (drawOverflowHint/overflowHintSvg,OVERFLOW_HINT_SIZE/COLOR),提示用户改盒高或删字。 - text 元素盒高:
attrs.h > 0= 固定盒高(valign 与 autoFit 生效,bounds 用盒高);h ≤ 0/未设置 = 自适应高度(忽略 valign,bounds =max(排版高, 20),与 P3a 之前一致)。h未注册默认值:geomOf对 缺省h兜底 100(旋转轴沿用旧口径),注册 0 会覆盖它。 - 已知限制:编辑 overlay 是扁平
\n文本(保证textContent提交与 IrisBoard 共用路径不回归), 故不镜像段落缩进/列表标记/段前段后间距/行内 atom 抬高的逐行差异——多段或列表文本在编辑态下看不到标记与缩进; 垂直口径(行高 × zoom、valign 的 paddingTop)与画布一致。 ui/通用 DOM 组件(视觉样式由组件自注入,宿主 CSS 只负责排布):HistoryPanel(undo/redo 时间线面板,宿主只需 history.timeline() / jumpHistory() / on('change'))AnimPanel(动画时间轴面板:按触发三段分组「单击时/与上一动画同时/上一动画之后」+ 内联编辑九项 + 效果下拉按EffectDef.group归入<optgroup>、选中项呈现效果参数(EffectDef.params→spec.params) + pointer 拖拽排序(跨组改 trigger)+ 动画刷 + 预览;只依赖 host 的 getAnimations/setAnimations/animScopeRecords/selection/on/previewAnimations,排序真相始终是宿主文档;形态可选:{ dialog: true }= 自带居中弹窗,缺省 = 宿主排布)Dialog(createDialog:通用居中弹窗——遮罩 + 卡片 + 标题栏 + ✕ + Esc 只关最上层 + 焦点移入/归还 + 堆叠 z-index + 尺寸自适应 + 样式自注入;零领域依赖,见上一节)
anim/动画内核(宿主无关,Board/PPT 同权复用):效果 = 绘制期变换(ElementPaint,不改 attrs)→effects(效果库:30 个效果 = 进入 10 / 强调 8 / 退出 8 / 路径 4,含group分组与params可调参数元数据;方向语义与类别不变量见文件头注释)/easings/timeline(buildAnimSteps分步与describeAnim)/player(可注入时钟的逐帧播放器;path 类效果经EffectContext.path透传AnimSpec.path)/frame-store(帧仓 + 变更通知)/types(AnimSpec、AnimHost)。渲染侧经RenderState.paints通用补丁通道生效:补丁记录的键集并入excluded,不参与静态瓦片烘焙,由动态层实时绘制- 纯工具:
ids(全局唯一 id)、token(字符级 InlineToken + marks)、keymap(parseKeymap/matchKey)、emitter、geometry、throttle styles/tokens.css:--ir-*设计令牌
画布字体串:必须是字面量族(DEFAULT_FONT_FAMILY)
ctx.font 是 canvas 的解析属性,不接受 CSS 变量:g.font = '400 24px var(--ir-font)' 会被浏览器
静默忽略,g.font 保持上下文残留值(典型 10px sans-serif),于是 measureText 与字形都按残留字体走。
后果是未显式设族(fontFamily: '',「插入文本」的默认值)的文本按错误的字号/字体渲染与测量,且结果取决于
「上一个元素碰巧用了什么字体」(实测:24px 四字文本,正确族墨迹 95×23px,残留 10px sans-serif 时仅 40×10px)。
同一字符串在 DOM overlay(scene/text-edit.ts 的 font:)里合法,所以只有画布路径坏——现在两条路径同源同值。
elements/spec.ts导出DEFAULT_FONT_FAMILY=-apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif, 逐字镜像styles/tokens.css的--ir-font;spec.test.ts用?raw读 tokens.css 断言两者一致, 改令牌值必须同步本常量(否则测试红)。textStyleOf的空/无效族、fontOf以及scene/text-draw.ts的 token 族(含 code mark 的MONO_FONT_FAMILY, 本就是字面量)一律经sanitizeFontFamily:空串、含var(的历史/外来值(旧文档里存过var(--ir-font))、 裸--x变量名 → 替换为DEFAULT_FONT_FAMILY;带引号/逗号的显式族栈原样保留(不二次加引号)。- 行为变化(如实):文本元素的字体族不再跟随宿主对
--ir-font的覆盖——画布、编辑 overlay、SVG 导出三处 现在都固定为字面量栈。此前画布侧是「碰巧继承上一个元素字体」的不确定状态,overlay 侧跟随宿主;宿主(含 demo 的demo.css)对--ir-font的覆盖今后只影响 DOM 文本观感。宿主若要改画布文本字体,请写入文本元素的fontFamilyattrs,或同时修改styles/tokens.css与本常量。
依赖接线(重要)
消费方以 link: 协议引用(本包改动即时生效):
pnpm add link:../IrisCommon消费方 Vite 需要两处配置(否则预打包缓存陈旧源码 / dev server 拒绝 root 外文件):
optimizeDeps: { exclude: ['@xnary/iris-common'] },
server: { fs: { allow: ['.', '../IrisCommon'] } },Vitest 无需额外配置(linked 包真实路径在 node_modules 之外,默认现场转译)。
开发
pnpm install
pnpm test # node 环境纯逻辑单测(DOM 用例逐文件 jsdom 覆盖)
pnpm typecheck准入纪律
只放纯函数、零领域概念的代码(elements/ scene/ ui/ 三个下沉层例外:随界面包一起搬入的引擎/组件代码原样搬移不改逻辑);迁入代码配单测守卫,需要 DOM 的用例首行标 // @vitest-environment jsdom。
