# 设计文档 / Design notes 这份文档保留 **dsh-drawai 为什么这么做** 的全部细节:载体为什么是 `.drawio`、 无损写回的外科手术规则、drawio 的 style 键与默认省略、图层与折点模型、 连线路由与预览、构建器为什么这么小、以及宿主/客户端两半边各自的自测。 面向使用者的说明在 [README](../README.md);发布与收录流程在 [release.md](release.md)。 > 文中「这一版刚补齐,留档」之类的段落是开发过程中的记录,保留原样 —— 它们记的是 > **当时为什么改**,不是当前待办。 --- ## 能力现状 **已实现** | 半边 | 能力 | |---|---| | 宿主 | `diagram_apply`:结构化 ops(`addNode`/`addEdge`/`setLabel`/`setStyle`/`move`/`remove`/`highlight`)→ 分层自动布局 → 原子写回。返回值里带 `created`/`changed`/`removed`(结构化 id,含自动分配的那些),模型不必解析 summary 文本 | | 宿主 | **一次改一组**:`setStyle`/`setLabel`/`move`/`remove` 都收 `ids:[…]`(用户说"把这几个换成绿色"时不用发 N 个 op);`addNode`/`addEdge` 收 `as:"名字"` 别名,同一个 ops 数组里就能引用"刚建的那个"(不必猜自动分配的 `n7`) | | 宿主 | **改接边而不重画**:`{op:'setEdge', id, from?, to?, fromPoint?, toPoint?}` 只换 source/target(或把某一端脱开成自由点),边的 id、标签、**标签位置**、折点、端点约束全都留着 —— 以前只能 `remove`+`addEdge`,那些一次性全丢 | | 宿主 | **边上的文字位置可读可写**:`diagram_read` 回 `labelX/labelY`(沿边比例 + 垂距;拖过才有),`{op:'setLabelPos', id, dy:12}` 挪一下、`{center:true}` 放回弧长中点(= 画布右键「标签居中」) | | 宿主 | **顺序(谁压谁)**:`{op:'order', id\|ids, to:'front'\|'back'\|'up'\|'down'}`,与画布右键同一套语义,并且**真的改文件里单元的先后**(否则重新打开又变回去) | | 宿主 | **复制**:`{op:'duplicate', id\|ids, …}` 新 id + 整体平移两格(与 Ctrl+V 一样),折点/端点约束/标签位置/自定义数据都带过去,默认连"两端都在复制范围内的边"一起复制 | | 宿主 | **按层操作**:`{op:'addLayer'}` 新建层、`addNode/addEdge/duplicate` 的 `layer`(层 id 或层名)、`{op:'setLayer', id\|ids, layer}` 移层、`{op:'setLayerProps', layer, name?, visible?, locked?}` 改名/显示/锁定。移层是真的改文件里的 `parent`(以前模型里改了层名却写不回去——见下面那条注记) | | 宿主 | **导出通道**:`{op:'export', format:'svg'\|'png'}` —— 渲染器只在浏览器那一半,所以这是"请求/回执":宿主挂请求 → 客户端下一次轮询取走并渲染 → svg 回执给宿主落在 `.drawio` 旁边、png 走浏览器下载;`diagram_read` 的 `export.status` 是 `pending/done/downloaded/failed` | | 宿主 | **容器层级可读**:`diagram_read` 的节点带 `parent`(它在哪个容器里)。画布按绝对位置显示,所以移动容器**不会**带走子单元 —— 要整组动就把它们一起写进 `move` 的 `ids`(以前 AI 根本不知道有这层关系) | | 宿主 | **「看一眼」画布效果图(工作区零文件)**:`diagram_read {render:true}` → 画布把当前画面渲成 PNG 回执给宿主 → 宿主存进 **DSH 的附件服务**(`attachments.saveImage`,内容寻址、在工作区之外,与用户上传图片是同一套存储)→ 下一次 `diagram_read {render:true}` 用一个 `{type:'image', attachment}` 内容块把图交给模型。**一个字节都不写工作区、也不进下载目录**。状态在 `look` 里:`pending`(已请求,画布几秒内渲好)/ `ready`(图在这一次的返回里,`stale:true` 表示回执之后文件又被改过)/ `unsupported`(这个部署没挂附件服务、或当前模型没声明图片输入 —— 给一句人话,其余功能照旧)/ `failed` | | 宿主 | **乐观锁对 AI 也生效**:`diagram_apply` 收 `expectRevision`(就是 `diagram_read` 回的 `revision`)—— 对不上(用户在 drawio 里改过)就**报错且一个字节都不写**,重读再改。不传则不检查 | | 宿主 | `diagram_read`:读回节点/边/标签/**style 键**(并给出派生的形状/线型/箭头/颜色名便于阅读)、每个单元属于哪一层、图层表(名字 + 可见性 + 锁定) | | 宿主 | **用户选区可见**:客户端把"选中了哪几个单元"上报给宿主(`action:'selection'`,防抖 300ms),`diagram_read` 用 `selection` 回给模型 —— 用户说"把这几个挪一下"时 AI 不用靠坐标猜。按**文件**配对(两个文件里都叫 `n1` 很常见)、按**存在**过滤,改图删掉的单元会主动划掉(id 会复用,不划掉就会"继承"旧选区) | | 宿主 | 语义校验:边指向不存在的节点直接报错并列出已知 id,**写盘之前**失败 | | 宿主 | 稳定 id 分配(`n1…` / `e1…`)、按字符宽度估算节点尺寸 | | 宿主 | 自动布局四种:`dagre-tb`(默认,贴合右栏窄高形状)/ `dagre-lr` / `grid` / `none`;显式重排会清掉端点已移动的边上的**过期折点** | | 宿主 | **树/森林按"父节点居中于两个孩子"摆**(二叉树、组织架构图、决策树):分层 → 重心排序减交叉 → 逐层居中,最后再自下而上把每个父节点摆到孩子跨度的中点,同层挤了只往右让。多父的 DAG(依赖图)保持逐层居中 —— 那里"居中于父"没有唯一答案。实测过:不做这一步,一棵完整二叉树会排成 `B` 压在 `E` 正上方、`D` 甩在左边 | | 宿主 | **载体就是 `.drawio`**:读写 drawio 的 mxfile 本身(含压缩形态)。`revision` 是**文件内容指纹**,所以 drawio 或别的编辑器改过文件,乐观锁照样准 | | 宿主 | **无损写回**(见下一节):只在原文件上改我们拥有的单元,其余原文逐字节保留;打开后原样保存 = 文件一个字节都不变 | | 客户端 | 右栏 tab 类型 `drawai:diagram`(`kind: diagram`),认领 `**/*.drawio` | | 客户端 | **文档由宿主读**(`action: 'read'`):浏览器没有 zlib,drawio 压过的 mxfile 客户端解不开,所以解析器只有宿主那一份 | | 客户端 | **工作栏在标签页之上**:`文件 / 编辑 / 图层 / 视图 / 导出` 常驻在面板最上方,下面才是多画布标签条,再下面是画布。一张画布都没有时它也在(否则「新建/打开」就点不到了) | | 客户端 | **下拉栏点外面就收**:工作栏那五个下拉菜单与「打开 / 新建画布 / 另存为」面板,在**外面**按一下(画布、标签条、工作栏的空白处)就关掉,`Esc` 同样收掉 —— 菜单是"看一眼、点一下就完事"的东西,不收会一直挂在画布上方挡着,而且下次点同一个按钮会变成"关掉"而不是"打开"。两类地方**不算外面**:菜单自己(否则点选项会先把自己关掉),以及**开菜单的那个按钮**(它自己有 toggle 语义:点同一个是关、点另一个是换)。判定挂在 `.drawai-root` 的**捕获阶段**,所以在画布自己的按下处理(框选、拖动、右键菜单)之前就判完 | | 客户端 | **打开后为空**:进入面板时不预先开任何画布(没有"未绑定画布"这种中间态),用「文件 → 新建画布…」或「文件 → 打开…」开始;关掉最后一个标签就回到这个空舞台 | | 客户端 | 「打开…」列出的每一个 `.drawio` 都能直接打开;文件里"画布表示不了、但会原样保留"的东西(多页、分组层级、图片、HTML 标签)在工作栏上一句句说明;**图层**已经能显示(见下面的图层一节),所以它单独报一句"这个文件有几个层" | | 客户端 | draw.io 经典外观:9 种形状(由 `shape=`/`ellipse`/`rhombus`/`rounded=`/`arcSize=` 驱动)、8 色 mxGraph 调色板(`fillColor`/`strokeColor`)、白纸 + 网格、正交折线 + 障碍避让、边标签衬底、自动换行、明暗切换 | | 客户端 | **拖拽连线预览**:从四面的引出端点拖出线时,实时显示**与落盘同一套路由**算出的正交折线(不是直线);靠近目标节点时列出它的**四个端点**并高亮将连接的那个(可挪指针改选),折线终点贴到该端点 | | 客户端 | **多选对齐 / 分布**:Shift 加选后右键 → 左/中/右、顶/中/底对齐,水平/垂直等距 | | 客户端 | **空白左键拖框选**:框到节点**或线段**都算选中(连线按**段**判定,不是拿整条边的外接框);Shift 框选是加选。选中的连线变蓝加粗并长出端点/段把手,可以直接 Delete 或整体复制 | | 客户端 | **整体拖动**:拖选区时节点、连线的**折点**与悬空端的**自由端点**用**同一个位移**一起走 —— 相对位置一点不变(以前只挪节点坐标,线被重新自动路由,形状就变了)。位移只吸附一次(拿抓起来的那个节点当基准),所以第二个节点与第一个之间原有的错位不会被各自吸附吃掉。抓在节点上或抓在线段上都能拖动整组;没有任何折点/自由端点的纯自动边没有"自己的几何"可平移,改形状要用段把手 | | 客户端 | **对齐辅助线(drawio 的 guides)**:拖节点时与**没在拖的**节点比 左/中/右、上/中/下 六条线,差在 6px 内就吸过去并画出蓝色虚线(以**整组的外接框**为准);网格吸附仍是底子,辅助线在容差内优先 | | 客户端 | **`Ctrl+A` 全选**(「编辑 → 全选」同款):节点与连线都进选区 —— 与框选同一套语义,于是能整体拖动、整体换样式、一把 Delete | | 客户端 | **批量改样式**:右键点中的元素**在选区里**时,形状/配色/线型/箭头作用于**整组同类型元素**(菜单标题会写"共 N 个/条");删除、拖动、复制、对齐早就是整组生效的 | | 客户端 | **独立线**:右键空白处「/ 独立连线」直接放一条**两端都不接节点**的线(drawio 的合法形态:两端都是自由点),之后拖端点就能接到节点上;它也能被整体拖动、加标签、改画法 | | 双方 | **连线画法**:线型(实线/虚线/点线,含 `dashPattern` 间距)× 箭头(单向/双向/无/反向)× 颜色 × 折角圆滑 × 引出段长度(`jettySize`)—— 全部是**文档里的 drawio style 键**,与 drawio 同构 | | 双方 | **线型四选一**:**直线**(`edgeStyle=none`,不带折点)/ **直角折线**(正交,缺省)/ **圆角折线**(`rounded=1`,**只在折点处**倒圆角 —— 圆角半径跟 drawio 一样由 `arcSize` 决定,缺省 `LINE_ARCSIZE(20)/2` = 10px)/ **曲线**(`curved=1`,drawio 的 Curved)。这是把 drawio 的两组键(走线 Straight|Orthogonal × 拐角 sharp|rounded|curved)压平成一次选择:四个组合里只有这四个有意义(直线没有折点可倒角,曲线与圆角互斥 —— drawio 的 `paintCurvedLine` 优先)。右键连线即可切;空白处右键还有「╱ 直线」直接放一条**两端自由、没有折点**的直线。两个语义上的连带动作:选直线会**清掉折点**(留着就不是直线了);选曲线时,如果这条边本来就笔直(两点共线),会**补一个弓形中点** —— 因为 drawio 的曲线是把*现有走线*抹圆(`mxPolyline.paintCurvedLine`),两点时控制点落在起点上、退化成直线,不补中点的话"画条弧线"屏幕上什么都不会发生。曲线的路径算法与 drawio **逐字一致**(中间点当二次曲线的控制点),命中带也用同一条曲线(否则"看着是弧、点起来按折线算") | | 双方 | **字号可改**:节点标签、独立文字、连线上的文字都吃 drawio 的 `fontSize` 键。右键菜单里是「10 / 12 / 14 / 18 / 24」加上**手动调节**(`−` / 数字输入框 / `+`,8–72 任意整数,回车或失焦生效,打字过程中不写文档)—— **没有「默认」那一项**:它唯一的作用是删掉键,而看到的样子和"选中那个等价档位"完全一样(节点/文字 12、连线 10,两个都在档位里),一个"选了等于没选"的选项只会让人犹豫;AI 侧是 `fontSize:18`(`null` = 删键,这条仍然保留) | | 双方 | **字色可改**:节点标签、独立文字、连线上的文字都吃 drawio 的 `fontColor` 键。右键菜单里节点与连线各有**一类「字色」**,色板预览的就是**文字色**(调色板的 stroke 那一支 —— 与"独立文字换色"是同一个值,否则点"黄"得到的字色看着像点错了);色板第一块是**黑**(写出去是删掉 `fontColor` 键 —— 画出来就是缺省黑字;叫它「黑」而不是「默认」:一排颜色里混一个"默认",既说不清它是什么色,又和"选个深色"是同一件事)。独立文字那一类**直接叫「字色」**(它只有字色可换,不再多一个重复入口)。AI 侧是 `fontColor:"#666666"`,或调色板名(`fontColor:"red"` → `#b85450`),`null` = 删键 —— 注意它与连线上的 `color` 是两件事:`color` 是**线**的颜色,`fontColor` 是**字**的颜色 | | 客户端 | **暗色外观不改节点里的字色**:缺省字色由**这块填充**算(浅底黑字、深底浅字),不跟主题走 —— 以前缺省字色取的是主题的 `skin.text`(暗色 `#e8e8e8`),而填充是文档里写死的浅色(`#ffe6cc`/`#dae8fc`…,主题不改它),于是**切到暗色,浅底节点上的字一起变浅,直接看不见**。现在只有"填充也是主题补的深色底"(无填充节点)才用浅字,那一对仍然看得清;**边上的字**压在页面上,照旧跟主题走(深色页面配浅字) | | 客户端 | 刷新:宿主变更流 `remote.workspaceFiles.changes()` 为主 + 5s 低频轮询兜底 | | 客户端 | **视口不随容器尺寸变化**:拖右栏改宽度时保持缩放比例、只改变可见范围(`resizeViewFor`)—— 否则右栏一拖画面就跟着缩放,且视口宽高比与容器失配后会被 `preserveAspectRatio="none"` 拉扁 | | 客户端 | **自环**:把线拖回起点节点自己即可(进出口自动错开,同侧会原路折回、看不见);复制 / 粘贴 / 剪切(`Ctrl+C/X/V`,剪贴板跨标签页,只搬两端都在选区内的边) | | 客户端 | **拖到空白处 = 悬空端**:新建连线落在空白处会连出一条"还没接上"的线;端点拖到空处就**脱离形状**(数据层用 `targetPoint`/`sourcePoint`,与 drawio 一致),再拖回节点上就重新接上 | | 客户端 | **图层(v1)**:文件里的图层就是 drawio 的图层单元(root 下没有几何的 ``,`value` = 名字、`visible="0"` = 隐藏、`locked="1"` = 锁定),画布按它分层显示。「图层」菜单里列出现有层:点名字 = 设为**当前层**(新建的节点/连线/粘贴的单元都落进它)、点眼睛 = 显示/隐藏(**写进文件**,drawio 打开也是隐藏的;隐藏层的单元**一个字节都不动** —— 隐藏是显示状态,不是删除)、「+ 新建图层」追加一层(自动起名 `图层 N`,id 避开已用的)。单元格的 `parent` 就写在它那一层上 | | 客户端 | **右键菜单:几类并排一行,每类只显示当前值**(`形状:矩形 ▾` `配色:蓝 ▾` `字号:默认 ▾`;连线是 `线型:直角折线 ▾` `样式:实线 ▾` `箭头:→ 单向 ▾` `字号:默认 ▾`,右栏放不下会自动折成两行)。点某一类才把它那一类的选项铺在下面**整行**里(形状仍是缩略图、配色仍是色块、线型/箭头会高亮当前项),选中一个就收起。以前每一类都把全部选项铺在菜单里 —— 3+4+4+6 个按钮加上 10 个形状缩略图与 8 个色块,右栏又窄,一屏全是按钮、反而看不出"现在是什么"。**动作类**(改标签 / 复制 / 删除 / 顺序 / 编辑数据…)仍然是一排按钮:它们没有"当前值",收进下拉只是多一次点击。空白处右键的「元素库」同样只显示当前形状与配色,`+ 新增节点` 放的就是下拉里选中的那个。菜单还夹了一道 `maxWidth`(chips 横排后可能比原来宽),保证它不会越过画布右边缘被 `overflow:hidden` 裁掉 | | 客户端 | **顺序**:节点/边的右键菜单有「置顶 / 上移 / 下移 / 置底」;模型顺序决定覆盖顺序,写回会真的改动文件里单元的先后 | | 客户端 | **编辑数据**:右键「编辑数据…」按 `key=value` 一行一项改 drawio 用户对象的自定义属性;裸单元第一次加数据会自动包成 ``(不包的话 drawio 保存时会把属性丢掉) | | 客户端 | **线上的文字**(边自己的 `value`):双击线、线上的文字、或线上的把手都能就地改(右键也有「改标签」;AI 侧是 `addEdge {label}` / `setLabel`);**按住线上的字就能拖**且**有吸附**(半格 + 贴线),位置按 drawio 的存法写进边几何的 `x/y/offset`(右键「标签居中」放回中点);落点缺省是 drawio 的**弧长中点**;**线在文字的位置断开**(真的不画那一段,不盖白底,`labelBackgroundColor` 有才画底衬) | | 客户端 | **双击节点 = 就地改标签**(右键也有「改标签」;AI 侧是 `setLabel`):编辑框是绝对定位盖在节点上的 HTML `input`(不用 SVG `foreignObject` —— React 会把 svg 后代建成 SVG 命名空间元素,里面的 input 不按 HTML 渲染),回车提交、Esc 取消、失焦也算提交;**点编辑框外面(画布/标签条/别的元素)就提交并退出** —— 不能指望 `onBlur`,画布的按下处理普遍 `preventDefault()`,而它会挡掉焦点变化,输入框根本不 blur | | 客户端 | **独立文字**(drawio 的 `text` 形状):空白处右键「T 文字」放一段**没有边框、没有底色**的字,位置就是点的地方;放下即进编辑态(drawio 的 `insertText` 也是这样)。它就是**一个节点**,所以拖动/缩放/改字/改字色/进图层/复制粘贴/顺序/避让全都免费复用节点那一套;形状面板里也有「文字」,普通节点能换成文字、文字也能换成别的形状(换走时会把 `strokeColor=none;fillColor=none` 一起清掉,否则换出来的是**看不见的矩形**)。文字元素的"配色"落到 **`fontColor`** 上(它没有填充与描边,往它身上写 fillColor 屏幕上不会有任何变化)。样式与 drawio 的 `Editor.defaultTextStyle` 对齐(`text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0`) | | 客户端 | **pointer capture 只在"真的拖动起来"(>3px)时才抢**:capture 会把随后的 `click`/`dblclick` 目标改成**捕获元素**([w3c/pointerevents#356](https://github.com/w3c/pointerevents/issues/356):click 取按下/松开两个目标的公共祖先,而被捕获的 `pointerup` 目标是画布容器),按下就抢的话"双击节点/线上的字改标签"会**整个失灵**。所以按下只登记坐标,指针移动超过 3px(与"越 3px 才算拖"同一套手感)才在 `pointermove` 里抢 —— 一次点击全程不抢捕获。代价:极快地一甩、第一帧就离开面板的那种拖动可能抓不住(那条拖动本来也要靠 capture 才留得住),这是为了不牺牲双击而付的 | | 客户端 | 读 drawio 的**独立边标签单元**(`edgeLabel`):按 `mxGraphView.getPoint` 的规则算位置(沿边比例 + 垂直偏移 + 残余偏移)并只读显示 | | 客户端 | 走线里**不会出现折返段**(出去又原路描回来):选 L 时避开折返,残余的(目标侧桩点与折点分居拐点两侧时)由 `removeRetraces` 消掉 —— 人摆的折点当尖点时不动 | | 客户端 | **快捷键有归属判定**:焦点在输入框(DSH 的输入框是 Lexical 的 `contenteditable`,事件目标常常还是它内部的 span)、或别的面板的控件上时,画布一个键都不碰 —— 否则在输入框里按退格会删掉画布里的选中内容,`Ctrl+C/V` 也会被 `preventDefault` 吞掉 | | 客户端 | **一次性几何迁移**:菜单「整理几何(吸附到格线)」把这张画布对齐到格线;`node tools/snap-geometry.mjs [--write]` 面向盘上已有文件做一次性迁移 | **未实现**(用户侧):设置页、**图层 v2/v3**(v1 已经能列出/新建/显示隐藏/设为当前层, 还没有:锁定、把选中单元移到别的层、重命名(要在画布上输入)、删除图层(连层内单元, 且不许删最后一层);AI 侧也还没有按层操作的 ops)、分组/子图的**画布内编辑** (层级在文件里保留,但画布按绝对位置显示)、多页的切换与新建、 `.drawio.svg`/`.png`/`.html` 内嵌载体;另外**所有边都画在所有节点下面** (drawio 是按单元顺序混合层叠),所以"让某条边压在某节点上"还不成立 —— 现在的绕法是**把那条边放进更上面的一层**(图层顺序就是粗粒度的 z-order)。 **未实现**(交互细节): - **拖拽时的对齐辅助线只跟节点比**:不与连线的折点/端点对齐(drawio 的 guides 同样只看单元)。 - **没有方向键微移**(位置只能靠鼠标拖,或者让 AI 用 `move`)。 - **单条选中的连线不能复制**:剪贴板只收"两端都在选区内"的边(设计如此, 否则粘出来的边会指向没被粘贴的节点)。 - 导出只有 SVG 与 PNG(2×);没有 PDF,也没有"只导出选中部分"。 **AI 侧(这一版刚补齐,留档)**:`diagram_read` 现在回节点坐标(x/y/w/h); 新增 `{op:'move'}`(节点 dx/dy 或 x/y、连线 dx/dy 平移自己的几何)、 `addEdge` 支持 `fromPoint`/`toPoint`(两端都给点就是**独立线**)、 `{op:'highlight', ids}`(让画布选中给你看,不改文档也不再重排); 宿主留了一层"AI 写盘前的文本",界面「编辑 → 撤销 AI 改动」可退回(`read` 的 `canRevert` 表示有没有)。 另外:**ops 里自带几何(addNode 给 x/y、或有 move)时默认不再重排** —— 否则"挪 40px"会被布局立刻冲掉,看起来像工具坏了。 图层方面 `diagram_read` 现在会回**图层表**(顺序 = 叠放顺序、`visible`、`locked`)和每个单元的 `layer`,所以 AI 知道"这东西在哪一层""哪一层用户现在看不见"(隐藏层的单元照旧读得到 —— 隐藏 ≠ 删除)。按层增删/移动单元的 ops 要等 v3。 **AI 侧 P0(这一版刚补齐,留档)**:四件事一起做的,它们把 AI 从"猜"变成"知道": | 做了什么 | 为什么 | |---|---| | **选区通道**:客户端 `action:'selection'` 上报(防抖 300ms,框选时指针每动一下都会改选区),`diagram_read` 用 `selection` 回给模型 | 用户说"把这几个挪一下/换个颜色"时,"这几个"原来是看不见的,只能靠坐标猜 —— 这是 AI 侧最容易咬人的一条 | | **结构化返回 `created`/`changed`/`removed`** | 自动分配的 id 以前只出现在 summary 文本里,模型要用它就得解析自然语言(或者去猜 `n7`) | | **`ids:[…]` 批量 + `as` 别名** | "把这几个换成绿色"不必发 12 个 op;`{op:'addNode', as:'start'}` 之后同一个 ops 数组里就能 `from:'start'` —— 非空画布上猜编号必错 | | **`expectRevision`** | `read` 回来的 `revision` 原来在 AI 侧**毫无用处**(409 只存在于画布保存那条路由),于是"AI 读 → 用户在 drawio 里改 → AI 写"会把用户的改动静默覆盖。现在基线对不上就报错且不写盘 | 连带修掉的两个坑,都在自测里留了断言: ① **别名/id 复用**:id 是会被复用的(删掉 `n3` 再建一个,下一个还是 `n3`),复用的新节点 **不该继承**"用户之前选中过 `n3`" —— 所以删除时主动从选区里划掉(`forgetSelection`), 光靠读时的存在性过滤不够。 ② **自测桩的 `processPath(句柄)`**:原来退化成 `"[object Object]"`,两个不同文件算出**同一个** `absolute` —— 任何"按文件配对"的逻辑(选区就是)在自测里会假绿。桩已按真实 fs 建模。 **AI 侧 P1(这一版刚补齐,留档)**:把"客户端能做、AI 只能重画"的几件事补齐了: | 做了什么 | 为什么 | |---|---| | `setEdge` 重接边 | 以前换接点只能 `remove`+`addEdge`:id、标签、标签位置、折点、端点约束全丢 | | `setLabelPos` + read 的 `labelX/labelY` | 客户端能拖边上的字,AI 原来既看不见也改不了("文字压住线"只能让用户动手) | | `order` | 客户端能置顶/置底,AI 原来不能;**写回会真的改文件里单元的先后**(不然重开就变回去) | | `duplicate` | 客户端有 Ctrl+C/V,AI 只能重画一批 addNode+addEdge(折点/端点约束全丢) | | `addLayer`/`setLayer`/`setLayerProps` + `layer` 参数 | AI 原来只能看层表;而且**宿主写回根本不认模型的 `layer`**(下面那条注记) | | `export` | "给我一张 PNG"做不到:渲染器只在浏览器,于是做成了请求/回执通道 | | 节点的 `parent` | 让"移动容器不带走子单元""删容器会重新挂父级"从暗坑变成已知 | > **移层曾经是个假动作。** `writeChainOf`(写回时算"新父级"的那段)原来只看**文件里的父链**, > 而模型里每个单元都带 `layer`(读的时候沿父链得到的)—— 于是 `setLayer` 改了模型的 layer, > 文件里的 `parent` 一个字都没动,重新读回来还是老层,看起来像"移动没生效"。 > 修法是"只有**层真的变了**才换父级":容器里的单元 `layer` 没变时**必须**保住容器父级, > 不然每次保存都会把分组拍平(那比移层失效严重得多)。两条都留了断言。 > > **技能正文里的反引号是有毒的。** `SKILL_BODY` 是模板串,markdown 里写 `` `.drawio` `` 会 > **提前闭合模板串**,后面那段就成了 JS 代码 —— 表现是 `import lib/index.js` 抛 > `TypeError: ".drawio" is not a function`(而不是语法错误,因为 `"str" \`x\`` 是合法的标签模板)。 > 这一版踩了两次,`tools/check-package.mjs` 的"正文讲了 …"关键字表就是为这类内容守门的。 **AI 侧 P2(这一版刚补齐,留档)**: | 做了什么 | 为什么 | |---|---| | `diagram_read` 收 `layer` / `ids` | 大图上"先看一层""只看刚改的那几个"能省掉一大段上下文;过滤时同时报 `totalNodes/totalEdges` 与整份层表(不然模型不知道自己漏了什么) | | `page`(纸张尺寸) | `pageWidth/pageHeight` 原来只在 `mxGraphModel` 上原样保留、AI 看不见;现在 read 报出来,从零重建时也照旧用它(不是悄悄变回 A4)。**没写就不编** —— "这份文件没说"和"就是 A4"是两件事 | | 回退栈(最多 8 步) | 原来是**一层**:一轮会话里 AI 改三次,用户按一下「撤销 AI 改动」就跳回最开始,中间两步无从恢复。现在是栈,每按一次退一步;read 的 `revertSteps` 是剩余步数 | --- ## 载体:`.drawio` 就是真相 画布文件是 **drawio 自己的 mxfile**(`.drawio`)。没有"我们自己的格式 + 导入导出"这一层: drawio 打开它、画布打开它、AI 改它,改的都是同一份文件。 **难点不在读,在写。** drawio 的能力比本画布大(多页、图层、分组层级、图片、HTML 标签、 UserObject 自定义属性、旋转翻转、页面设置…),而本画布只理解其中一个子集。如果写回是 "按模型重新生成整份 XML",用户稿子里那些我们不理解的部分会在保存时**被悄悄删掉** —— "悄悄"是最糟的失败方式(用户以为在编辑,其实在删)。所以写回是**外科手术**: | 规则 | 说明 | |---|---| | 页 1 的 `` 之外 | 其他页、mxfile 属性、空白 —— **逐字节保留** | | 我们拥有的单元(节点/边) | 只重写 `value`/`style`/几何/端点这几处属性;`` 包装上的自定义属性、`` 原样留着 | | 我们不认识的单元 | 原样留着(例如两端都没落点的边),不因为不认识就删 | | 只有"我们导入过、模型里又没有了"的单元 | 才删 —— 那才是用户真的删了它 | | 压缩形态 | 原本压缩的页体,写回后仍然压缩 | | 容器子单元 | 坐标在文件里是**相对父级**的:读出来累加、写回去减掉。容器被删时,子单元被接到还活着的祖先上并补回坐标差(不留悬空 `parent`) | | 元数据 | `meta.pinned`(人摆过版面,AI 别重排)存成文件里一个隐藏的 `` 单元;`revision` **不落盘**,直接用文件内容的指纹 | 由此得到一条可断言的性质:**打开后原样保存,文件逐字节不变**(`check-mxfile` 里 30 多条断言盯着它)。 **有损的地方一律如实报。** 多页只显示第 1 页、分组按绝对位置显示、图片按矩形显示、HTML 标签 按纯文本显示 —— 这些都会作为 `notes` 出现在工作栏和 `diagram_read` 的返回里, 因为用户会拿这份文件继续在 drawio 里编辑,不说明就等于骗人。 > 边标签单元(drawio 的 `edgeLabel`)这一条同时是**安全**问题:把它当成普通节点读进来, > 画布上会多一个鬼影框,一旦被拖动,写回就会把**绝对坐标**写进一个"相对"几何里, > 把 drawio 里的标签偏移弄歪。所以它**不作为节点导入、不拥有、写回原样保留**; > 只作为 `doc.labels` 读出来**只读显示**(见下面「边上的文字」)。 ### 边上的文字:drawio 有两套存法 同一句"线上的字",drawio 按用户怎么操作存成两种形态(对着 `Graph.js` 与 `mxGraphView.js` 核实): | 形态 | 谁存文字 | 位置存在哪 | 我们 | |---|---|---|---| | 边自己的标签 | 边单元的 `value` | 边自己的 `mxGeometry`:`x` = 沿边比例(-1..1,0 = 中点)、`y` = 垂直偏移 px、`offset`(``)= 残余 px | **读写**:文字进 `value`(边上有自定义数据时进 ``),位置进 `mxGeometry` 的 `x/y` 与 `offset` 点 —— **能拖**,与 drawio 同一套存法 | | 独立标签单元(把标签拖走之后 drawio 会转成它) | 子单元的 `value`:`style` 带 `edgeLabel`、`vertex=1`、`parent` = 那条边 | 同一个子单元的 `mxGeometry`(`relative="1"` + `x/y/offset`) | **只读显示**,不拥有、写回原样保留(不能编辑/拖动) | 落点两边是**同一套公式**:`mxGraphView.updateEdgeLabelOffset` → 几何是 `relative` 时走 `getPoint()`, `x = 0` 即"沿折线走一半" = **弧长中点**(不是包围盒中心,也不是某一段的中点)。 这里以前取的是"第 2 段的中点",于是**同一份文件在两边画在不同地方** —— 折线一拐, 那一段可能只是 24px 的引出段,文字就贴在节点边上。现在 `edgeLabelPosition(pts, edge)` 走 `edgeLabelPointAt(pts, labelX, labelY, labelOffsetX, labelOffsetY)`,缺省(没拖过)就是 `0,0,0,0` = 弧长中点,与 drawio 对齐。 **拖标签**也是同一套:drawio 靠 `mxEdgeHandler.moveLabel`,我们靠它的镜像 —— `relativePointOnPath()`(= `mxGraphView.getRelativePoint`)把指针位置反解成 `x` = 沿边比例、`y` = 垂距,再按 drawio 的存法收尾成 **`x` 四位小数、`y` 取整、零头进 `offset`** (于是标签正好落在指针下,而文件里那两个数是干净的数)。手势上: **按住线上的字拖**(越过 3px 才算拖,普通点选不改文档);右键菜单多一项「标签居中」把位置删掉。 **拖动有吸附**(与画布其余部分同一套单位:连线几何走半格 5px): | 吸附 | 什么时候起作用 | |---|---| | 指针吸到半格 | 总是 —— 线本身在格线上时,标签自动落在线上(垂距 0) | | 贴线吸附(垂距 ≤ 3px 并回 0) | 线不在格线上时(贴节点边框的那一轴是精确值,例如 x=455) | **线在文字的位置断开,而不是盖一层白底。** 这是刻意的做法:`labelGapsOnPath()` 把"文字框" 与折线求交(正交路径每段只有横/竖,就是两个一维区间求交),`cutPathByGaps()` 按弧长把那段挖掉, 于是渲染出来是**两条子路径**(`M… M…`)—— 字的位置根本没有线,网格也不会被白块盖住。 两端各留 `min(8px, 总长 20%)`:文字比这条线还长时(长标签压在短边上)不会连箭头一起消失。 命中带**不挖空**(线在字下面也要能点中/拖动)。 底衬只在样式**明确要求**时画(drawio 的 `labelBackgroundColor`,含它写出来的 8 位带透明度 `#ffffffe0` → 拆成 `fill` + `fill-opacity`);默认不画。 写回只在真的变了才动手,且几何标签上的其它属性一律原样保留 —— > 这里修掉过一个**数据丢失**:以前改折点会整块重建 ``,于是 drawio 拖过的 > 位置(自闭合几何上的 `x/y`、或 ``)会被静默抹掉。 > 现在 `check-mxfile` 里两条回归盯着它(自闭合几何加折点、有折点时挪折点)。 > 非 `relative` 的几何是另一套语义(相对两端连线中点),我们**不认**这种位置,但属性原样保留。 **还差的(诚实标注)**:独立 `edgeLabel` 单元不能编辑/拖动/新建、 单行纯文本(drawio 还认 `html=1` 多行、`labelBackgroundColor`、对齐与旋转,我们只认 `fontSize`/`fontColor`)。 **旧格式(`.dshd.json`)已经彻底退场。** 仓库里不再有这种文件,也不再有迁移脚本 —— 需要从旧版本升上来的话,用 git 历史里的那一版: `git show 873a835:tools/migrate-dshd.mjs > migrate.mjs && node migrate.mjs`(它不删旧文件、 不覆盖已有目标)。 --- ## 数据格式:文档层仍按 drawio 的语义组织 宿主与客户端在内存里交换的是一份**语义文档**(不是文件本身),字段按 drawio 的语义组织: 形状、配色、线型、箭头、端点约束都是 drawio 的 style 键,没有自己的一套封闭枚举。键名逐个对着 drawio 源码核实过(`mxConstants.js`、`Graph.js`、`mxConnector.js`、`docs/claude/libavoid-routing.md`), 所以同一个文件在 `drawio → 本插件 → drawio` 之间往返不会丢东西。 ```jsonc { "version": 2, "revision": "9f2c1a4b7e03", // = 文件内容指纹,不落盘 "meta": { "pinned": true }, "layers": [ // root 下那几个没有几何的图层单元(顺序 = 叠放顺序) { "id": "1", "name": "主流程", "visible": true, "locked": false } ], "nodes": [ { "id": "doc", "label": "真相源", "style": "shape=document;fillColor=#fff2cc;strokeColor=#d6b656;", "x": 60, "y": 60, "w": 186, "h": 86, "layer": "1" } ], "edges": [ { "id": "e2", "from": "doc", "to": "canvas", "label": "变更流", "layer": "1", "style": "edgeStyle=orthogonalEdgeStyle;rounded=0;jettySize=auto;orthogonalLoop=1;html=1;endArrow=classic;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" } ] } ``` 三条规则,与 drawio 一致: 1. **默认值一律省略。** `Graph.prototype.defaultVertexStyle = {}` —— 普通矩形就是**空样式** ("rect = 没有 shape 键")。`endArrow` 缺省 = 不画箭头(`mxConnector` 拿 `NONE` 当缺省)。 2. **开放键值集合。** 认不出的键**原样保留、原样写回**,只是不认识就不渲染 —— 于是 drawio 导出的 文件在这里编辑一轮再拿回去,陌生键不会消失(v1 的"认不出的只能丢"就是这么修掉的)。 3. **折点与端点是两套模型。** `points` 只放人摆的折点;"从哪一侧进出"是 `exitX/exitY`(源端)与 `entryX/entryY`(目标端)的**比例约束**(0 / 0.5 / 1 = 左·中·右、上·中·下);悬空端的自由点才是 `sourcePoint`/`targetPoint`,且仅在该端**没有**真实顶点时生效(`mxGeometry` 的原话)—— 悬空边照常渲染(v1 会把它整条丢掉),两端都悬空也能画。 4. **图层是文档结构的一部分。** `layers` 数组 + 每个单元一个 `layer`(层 id)。 层不是我们发明的分组:它就是这个文件里没有几何的那几个 ``(见「图层」一节)。 普通单层文件也照样有 `layers`(就是那个缺省的 ``,名字为空), 单元一律带 `layer` —— 少写一个"有时有、有时没有"的字段,读的人少一处分支。 | 用途 | 键(drawio 名) | |---|---| | 形状 | `shape=`、`ellipse`、`rhombus`、`rounded=`、`arcSize=`、**`text`(独立文字,drawio 的裸键)** | | 配色 | `fillColor`、`strokeColor`、`fontColor`、`strokeWidth` | | 线型 | `dashed=1`、`dashPattern`(画布缺省 `3 3`) | | 箭头 | `endArrow`、`startArrow`(`classic` / `none` / …) | | 路由 | `edgeStyle=orthogonalEdgeStyle`(`none` = 直线)、`rounded=`(折角是否圆滑)、`curved=1`(把折线抹成曲线) | | 端点 | `exitX`/`exitY`、`entryX`/`entryY`、`jettySize`(引出段长度,数字或 `auto`) | | 避让 | `libavoidRouting=1`(drawio 里它就是一个 per-edge 键) | | 文本 | `html=1`、`whiteSpace=wrap`、`fontSize`、`fontColor` | > **哪些键真的驱动渲染**:形状 / 配色 / 线型(含 `dashPattern` 间距)/ 箭头 / 路由(`edgeStyle=orthogonalEdgeStyle` > 与 `none` 直线)/ 折角(`rounded`)/ 端点约束(`exitX·exitY`、`entryX·entryY`)/ `jettySize` / > `fontSize`·`fontColor`·`whiteSpace` 都**真正生效**;而 `html=1`(本画布始终按纯文本渲染标签)、 > `endSize`·`startSize`·`endFill` 这类箭头几何、`libavoidRouting`(本画布的路由本来就带避让惩罚) > 以及任何陌生键,只是**原样保留**、不影响画面 —— 这不影响往返:它们不会丢。 **工具语言 ≠ 文档语言。** AI 侧的 ops 仍然收 `shape:'diamond'`、`dash:'虚线'`、`arrow:'双向'`、 `style:'yellow'`、`exit:'e'` 这些**糖**,宿主翻成上面的键再落盘;`keys:{…}` 用来写任意 drawio 键 (值给 `null` = 删键回缺省)。drawio 自己也是这个分工:面板上给名字,文档里只有十六进制。 两条与"独立文字"有关的细节:`shape:'text'` 写出去的是 drawio 的 `Editor.defaultTextStyle` (无边框无底色),而且对文字元素 `style:'red'` 会落到 **`fontColor`** 而不是填充色(见「独立文字」一节)。 > **一处刻意的规范化**:drawio 自己写裸键 `text;html=1;…`,我们的 `parseStyle` 把"单独一个键" > 规范化成 `=1`(内核注释里就写着这两者等价,`ellipse;` 同理),所以**由我们新写/改过的**文字元素 > 落盘是 `text=1;…`。只读不改的单元**一个字节都不动**(无损写回的规矩),drawio 打开两种写法 > 都是同一段文字。 **只有一种格式。** `.dshd.json` 那套语义枚举(`shape:'rect'`、`style:'blue'`、`dash:'dashed'`、 把进出侧混在 `points` 里的桩点)随载体切换一起退场了 —— 连同它的读时升级代码。留着一份 "另一种格式的迁移"只会让人以为还有第二种真相。 ## mxfile 读写细节 drawio 的文件就是 mxfile:`` 里一堆 `` + ``。文档层已经 按同样的语义组织(见上一节),所以**style 串是原样搬运的** —— 颜色、虚线、箭头、 `exitX/exitY` 到了 drawio 里还是那些键,不需要翻译;真正要翻译的只有**几何与结构**: | 结构 | 读进来 | 写回去 | |---|---|---| | 顶点 | `mxGeometry@x/y/width/height`;**容器子单元的坐标是相对父级的**,沿 `parent` 链累加成绝对坐标 | 按"画布上的绝对坐标 − 父链偏移"反算回相对坐标 | | 单元 id | `id` 一般在 `mxCell` 上,但 `` 包装的单元 **id/label 在外层** | 标签写回外层 `label`,包装上的自定义属性原样留着 | | 标签 | `value`(或 `object@label`);`
` 折成空格、其余标签去掉,实体解码 | 纯文本 + XML 转义(改过标签的单元会丢掉原来的 HTML 标签,已在 notes 里说明) | | 折点 | `` | 语义没变就一个字节都不动 | | 悬空端 | `as="sourcePoint"` / `as="targetPoint"` —— **只在该端没有真实顶点时才生效**(有顶点时忽略,与 drawio 一致) | 同左 | | 边端点 | `source` / `target` | 同左 | | 新单元 | —— | 插到最后一个单元之后,`parent` 用**当前图层**(没设过就用第一个图层);**缩进跟着这个文件走**(硬编码会顺带改动别处的字节) | | 图层 | root 下**没有几何**的 ``;`value` = 名字、`visible="0"` = 隐藏、`locked="1"` = 锁定;单元的 `parent` 指向层 id,层的先后 = 粗粒度 z-order | 只做属性级改动(`value`/`visible`/`locked`);隐藏写 `visible="0"`,改回可见就**删掉这个属性**(不写 `visible="1"` 这种噪音);新层按同一套写法插入 | **压缩**:drawio 默认把 `` 的内容压成 `base64(raw deflate(URI 编码的 XML))` (`Graph.compress`)。解压靠宿主(Node 有 `node:zlib`,浏览器没有),而且判形态是**看内容** 而不是看 `compressed` 属性 —— 属性标错的文件照样读得开。原有文件是压缩的,写回后仍压缩。 **编码**:UTF-8 首部的 BOM 会被摘掉(有的编辑器会加);若文件其实是 UTF-16(记事本的"Unicode" 另存),按 UTF-8 读进来会在字节之间夹满空字符 —— 这时**报一句明确的编码错**(请用 UTF-8 另存), 而不是硬按 UTF-16 重解释:那会把中日韩标签变成乱码,而乱码是看不见的损失。 代码只有一份:`src/mxfile.js`(`parseMxfile` / `buildMxfile`),由构建器原样拷成 `lib/mxfile.js` 供宿主半边 import;它**不进客户端 bundle**(浏览器没有 zlib,放进去只会白占体积)。 ## 图层(v1) drawio 的图层不是"我们发明的一个概念",它就是这个文件里的几个**没有几何的 ``**: `value` 是名字、`visible="0"` 是隐藏、`locked="1"` 是锁定,每个单元靠 `parent` 说自己属于哪一层。 所以 v1 做的是"**认出它们、按它们显示、让新东西进对层**",而不是另造一套分组的元数据: | 概念 | 存在哪 | 为什么 | |---|---|---| | 层 / 名字 / 有无 | **文件**(就是那几个 ``) | drawio 打开这份文件时看到的层必须和我们一样 | | 显示 / 隐藏 | **文件**(`visible` 属性) | 这是**文档状态**:它会被保存、被撤销、drawio 也照它显示。做成纯客户端开关的话,用户隐藏一层再保存,别的编辑器里它又冒出来了 | | **当前图层** | **客户端状态**(不进文件) | drawio 也是这样:当前层是编辑时的焦点,不是文档内容。关掉再打开回到第一层 | | 层的先后 | 就是那些单元在 root 里的顺序 | 顺序 = 粗粒度叠放顺序;**这也正好是"所有边都在所有节点下面"那个限制的绕法**(把边放进上面一层) | 三条不能破的规则: 1. **隐藏 ≠ 删除。** 隐藏层的单元在文件里一个字节都不许动,写回时也不能因为"看不见"就当它不存在 (面板、框选、命中、避让都可以忽略它,但所有权判定不行 —— 那会把用户的图删掉)。 2. **只有一个"可见性裁判"。** 所有"这单元画不画/点不点得到/参不参与避让"的判断都走 `buildGeometry` 这一个入口(它是渲染、命中、路由障碍的公共上游),隐藏层的节点在那里就被剔掉, 边与独立标签在各自的渲染循环里显式过滤。散在各处判断迟早会漏一处。 3. **新单元进当前层。** 新建节点、从空白处拉出的独立线、粘贴出来的单元都盖当前层的章; 粘贴时如果原件带着层就沿用原件那一层(复制一层里的东西,贴出来还在那一层)。 还有一条属于"文档对象"的规则:**图层表必须一路跟着文档走,谁都不许"逐个字段抄"**。 `emptyDoc()`(新建画布的内存形状)与 `action:'create'`(新建画布落盘的那一份)都走它, 带一个缺省图层 —— 与盘上那个 `` 对应,落盘字节完全相同, 只是让"刚新建"与"存过再打开"看到的是同一张画布(否则新建的画布在图层面板里 永远说"没有图层信息",一存一开又有了)。这条规则是被咬出来的,见下面那条注记。 v1 的界面入口在「图层」菜单:列出各层(`👁`/`🚫` 切换显示、点名字设为当前层)+ 「+ 新建图层」(自动起名 `图层 N`,id 避开已用的)。**锁定、把选中单元移到别的层、重命名、 删除层** 都是 v2 —— 删除尤其要注意"不许删掉最后一层"(那样文件里就没有能挂单元的层了)。 回归断言:`check-mxfile` 的 [10] 图层一节(读层、隐藏属性级写入、显示回来时删属性、 新建单元进对层、多图层文件原样写回逐字节不变)、`check-render` 的图层一节 (隐藏层整层不画/点不到/框不到、新单元与粘贴进当前层)、`check-host` 的图层一节 (read 回图层表与每单元 `layer`、**改图之后层不能掉**、还不存在的画布也报一个缺省图层 —— 第一条抓到过 `normalizeDoc` 漏传 `layers` 导致"AI 一改图所有单元被挂回缺省层")、 `check-render` 的「文档在客户端里转一圈不能把字段吃掉」一节(见下)、`check-component` 的「工具条」一节 (把 `layerList/activeLayerId/item/layerMenuItems/toolbarMenus` **这段真实代码整体求值并调用**, 所以"自由变量不在作用域里"会直接炸 —— 见下)。 > **四个自测盲区,各付了一次线上降级的代价。** > ① `check-component` 原来把 `layerMenuItems` 桩成 `() => []`:真实函数体一次都没跑过, > 于是 `item`(菜单项构造器)被写在 `toolbarMenus` 肚子里、而 `layerMenuItems` 在外层这种 > **作用域错位**一路全绿 —— 用户打开面板看到的是 `ReferenceError: item is not defined` + > "画布未渲染"。现在 `item / layerMenuItems / toolbarMenus` 这段真实代码被整体抽出来求值并调用, > 我把 `item` 搬回去复现过:断言确实红。 > ② `check-component` 的 React 桩里 `createElement` 只记录元素、**从不调用子组件**, > 所以 `renderOnce` 只跑了最外层那个注册组件,`CanvasView` 本体从没被执行过 —— > 渲染期的错误(这个 bug 就属于渲染期,它在 `toolbarMenus()` 里)它看不到。 > ③ **"逐个字段抄文档"没人管。** 图层在**五处**被同一个毛病吃掉:宿主的 `normalizeDoc`、 > 宿主的 `action:'create'`(新建时手搭的那份空文档)、客户端的 `docFromPayload`(宿主 → 客户端)、 > `cloneDoc`(每次本地改动与撤销快照)、`pasteInto`(粘贴)—— 都是 > `{ version, meta, nodes, edges, labels }` 这么一个个列出来的,于是 `layers` 一路被丢。 > 用户看到的是"这张画布没有图层信息",而**更隐蔽的是显示/隐藏变成空操作**: > `applyLocal` 传进 mutate 的 `next` 是 `cloneDoc` 出来的、`next.layers` 是 `undefined`, > 于是 `if (layers === null) return` 直接返回 —— 点了眼睛什么都不发生,也不报错。 > 现在四处统一改成"**摊开整份文档再覆盖要深拷的那几个数组**"(`Object.assign({}, source, …)`), > `check-render` 的「文档在客户端里转一圈不能把字段吃掉」一节拿**键集合**比对来钉: > 基准是内核归一化后的文档,谁抄漏一个字段就报出缺了哪个 —— 以后内核再加文档级字段, > 这几处必须自动跟上,而不是再靠人记得去改四个地方。 > > 同一类地雷还有一颗已经拆掉:`layerList` / `activeLayerId`("当前层是哪个")原本算在菜单那段, > 却有更早的三个函数要用它(新建节点/连线/粘贴盖章)。功能上能用(那些都是事件期触发, > 组件体早跑完了),但只要哪天有人把它挪进渲染期,就会变成 > `Cannot access 'activeLayerId' before initialization` —— 同样的整画布降级。 > 现在它就在 `currentLayerId` 状态下面就地算,并且有一条断言盯着"它必须出现在最早用到它之前"。 > > **顺带记住 ③ 的运维含义**:宿主半边没有热重载,所以"客户端已经修了、宿主还是旧的"这种半新半旧 > 状态会真的出现 —— 旧宿主仍然会把 `layers` 丢掉,症状与没修一样。**改完宿主必须重启 `dsh web`。** > > ④ **"断言某个处理器挂了"不等于"浏览器会把事件送到它手上"。** > `check-render` 里对双击只断言过**边上的文字/把手**(直接 `props.onDoubleClick({})` 调一下), > 节点这一路一条都没有;于是 `onNodePointerDown` 里按下就 `setPointerCapture(画布)` 之后 > (95cc9d7 的选区拖动顺手加的),**双击节点改内容整个失灵**却全绿 —— > 因为捕获把 `click`/`dblclick` 的目标改成了画布容器,节点那个 `` 根本不在事件路径里, > 而我们测的是"函数调得通不通",不是"事件到不到得了"。 > 现在补两类断言:`check-render` 新增「双击节点 = 就地改标签」一节(真的调用节点 `` 上的处理器、 > 并检查 id/事件都传对),`check-component` 新增「按下时不许抢 pointer capture」一节 > —— 既做结构性守卫(`setPointerCapture` 全文件**只允许一处**、且必须在 `takePendingCapture` 里), > 也把 `takePendingCapture` 抽出来喂假事件真跑(1px 手抖不抢 / 4px 才抢 / 别的 pointerId 不抢)。 > 两类都做过破坏性复现:把 capture 挪回 pointerdown、把阈值改成 0,断言都确实变红。 ## 画布单位与吸附 | 手势 | 最小单位 | 说明 | |---|---|---| | 节点移动 | **一格 = 10px** | 位置按 `GRID` **绝对**吸附:指针再细,节点也落在格线上 | | 节点缩放 | **一格 = 10px** | 吸附的是**尺寸**(不是增量):186 + 30 → 220,而不是 216。对边原地不动,下限也取整格(60 × 40) | | 连线折点 / 线段 | **半格 = 5px** | `EDGE_GRID = GRID / 2`。连线比节点需要更细的手感;两个端点用**同一个位移**,段不会被吸附弄歪 | | 对齐 / 分布 | 一格 = 10px | `computeAlignMoves(…, GRID)` | | 自动路由的线段 | **半格 = 5px** | 路由器自己选出来的坐标(端点接入位置、走廊、拐角)一律吸附到 5px;**贴着节点边框的那一轴保持精确**,不为了对齐把线从边框上挪开 | | 新建节点尺寸 | **130 × 60**(整格) | 文档里缺 `w`/`h` 时按 **150 × 60** 兜底;AI 侧按标签估宽也会**向上取整到 10**(`estimateWidth`) | > **默认尺寸为什么要整格**:旧的默认高是 56 —— 不是 10 的倍数,于是节点中心落在 `y + 28` 上, > 只要两个节点的中心差一两像素,连线就会多出"差一像素、合不成一条"的台阶。 > 现在新建节点是 130 × 60、兜底 150 × 60、AI 估宽向上取整到 10,中心永远是 5 的倍数。 > > **自动路由为什么要吸附**:线段的位置来自"节点中心"与候选走廊中点。节点尺寸非整格时 > (56 高 → 中心 `y+28`、186 宽 → 中心 `x+93`),线段就会落在既不在整格也不在半格的坐标上。 > 所以路由器自己选的坐标一律吸附到 5px;而**用户手摆的折点是用户数据,一律不动** —— > 拖动时按半格吸附,已有文档里的坐标保持原样。 > 这两条都有断言:`check-route-preview.mjs` 的"移动单位"与"自动路由:每条线段都落在整格/半格线上"两节。 > **为什么缩放吸附"尺寸"而不是"增量"**:文档里的宽度可能是 186 这种非整格值(估宽 / 导入 / 手写), > 只吸附增量会一直把那个零头带着走(186 → 196 → 206…),中心也就永远落在半像素上 —— > 那正是"两条线段差一像素、合不成一条"的上游来源。 > 这三条规则都有断言盯着:`check-route-preview.mjs` 的"移动单位"一节直接断言 > `snapTo` / `resizeBox` / `segmentMoveOf` 三个纯函数。 ### meta.pinned:人手工摆过的版面不会被 AI 冲掉 文档带 `meta.pinned: true`(浏览器里存过盘就会打上)时,`diagram_apply` 默认 `layout: 'none'`, 只给新节点补位,不动已有坐标;想重排必须显式传 `layout`。 > 这里踩过一个坑,值得记下来:`diagram_apply` 原本用整体赋值写 `doc.meta = { engine, layout }`, > 于是**第一次 AI 改图就把 `pinned` 擦掉了** —— 那一次没事(mode 已经是 `none`), > 但第二次起读不到 `pinned`,mode 回落 `dagre-tb`,人摆好的版面被整张重排。 > 现在有回归测试盯着(`tools/check-host.mjs` 的 "meta.pinned" 一节)。 > **教训:用整体赋值覆盖 meta 时,没被显式处理的字段会静默消失。** ### 「一条线段上有两个点」是怎么来的 现象:某个节点上看起来一条直线上挂了两个段把手。 根因不在把手,在**路径**:两个节点的中心差几像素(自动布局取整的零头)时, `orthoV` 会在"起点中心 x"和"终点中心 x"之间**主动**走一个台阶 —— doc 中心 153、fs 中心 151.5 时生成 ``` (153,476) (153,528) (151.5,528) (151.5,580) ``` 视觉上是一条竖线,几何上是两条互相错开 1.5px 的线段,于是**各长一个段把手**。 修法是在**路由的输入端**对齐中心(`snapNearAxis`,容差 4px = 不到半个网格): 台阶根本不会产生,后续所有几何推理仍按原来的精确比较走。 > 走过两条弯路,都记在 `simplifyCollinear` 的注释里: > 1. 事后去清理折线(把 1.5px 的台阶并掉)会**凭空造出一条水平线**, > 再被共线消除吃掉,路径就断成半截(e5 会退化成从 y=528 起的 2 点线); > 2. 改 `simplifyCollinear` 时用 `out` 里最后两个点当邻居,会让**首点永远凑不齐两个邻居** > 而被判成共线删掉 —— 表现是 Z 形路径整段少掉一截。 > > 现在这两条都有断言守着:`tools/check-route-preview.mjs` 的"共线化简:必须保首尾" > 与"中心对齐"两节,外加一节直接读 `demo.drawio`、断言**真图上没有碎段、没有挨在一起的把手**。 ### 每段都有一个段把手(短段也不例外) 选中一条连线时,**每一段**都有个橙色空心把手,拖它就是整段平移。这里曾经写着 `if (segLen < 26) continue`("太短的段不放,否则手柄会挤成一堆")—— 代价是**短段整段挪不动**, 而差几像素的台阶、贴边的引出段、两个节点挨得近时那一小截,恰恰都是短段(实测报过: "短线段没有可移动的段点")。现在: - 每段都给把手,半径随段长收(4 → 3); - 首/末段的中点若离端点把手太近(两个圈会叠住,段和端点都抓不准), 把手挪到这一段的**另一端**(那个折点)—— 仍在这一段上,但离端点够远; - 整条边只有一段时(首即末)不挪 —— 挪到哪一端都会正好压在端点把手上,留在中点反而最清楚。 `tools/check-render.mjs` 的"短线段也要有可拖的段把手"一节盯着它:段数 == 把手数、 每个把手落在它那一段上、按下去报到正确的段号。 ### 线不能穿进节点内部 判定要把盒子**内缩 ε**:贴着边框走不算"进去"(很多正常路径就走在边框上)。 折点可能落在某个节点内部 —— 用户把线拖到节点上,或者节点移动后把原本在外的折点"吞"了进去。 照直连过去线就扎进节点里,实测过: ``` 折点在源节点内部 → (160,30) (80,30) (80,330) ... 从东边出去又折回节点里 80px ``` `pushOutOfBox` / `boxContaining` 在路由前把这类折点推到盒子外面(推最近的一侧, 优先沿"朝向另一端"的方向),**只改路由用的副本,不动用户的折点数据**。 回归测试见 `check-route-preview.mjs` 的"线不能穿进节点内部"一节。 > **已知未修**:两个节点**重叠**或一个被另一个**完全包住**时,线仍会穿过被包住那个的内部 —— > 那种几何下"不穿进去"本身就不可能(端点就在对方肚子里)。 > > **已知未修**:拖线预览在"指针靠近源节点那一侧的目标端点"时,会**回穿源节点**: > `stubPointFor(src,'e')` 在 (184,30),而目标端点在下方,于是路径沿 x=184 往下走, > 而 x=184 落在源盒的 x 区间内 —— 线段擦过源盒内部。根因是**引出侧只看指针方向**、 > 没有对整条路径做长度评估。修法是联合搜索两端(试过一次,见上一节,收益不抵复杂度)。 ### 折点链的绕行:`connectOrtho` 该怎么选 L `routeThroughWaypoints` 逐步穿过用户摆的折点,每一步都是"两条 L 连过去"。 这两条的**曼哈顿总长完全相同**(走的都是同样的 dx、dy),差别只在拐点落在哪个角: | 走法 | 拐点 | 结果 | |---|---|---| | 先横后竖 | `(b.x, a.y)` —— 与 a 同高 | 长的那一段先走完,短的收尾 | | 先竖后横 | `(a.x, b.y)` | 短的那一段先走,后面多半要折回来 | 判据是 `|dx| <= |dy|` 时先横(即"先走跨度更大的一轴"),但**它只知道 dx/dy 谁大**: "我们从哪儿来""接下来去哪"它都看不见,于是会挑出一条原路折返的 L(出去再回来 = 两段重合的线)。 两条 L 一样长,所以 `connectOrtho` 最后按**重合段最短**挑,完全平手才用默认判据。 重合有两处来源,两处都要看: | 判据 | 看谁 | 病 | |---|---|---| | `firstOverlap` | **上一段**的方向(从 `points` 末两点读) | 第一步就压着上一段走回去(横着进来又横着往回走) | | `arrivalOverlap` | **下一站**(`connectOrtho` 的第 5 个参数 `next`) | 到站方向被下一段立刻顶回来 —— 下一段是"直的"时必然折返 | `next` 由 `routeThroughWaypoints` / `selfLoopPath` 在循环里传(最后一个折点的"下一站"是落点 `borderPointToward`),不传就只有第一条判据。 > 两条判据都用**长度**而不是"有没有":两边都躲不开时(用户折点自己摆成了来回), > 重合 24px 总好过 44px。真机第二条截图正是这个岔口 —— 44px 的重合压在**用户折点**上 > (`removeRetraces` 不许动它,于是重合留在画面上),24px 那条压在目标侧桩点上, > 顺手就被消干净了。改成按长度选之后,那一整段拖动(7 个采样位置)都是零折返。 > > 原先的实现是"上一段是横的就先横后竖",**完全不管目标在哪边**。于是出现截图里那种线: > 起点 (246,433) 先往右跑到 x=466,再折回左边 x=153 —— 实测 1341px 的路径, > 改判据后降到 1073px;两折点那条 741 → 409。 > > 试过两种更激进的做法,都**回退了**,记在这里免得再试: > 1. "平手时改成先竖" —— 595 → 649(起点在南侧时先竖着出去,下一段又得往北折回); > 2. "四条边各算一遍、取整条路径最短的起点" —— 只在部分场景有效,同样会把 595 弄成 649, > 因为它先挑起点再算末段,末段那条 L 的方向没跟着一起优化。要做就得两端联合搜索。 > > 剩下的绕行(截图那条 1073px 仍比自动路由的 323px 长)是**折点本身摆成了往返**: > 466 → 153 → 466。路由器不该擅自重排用户的折点;想回到简洁路径,右键连线选「自动路由」即可 > (`clearEdgeWaypoints` 会清掉全部折点)。 ### 折返(头发夹)必须消掉:`removeRetraces` 选 L 的前瞻能防住绝大多数折返,但防不住**被数据逼出来的**: 目标侧的桩点(`stubPointFor`,边框外 24px)与用户折点分居拐点两侧时, 无论先横还是先竖都要走一段回程。真机第一条截图就是这样来的 —— 把右侧节点从右往左拖,右下角那条线变成"往右走 44px 再原路描回来": ``` 旧: (75,340) (75,445) (380,445) (336,445) (336,260) (360,260) └── 这一段和上一段完全重合 ──┘ ← 用户看到"两条线" 新: (75,340) (75,445) (380,445) (380,260) (360,260) ``` `pathOf` 还会在每个顶点处画 6px 的圆角,于是尖点那里再鼓出一个小包 —— "本来只有一条线段,现在像画了两条"就是这么来的。 去法很直接:`a → b → c` 而 `b` 是折返尖点(三点共线、方向在 b 处调头)时, 丢掉 `b`,等价于 `a → c`。新走线与 drawio 的 `mxEdgeStyle.SegmentConnector` (`OrthConnector` 在有折点时就是回落到它)**逐点一致**:先竖着上去、最后一段横着进西侧, 中间那段压在节点底下看不见 —— drawio 的段连接器本来就不做障碍避让。 两条边界: - **尖点若是人摆的折点就不动。** 那是用户数据,动了会让 `points[]` 与路径顶点失去一一对应 (段把手 `pathIndexOf` 找不到折点,拖不动)。路由器自己加的桩点/拐点才是这里要收拾的对象。 - **共线化简那遍故意不吃保护名单。** 折返消掉之后,用户折点可能正好落进一条长直线**中间**: 线照样经过它、画面完全一样,硬留成顶点反而在同一条线上多挂一个段把手。 下一次手势的 `prunePoints` 会把这条已经没有几何意义的折点从文档里清掉。 回归断言:`isFoldApex` 的四种情形、`removeRetraces`(非折点尖点被消 / 折点尖点不动 / Z 形不误伤)、 上面那条真机走线的**逐点期望值**、"同一判据在旧走线上能认出 1 段折返"(防假绿), 以及第二条真机截图那组(出口/入口同在东侧 + 折点在外侧):逐点期望值 + **拖动下方节点 7 个位置全程零折返** + 折点仍是路径顶点。 ### 改接端点:预览与落盘必须共用同一个折点拼装函数 "预览"和"松手后真正画出来的线"是两套代码算的,只要判据有一点不同就会分叉 —— 用户看到的就是**松手瞬间整条线跳掉**。这里踩过两个具体的坑: 1. **判据不同**:预览按"被拖那一端选中的端点"决定要不要钉桩点,落盘却按 `borderPointToward` 重算一次,还把固定端也算成了"现在不在自然侧"。于是把 `to` 端拖到目标 **左边**时,预览从左边接进去、落盘按"算法本来会选下边"处理 —— 两条完全不同的线。 2. **方向反了**:折点表按"被拖端 → 固定端"拼,`routeThroughWaypoints` 就倒着走; 拖 `from` 端时预览与落盘一正一反(渲染时反转折线只影响显示,救不了路径本身)。 现在两边都调用**同一个 `waypointsForRetarget`**,折点表一律按边的真实方向(`fromBox → toBox`)排列, 预览**不再反转**折线。回归测试覆盖两个方向 × 四个端点 × 有无折点共 12 种组合,逐点比对。 **未验证**(诚实标注):客户端半边是本仓库手写的,尚未在真实 DSH 里加载过; `remote.workspaceFiles.read/changes` 的调用签名是从官方文件查看器 `dsh-client-ui-sidebar-files` 的用法推定的,首次安装可能要调一次。 --- ## 拖拽连线的实时预览是怎么做的 要点只有一个:**预览和落盘必须走同一条路由函数**,否则松手那一刻整条线会跳变。 ``` 拖拽中(每一帧 pointermove) toUserSpace(指针) → 用户坐标 hitNodeAt(…, HOT_PAD) → 指针下/附近是不是某个节点(按几何判定,容差 18px) routePreviewFor(…) → 用 routeEdge / routeThroughWaypoints / stubPointFor 算正交折线 → 渲染:虚拟预览折线(虚线) + 从折线终点到指针的收尾段(带箭头) + 起点圆点 命中目标时:目标节点外扩 6px 的脉动提示环 + 光标变 copy 松手时(onNodePointerUp) 同一套 stub 规则 → 写进 edge.points → 下一帧真实路由 = 刚才的预览 ``` 三个刻意如此的选择: - **空白处也是折线,不是斜线。** 目标位置用一个零尺寸虚拟盒喂给 `routeEdge`, 于是它照常从 6 个候选里挑一条正交走法 —— 预览里看到的绕行,就是松手后的绕行。 - **端点由用户选,不由算法猜。** 拖线靠近某个节点时,该节点四个端点(上/右/下/左)会画出来, **选中的那个放大高亮**,预览线就接在它上面;想换一边,把指针往那个端点挪近一点即可。 选中的两个端点会写成 style 里的 `exitX/exitY` 与 `entryX/entryY`(drawio 的固定连接点), **不写进 `edge.points`** —— 折点只放人真摆的折点,所以"看到接哪边"和"存下来接哪边"是同一件事, 而 AI 重排图形也不会把端点约束当成过期折点清掉。这条有一组断言盯着 (`check-route-preview.mjs` 的"端点选择"与"改接端点"两节:四个方向 × 两个方向逐点比对预览与落盘)。 - **吸附判定用几何,不用 `elementFromPoint`。** 预览要提前知道落点,而 DOM 命中测试 只在松手那一刻才成立;顺带也就有了"靠近即接"的容差(draw.io 的语义)。 松手落点仍然由 `elementFromPoint` 最终裁决。 - **钉住引出侧时预览可以穿过障碍。** 用户按住某一侧的端点拖出来 = 指定了必经点, 路由必须照它走。这时预览的职责是**如实预告**,不是比落盘更聪明 —— 预览一旦自己绕开,落盘却照旧穿过,松手就又跳了。 这套几何逻辑可以在命令行里自测,不需要浏览器: ```sh npm test # 155 项 mxfile 编解码/写回(含顺序/数据/标签单元/标签位置/图层/独立文字)+ 177 项路由/自环/迟滞/折返 + 221 项宿主(聚焦/注入/树布局/坐标/move/独立线/highlight/撤回/图层/独立文字/线型与字号)+ 436 项渲染/标签/挖空/框选/菜单当前值/字号夹取/辅助线/批量样式/全选/图层/字段不丢/双击改标签/独立文字/线型与字号 + 199 项组件(菜单真实求值/点外面关掉/指针捕获策略/几类并排下拉/横排与手动字号/独立文字入口/降级边界)(合计 1188) # 路由那一节的条数跟着工作区里的 demo.drawio 走(每条边一组不变量),改了那张图就会变 ``` - `tools/check-mxfile.mjs` —— mxfile ↔ 文档:拿**真实产物形状**的夹具读(`` 包装、 root/layer 的 id 带前缀、容器子单元的相对坐标、多页),压缩形态**在测试里现压**(与 `Graph.compress` 同一套算法)再读回,文档 → `.drawio` → 文档的往返逐字段比对, 坏输入必须报错而不是给半张图; - `tools/check-route-preview.mjs` —— 折线正交性、吸附容差边界、**预览与落盘逐点一致**(含改接端点 两个方向、带折点的边)、**拖动线段松手不跳变**、避让、共线化简保首尾、中心对齐容差、 **折返段(出去又原路描回来)必须被消掉**且人摆的折点尖点不许动, 以及直接读 `demo.drawio` 的**真图连线不变量**(最短段 ≥5px、段把手不重叠、零折返 —— 就是「一条线上两个点」和「本来只有一条线却画了两次」那类毛病); - `tools/check-host.mjs` —— 用**内存文件系统**跑完整的 `diagram_apply` / `diagram_read`, 并直接打写回路由验证 `.drawio` 的**读 / 写 / 新建 / 另存为**(打开后原样保存逐字节不变、 改一处不动别处、指纹过时 409、另存为不覆盖、非 .drawio 拒收),以及 `meta.pinned` 是否被保住、非法 ops 是否在写盘前失败(全或无)、边样式落盘; 宿主半边没有热重载(改完要重启 `dsh web`),这个自测把反馈压到一秒内; - `tools/check-render.mjs` —— 用极简 React 桩驱动 `renderDiagram`:预览折线/收尾线/提示环 **确实被画进了 SVG**、连线画法(线型/箭头/marker 是否真的在 defs 里)、 `computeAlignMoves` 的对齐/分布坐标与幂等性,以及**独立文字**(不画边框底色、但留一个透明 命中框,字色由 fontColor 驱动)与"文字→矩形要把隐形设置清掉"; - `tools/check-component.mjs` —— 用带 hooks 的 React 桩把整个面板组件跑起来(状态形状不对时不能整块降级); 另有**工具条一节**:把 `item/layerMenuItems/toolbarMenus` 这一段**真实源码整体抽出来求值并真的调用** (不再把 `layerMenuItems` 桩掉),所以"菜单项定义在看不见它的作用域里"这类自由变量错位会当场炸 (真实事故见「图层」一节末尾),另有一条断言盯着 `layerList/activeLayerId` 必须定义在最早用到它之前; 还有一节**点到外面**:把 `onRootPointerDown` 的真实函数体抽出来、喂假事件真的调用一遍 (菜单外面 / 菜单自己 / 开菜单的按钮 / 没有 `closest` 的 document / **输入框外面** / 输入框自己 六种目标),并断言 root 上真的挂了捕获处理器、按钮与输入框真的带那两个类名 —— 这类"算不算外面"的分支只能靠跑,grep 源码看不出来; 再一节**按下时不许抢 pointer capture**(见「图层」一节末尾的盲区 ④):既做结构性守卫 (`setPointerCapture` 全文件只允许一处),也把 `takePendingCapture` 抽出来喂假事件真跑; 一节**独立文字入口**:右键菜单有「T 文字」、它对文字不套配色、放下即进编辑态、调色板改字色; 一节**线型与字号入口**:直线/直角折线/圆角折线/曲线四个按钮、线型的连带几何动作、 字号三个地方共用同一个控件(「默认」= 删键); 以及一节**键盘归属**:喂假 DOM 节点断言"输入框 / 别的面板里按键时画布一个键都不碰", 以及 window 上那个 keydown 确实**先问归属再动手**。 > **构建期的一道小闸门**:`tools/build.mjs` 现在会检查 `src/index.js` 里反引号的总数是不是偶数 > (客户端 body 早就有一条"代码行里不许有反引号"的检查)。原因是 `SKILL_BODY` 是一整段模板字符串, > **在里面给键名加行内代码反引号**会把模板字符串提前截断、整份 `lib/index.js` 语法错误, > 而构建器照样"成功"写出产物 —— 这个坑一共踩了三次(每次都靠 `npm test` 的 SyntaxError 才发现)。 > 现在构建这一步就会报出"最后一个反引号在第几行"。 --- ## 为什么构建器仍然很小 本机**没有** typescript / tsdown / esbuild / react,无法跑构建链。 而本项目不需要代码转换:客户端半边全部用 `React.createElement`(无 JSX), 宿主半边是普通 ESM(无 TS 类型)。 `src/` → `lib/` 只做三件事:拷宿主半边、拷样式内核、把内核**内联**进客户端 bundle 再套外壳加缩进 —— 仍是**零依赖 Node**,不需要 tsdown / typescript / esbuild(**本机也一个都没装**)。 > **样式内核为什么要内联。** `src/style-kernel.js` 是宿主与客户端共用的格式判据(解析 style 串、 > 默认省略、v1 读时升级…)。宿主是 ESM,直接 `import` 它;客户端的 bundle 是一个单文件 factory, > 只有一份冻结的 `require` 表,不能 import 兄弟文件。于是构建器把它去掉 `export`、包成 > **IIFE 命名空间**(`const styleKernel = (function(){…})()`)再内联 —— 包一层是因为两边有同名符号 > (都有 `SIDES`),平铺进同一作用域会直接 SyntaxError。源码只有一份,`check-package` 会断言 > 客户端里那份确实是内联进去的。 `lib/client.js` 的包装格式逐字对照官方产物 (`dsh-client-ui-sidebar-right/lib/client.js`)确认: ```js window.__ModuleLoader__.load({ id: 'dsh-drawai', factory: (require) => module.exports }) ``` > ⚠️ **绝不改 `lib/` 下的文件** —— 它们是构建产物,会被覆盖。改 `src/`。 > 构建器刻意**不加时间戳**:`dsh-client-hmr` 按内容变化判定 rebuilt, > 时间戳会让每次构建都被当成变更,页面就会无谓重载。 将来若要用 TSX / TS 类型:补上 typescript + tsdown 即可,`src/` 结构不用动。 --- ## 开发回路 ```sh npm run watch # 常驻:监听 src/,变化即重建 lib/ npm run build # 一次性构建 npm run check # 安装前烟测(含 lib 与 src 是否同步) ``` | 改什么 | 怎么生效 | 要多久 | |---|---|---| | `src/client.js` | watch 重建 `lib/client.js` → `dsh-client-hmr` 轮询到内容变化 → SSE → 浏览器**自动重挂载插件** | 约 1 秒,**不用刷页面** | | `src/index.js` | `npm run build` 后**重启 `dsh web`**(宿主半边只在启动时加载) | 一次重启 | | `src/style-kernel.js` | 两半都受影响:客户端那份走上面的热重载;宿主那份要**重启 `dsh web`** | 两者都要 | | `package.json` / `cordis.patch.yml` | 这两个文件被 watch,多数情况实时重组 | 立即 | `dsh-client-hmr` 的要求只是"有进程在写 `lib/client.js`"——不限定是 tsdown。 `tools/watch.mjs` 直接 `import` 构建函数而**不 spawn 子进程**: 本机沙箱下 Node 的 piped stdio 会被拒,`spawn('node', …)` 会 EPERM。 **提交信息用中文。** 沿用本仓库既有历史的写法:一行标题带前缀(`feat:` / `fix:` / `chore:` / `docs:`), 必要时分点写正文 —— 说清"改了什么、为什么这么改",而不是"update"。 --- ## 安装 ### 路线 A:本地 link(开发期推荐) 在 `$DSH_HOME/profiles//package.json` 的 `dependencies` 里加: ```json "dsh-drawai": "link:D:\\_Project\\drawAi" ``` 然后: ```sh cd $DSH_HOME/profiles/ pnpm install ``` ### 路线 B:bundle 通道 `package.json` 已声明 `dsh.bundle.patch`,用官方 CLI 安装即可自动挂载: ```sh # 从 npm 装(推荐:预构建,不需要 allowBuilds 授权) dsh plugin --profile web add dsh-drawai # 或者直接从 GitHub 装(仓库里没有 prepare 脚本,同样不需要构建) dsh plugin --profile web add github:fourzkw/dsh-drawai ``` ### 手动挂载(两条路线的兜底) 把这一行加进 `$DSH_HOME/profiles//cordis.patch.yml`: ```yaml - insert: - id: drawai name: 'dsh-drawai' ``` > ⚠️ patch 文件不能为空或只有注释,否则 loader 启动失败。要留空请写 `[]`。 > ⚠️ 该文件被 watch,`patchReload: live` 时改对即重组 —— **但新增一个 bundle 通常仍需重启 `dsh web`**。 ### 重启 **宿主半边必须重启 `dsh web` 才会加载。** 客户端半边是独立 bundle,页面刷新即可。 --- ## 烟测 不需要 DSH 在跑,也不需要安装。先让它能解析 `@deepseek-ai/dsh-tools`: ```powershell New-Item -ItemType Directory -Force -Path node_modules\@deepseek-ai | Out-Null New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-tools ` -Target "$env:APPDATA\npm\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\dsh-tools" ``` 然后: ```sh node tools/check-package.mjs ``` 它会验证宿主半边能否 import、`apply` 能否注册工具、以及 DSH 那套严格的 schema DSL 是否接受本包声明。 > 这个 junction 只是本机自测用的**机器相关**路径,不要提交,也不要在安装后保留: > 装进 profile 后应当让包走部署自己的模块解析(同 `dsh-better-sidebar` 的做法)。 --- ## 目录 ``` package.json # dsh.bundle.patch + dsh.client 声明 + scripts cordis.patch.yml # bundle patch:insert 一行 drawai src/style-kernel.js # ← 源:格式判据(style 键解析/默认省略/v1 读时升级),两半共用 src/mxfile.js # ← 源:mxfile(.drawio)编解码,**只给宿主**(要 node:zlib) src/index.js # ← 源:宿主半边(工具 + 布局 + 读写路由) src/client.js # ← 源:浏览器半边(画布 + tab 类型) lib/style-kernel.js # 产物(勿改) lib/mxfile.js # 产物(勿改) lib/index.js # 产物(勿改) lib/client.js # 产物(勿改) tools/build.mjs # 零依赖构建器(含内核内联) tools/watch.mjs # 构建监视器(HMR 的那一环) tools/check-package.mjs # 安装前烟测(含 lib 与 src 是否同步) tools/check-mxfile.mjs # mxfile 编解码 + 无损写回自测(真实夹具 + 现压的压缩形态 + 逐字节不变) tools/snap-geometry.mjs # 一次性几何迁移(对齐整格/半格;默认 --dry) tools/check-route-preview.mjs # 连线预览的路由自测(纯几何,无需浏览器) tools/check-host.mjs # 宿主半边行为自测(内存文件系统,无需 DSH) tools/check-render.mjs # 渲染 + 对齐/连线画法自测(React 桩驱动 renderDiagram) tools/check-component.mjs # 客户端组件自测(tab 注册、工作栏/菜单文案与入口) demo.drawio # 示例图(画布默认读它;仓库里唯一被跟踪的 .drawio,其余被 .gitignore 挡住) docs/design.md # 本文档 docs/release.md # 发布与收录流程 ``` --- ## 装到别的电脑上:AI 怎么知道该怎么操作 插件装到别的机器上时,**跟着走的只有 `lib/` 与 `cordis.patch.yml`**(`package.json` 的 `files`), README、`src/`、`tools/` 都不会过去。于是模型能依仗的是三条通道: | 通道 | 走什么 | 装到任何机器上都有吗 | |---|---|---| | 工具的 name / description / 参数与输出 schema | 就在 `lib/index.js` 里,DSH 会把它们交给模型 | ✅ 有 | | 工具返回值与报错(`diagram_read` 的输出、`diagram_apply` 的 notes、非法 ops 的报错) | 运行时产生 | ✅ 有 | | **内嵌技能(skill)** | 插件在 `apply()` 里 `ctx.skills.register(...)` 注册一份**内存里的**使用说明 | ✅ 有(见下) | 第三条是关键:`README` 里那些"什么时候别用 layout""自环怎么写""为什么不能手改文件"的经验, 原本只活在这份仓库里。现在它们被写成一份**技能正文**(`src/index.js` 的 `SKILL_BODY`,约 3.7k 字), 注册进 DSH 的 skill 注册表: - 会话的技能目录里出现一条简介(名字 `drawai-canvas`,带 `whenToUse`),模型按需取全文; - 正文随 `lib/index.js` 一起装到任何机器,**不依赖工作区里有没有这个仓库**; - `skills` 是**可选**依赖(走 `ctx.get('skills')`,不写进 `inject`):没装 skill 注册表的部署里 插件照常工作,只是少了这份说明; - 注册必须带 `source: 'runtime'` —— 注册时只校验 name/description/invocation, 但**取全文**时注册表会再跑一次 `validateDefinition`,那里要求 `source` 是字符串 (少了它:目录里看得见、一 load 就抛 `source must be a string`)。`tools/check-package.mjs` 盯着这一条。 想让**某台机器**或**某个项目**补充自己的规则(比如"我们团队的图统一用 blue + daguerre"), 按 DSH 的文件技能放就行,不需要改插件:项目里放 `<项目>/.dsh/skills/<名字>/SKILL.md`、 用户级放 `/skills/<名字>/SKILL.md`(frontmatter 要有 `name` 与 `description`)。 同名的项目技能优先级高于插件注册的运行时技能。 ### AI 怎么知道用户当前打开的是哪一张画布 三条通道,都是"跟着插件走"的: | 通道 | 机制 | |---|---| | **工具默认值** | 客户端在画布面板里切换标签页时 `POST {action:'focus'}`,宿主按会话记下路径;`diagram_read` / `diagram_apply` 不传 `path` 时就用它(没打开任何画布才退回 `demo.drawio`)。两个工具的 `path` 说明里都写明了这一点 | | **返回值** | 不传 path 调一次,返回里的 `path` / `absolute` 就是当前那张 —— 想确认是哪张,这一步就够 | | **主动注入** | 聚焦**变化**时,宿主用 `ctx.agents.get(sessionId).inject(...)` 往那个会话注入一条环境事实(`{role:'user', source:{kind:'plugin', plugin:'drawai'}}`):`DrawAI 画布:用户现在打开的是 <路径>。diagram_read / diagram_apply 不传 path 时默认就操作这一张。` 于是模型下一步直接看得到,不用先探 | 细节:注入只在**路径真的变了**时才发(客户端每次挂载都会重报同一张,去重;A→B→A 是三次真实切换); 画布关掉时也发一条(否则模型还以为是刚才那张);`agents` 是**可选**服务(走 `ctx.get`), 没装 agent 服务的部署只是少这条主动提示,工具那条回退链照旧。 `tools/check-host.mjs` 用第二个实例(带假 `agents`)盯这四条。 **"用户选中了哪几个"走的是另一条更轻的通道**:客户端在选区变化时 `POST {action:'selection'}` (**防抖 300ms** —— 框选时指针每动一下都会改选区,不防抖就是按帧打宿主),宿主按会话存 `{绝对路径, ids}`,`diagram_read` 读那张图时带回去(`selection`)。三条规矩:**认文件** (路径对不上不回 —— 两个文件里都叫 `n1` 太常见)、**认存在**(当前文档里没有的 id 过滤掉)、 **删除即划掉**(id 会被复用,删掉 `n3` 再新建一个往往又叫 `n3`,不划掉就会"继承"旧选区)。 选区**不进文件**:它是"用户在看什么"的提示,不是文档状态。