设计器内核
@xihan-ui/designer-kit 是画布式设计器的公共内核:撤销重做历史栈、按 JSON 路径的编辑操作、文档内唯一 id、网格布局、视口平移缩放、对齐吸附与参考线、多选对齐与等距分布、框选与命中、变换控制柄、标尺刻度、长度单位与纸张预设,以及属性面板的自定义编辑器接口。打印、流程、表单、报表这类设计器都要做这几件事,内核把它们写成与文档形状无关的纯函数,各设计器只需提供自己的文档模型与编辑操作。
import { createHistory, fitRect, snapRect, zoomBy } from "@xihan-ui/designer-kit";
const history = createHistory({ document, apply: applyOperation });
history.execute([{ type: "moveNodes", positions }], { label: "移动" });
history.undo();
let viewport = fitRect(contentBounds, { width: 800, height: 600 });
viewport = zoomBy(viewport, 1.2, { x: 400, y: 300 });
const { dx, dy, guides } = snapRect(draggingRect, { targets, threshold: 6 / viewport.zoom });设计约束
- 不碰 DOM。 全部是纯函数与普通对象,指针事件、绘制与样式由设计器自己处理;同一页面放多个设计器互不影响。
- 文档形状由使用方决定。 历史栈只依赖「应用一个操作,返回新文档与逆操作」这一个函数,不认识节点、连线或字段。
- 不改输入。 视口、吸附、对齐与分布都返回新值;历史栈要求
apply不修改传入的文档。 - 非法输入立即报错。 缩放上限小于下限、吸附阈值为负、未知的对齐方式等一律抛
RangeError,不静默修正。
是否安装
它不随适配器安装,需要时单独安装:
pnpm add @xihan-ui/designer-kit运行时只依赖 @xihan-ui/core 的 ./json 与 ./hotkey 子入口(./hotkey 是快捷键的平台写法与按键命中)和 @xihan-ui/pointer 的滚轮换算,子入口 ./properties 另用 @xihan-ui/expression 的 checkFormatSpec 检查格式说明、@xihan-ui/core 的 ./options 检查选项列表;import 无副作用,浏览器与服务端都能运行。属性描述与结构校验分别在子入口 ./properties 与 ./schema,只做画布的页面(如流程查看器)不为它们付字节。
撤销重做
操作约定
type ApplyOperation<D, O> = (document: D, operation: O) => {
document: D; // 新文档,不修改输入
inverse: readonly O[]; // 按顺序应用即可撤销
};一组操作的逆操作是各操作逆操作按反序拼接。逆操作必须能精确还原:撤销时应用逆操作,应用得到的逆操作就是重做所需,历史栈每一步只存待执行方向的那组操作。
执行、撤销与重做
| 成员 | 说明 |
|---|---|
execute(operations, { merge?, label?, mergeWithin? }) | 依次应用一组操作并记为一步,返回新文档;清空重做栈。空数组、或应用后文档仍是同一引用(操作没有带来变化)时什么都不做,不入栈也不通知 |
undo() / redo() | 撤销或重做一步,没有可执行的步时返回 false |
canUndo / canRedo、undoCount / redoCount | 工具栏按钮的可用状态与步数 |
undoLabel / redoLabel | 下一次撤销、重做那一步的说明,读的时候现取成字符串 |
entries | 全部步骤 { position, label, state, saved, time },按先后:已做的在前(state 为 done),已撤销的在后(undone);每次读都给新数组 |
position / origin | 当前位置:最后一个已做步骤的位置号,一步都没做时等于 origin;起点:entries 之前那个状态的位置号 |
truncated / originSaved | 起点之前有被步数上限丢掉的步骤;起点是保存点 |
goTo(position) | 跳到起点或某一步,见下文「步骤列表与跳转」 |
document | 当前文档 |
markSaved() / dirty | 把当前位置记为保存点;dirty 为当前位置与保存点不同,或事务中已有改动 |
reset(document) | 换成新文档并清空两个栈,新文档视为已保存(装载模板时用) |
clear() | 只清空两个栈,是否已保存不变 |
subscribe(listener) | 每次变化后通知 { kind, document, operations, steps },返回取消订阅的函数;steps 是撤销或重做一次走了几步:undo()、redo() 为 1,goTo 为实际步数,别的种类为 0 |
应用失败时 apply 抛出的错误原样抛出,文档与两个栈都不变。limit 是撤销栈的步数上限,缺省 100,超出时丢弃最早的一步。
保存点按位置比对:撤销、重做回到保存点时 dirty 变回 false;保存点被新执行清出重做栈、或早于步数上限能退回的位置时,再也回不到保存点。markSaved() 同时封口,保存之后的输入另起一步。
合并连续输入
在属性面板里逐字输入时,每次按键都执行一次,但撤销应当一次回到输入前。给这些执行带同一个合并键:
history.execute([setLabel(nodeId, value)], { merge: `node:${nodeId}:label`, label: "改名" });上一步带同一合并键且没有封口时,新执行并入上一步,说明保留上一步的。seal()、撤销、重做与事务都会封口;字段失焦时调用 seal(),下次再输入就另起一步。mergeWithin(毫秒)可再加一道时间窗,配合 now 选项注入时钟;单次执行也可以带自己的 mergeWithin 盖过它,比如属性面板的输入传 Infinity,不论停顿多久都并成一步,只等失焦封口,而方向键微移仍按历史的时间窗合并。
方向键微移各设计器用同一个口径:合并键用 nudgeMergeKey(ids)(按选中对象的 id 定,与先后无关),时间窗用 HISTORY_NUDGE_MERGE_WITHIN(1000 毫秒),选中变化时 seal()。这样连续按、选中不变、一秒之内的微移并成一步:
history.execute(moveBy(selection, dx, dy), { merge: nudgeMergeKey(selection), mergeWithin: HISTORY_NUDGE_MERGE_WITHIN });事务
拖动、批量对齐这类由多次执行组成的交互用事务记为一步:
history.begin("拖动");
// 拖动中每帧 execute,文档实时更新
history.commit(); // 整段拖动记为一步
// 或 history.rollback():按 Esc 放弃,文档回到拖动前事务可嵌套,最外层 commit 时才入栈,说明以最外层给的为准;rollback 放弃整个事务(含外层),重做栈保持事务前的样子。transact(run, label) 在 run 正常返回时提交、抛错时回滚后重新抛出。事务中调用 undo、redo、goTo、reset、clear 会抛错,事务中的执行忽略合并键与自己的说明。
步骤说明
每一步的说明是 HistoryLabel:字符串,或读的时候现取的函数。写成函数时,步骤列表与撤销、重做的提示换语言后跟着变;函数里用到的对象名在执行那一刻取好闭进去,说明写的是当时改了什么:
const title = card.title;
history.execute(ops, { label: () => t().history.removeCard(title) });说明的先后是:execute 的 label,事务的 label,都没写时调用 createHistory 的 describe(operations, before)。describe 在新起一步时调用,收到这一步的正向操作与应用前的文档;并入上一步的执行不再调用;事务提交时收到整个事务的正向操作与事务开始前的文档。返回 undefined 时这一步没有说明。
步骤列表与跳转
entries 按先后列出全部步骤,position 是当前位置,origin 是起点。每一步有一个位置号,撤销、重做后不变;超过步数上限丢掉最早的步时,别的步的位置号也不变,可以拿来给列表的条目定键。
goTo(position) 跳到起点或某一步:目标在当前之前就连续撤销,之后就连续重做。
- 各步依次应用、全部成功才生效;有一步抛错时文档与两个栈都不变,错误照样抛出。
- 成功后只通知一次:
kind是undo或redo,operations是各步实际应用的操作按先后拼起来,steps是走过的步数。 - 已在那里或位置号不认识时返回
false。 - 跳转与撤销、重做一样会封口;之后再执行新的修改,已撤销的步骤整段丢掉。
snapshotHistory(history) 把历史取成快照 { entries, position, origin, truncated, originSaved },各步的说明按此刻的语言取成字符串,给历史面板与宿主用。
依次应用一组操作
applyInSequence(apply, document, operations) 依次应用一组操作,返回最终文档与按反序拼接的逆操作,任一操作抛错时整体抛错、输入文档不变。历史栈的 execute 内部就是用它,设计器自己的 applyOperations 也可以直接写成它。
路径编辑操作
文档就是一份 JSON 时,可以不另写操作类型,直接按路径编辑。路径与读写语义见 @xihan-ui/core/json:段只有对象键(只认自有属性)与数组下标两种,原型键、负数与小数一律不合法。
| 操作 | 作用 | 逆操作 |
|---|---|---|
{ type: 'set', path, value?, createMissing? } | 写入 value;value 缺省时删除对象键。缺中间层时报错,createMissing: true 才补成空对象。空路径替换整个文档 | 原来有值时 set 回原值,原来没有时 remove;补出中间层时删掉补出的最上面一层 |
{ type: 'insert', path, index?, value } | 把 value 插入 path 指向的数组,index 缺省时追加 | remove 插入的那一项 |
{ type: 'remove', path } | 删除对象键或数组项 | 对象键 set 回去,数组项 insert 回原位置 |
{ type: 'move', path, index } | 把数组项移到同一数组的 index 处 | move 回原位置 |
import { applyPathOperation, createHistory } from "@xihan-ui/designer-kit";
const history = createHistory({ document: template, apply: applyPathOperation });
history.execute([{ type: "set", path: ["rows", 0, "title"], value: "合计" }], { merge: "rows.0.title" });
history.execute([{ type: "move", path: ["rows", 2], index: 0 }], { label: "上移" });applyPathOperation(document, operation) 不修改输入,未改动的部分沿用原引用,写入的值不做拷贝;没有带来变化时返回同一个文档、逆操作为空数组,历史栈因此不会记下空步。applyPathOperations 依次应用一组。失败时抛 PathOperationError,message 就是 code,operation 是失败的那个操作:
code | 含义 |
|---|---|
invalid-operation | 不是已知的操作或缺少 path |
invalid-path | 路径段不合法,或对空路径做 remove / move |
path-not-found | 路径不存在,或段的种类与所在容器不符 |
invalid-value | 值不是 JSON,或想用 set 删除数组项 |
not-an-array | insert / move 指向的不是数组 |
index-out-of-range | insert / move 的目标下标越界 |
按 id 或名字定位数组项属于各设计器自己的语义,先把它解析成下标再交给路径操作。
文档内唯一 id
| 函数 | 说明 |
|---|---|
nextFreeId(prefix, taken) | 形如 ${prefix}${n},n 取 taken 里同前缀正整数序号的最大值加一 |
createIdAllocator(prefix, taken?) | 分配器:next() 从已有最大序号之后连续分配、互不重复;reserve(id) 登记已被占用的 id |
remapIds(ids, allocate) | 为一组旧 id 各分配一个新 id,返回旧 → 新的对照表(粘贴、复制时改写引用用);重复的旧 id 只分配一次 |
分隔符算在前缀里:createIdAllocator("text-") 给出 text-1、text-2。这里生成的是文档里保存的 id;DOM 上关联 ARIA 用的 id 走适配器自己的生成器,两者不要混用。
剪贴板数据
复制出去的内容包成一个带格式标记、产品名与版本的 JSON 包,粘贴时先认包、再交给产品按版本校验载荷。同一产品的设计器之间(跨实例、跨标签页)可以互相粘贴,别的产品或普通文本粘进来时能明确拒绝。
import { clipboardMimeType, decodeClipboard, encodeClipboard } from "@xihan-ui/designer-kit";
designer.addEventListener("copy", (event) => {
const text = encodeClipboard("print", 1, { elements: selectedElements });
event.clipboardData?.setData(clipboardMimeType("print"), text);
event.clipboardData?.setData("text/plain", text);
event.preventDefault();
});
designer.addEventListener("paste", (event) => {
const text = event.clipboardData?.getData(clipboardMimeType("print")) || event.clipboardData?.getData("text/plain") || "";
const result = decodeClipboard(text, "print");
if (result.ok)
pasteElements(result.version, result.payload); // 载荷的结构由产品自己校验
});| 函数 | 说明 |
|---|---|
encodeClipboard(product, version, payload) | 序列化为 { format: "xihan-designer-clipboard", product, version, payload } 的 JSON 文本 |
decodeClipboard(text, product) | 成功得到 { ok: true, version, payload };失败得到 { ok: false, reason } |
clipboardMimeType(product) | application/x-xihan-<product>+json |
- 产品名是小写字母开头的小写字母、数字与连字符,版本是正整数,载荷是合法 JSON 值;不满足时
encodeClipboard抛RangeError。 - 失败原因:
invalid-json(不是 JSON)、not-clipboard(没有格式标记)、invalid-envelope(产品名、版本或载荷缺失或不合法)、other-product(别的产品复制的)。包上多出的字段忽略。 - 自定义 MIME 类型只在同一浏览器里有效,所以同时写一份
text/plain;粘贴时先读自定义类型,再退到纯文本。
可绑定字段树
宿主用字段树描述数据的形状,设计器的字段选择器据此列出可选字段。list 字段的子字段描述数组的一个元素,比如表格的列从行对象里取值。
import type { DataField } from "@xihan-ui/designer-kit";
import { listDataFields } from "@xihan-ui/designer-kit";
const fields: DataField[] = [
{ name: "order", label: "订单", kind: "object", children: [
{ name: "no", label: "单号", kind: "value" },
{ name: "items", label: "明细", kind: "list", children: [{ name: "qty", kind: "value" }] },
] },
];
listDataFields(fields, { kinds: ["value"] }); // order.no
listDataFields(fields, { kinds: ["list"] }); // order.items
listDataFields(fields, { base: ["order", "items"] }); // qty,相对数组的一个元素| 函数 | 说明 |
|---|---|
listDataFields(fields, { base?, kinds? }) | 深度优先列出 { path, text, field }:进入 object 的子字段,不进入 list 的子字段;base 是起点(只含键的路径),是 list 时列它的元素字段;kinds 只列这些种类 |
findDataField(fields, path) | 按只含键的路径找字段,经过 list 时进入元素字段,找不到为 undefined |
listFilterFields(fields) | 条件编辑器用的字段清单(@xihan-ui/viz 的 FilterField):根上与 object 里的 value 字段,name 是路径的 formatJsonPath 写法,不进入 list(条件筛的是行,不是行里数组的元素);type 取字段的 type,没写时按文本("string") |
text 是 formatJsonPath 写法,不是标识符的键写成 ["unit price"]。同级字段重名、名字为空、种类或内容未知时抛 RangeError。
value 字段还有两项可写,各管一件事:
| 项 | 取值 | 管什么 |
|---|---|---|
type | "string" | "number" | "boolean" | "date" | "datetime",即 @xihan-ui/viz 的 FilterValueType | 数据类型,管筛选与格式化:条件编辑器据此给运算符与值的控件,设计器可以据此挑值格式;不写时按文本 |
content | "text" | "image" | "barcode" | "qrcode"(DATA_FIELD_CONTENTS) | 这个值代表什么,管生成哪种元素、用哪种控件:文字、图片地址、一维条码的内容、二维码的内容。设计器把字段放上画布时据此生成元素;不写时按文本 |
两项互不相干:商品图片地址是 { type: "string", content: "image" },条码号可以是 { type: "number", content: "barcode" }。content 写了不认识的值时抛 RangeError。
推断字段树
子入口 @xihan-ui/designer-kit/field-inference 从一份 JSON 样例或一份 JSON Schema 推断字段树,再把导入的字段并进现有字段。都是纯函数,不联网;设计器的导入字段对话框用的就是它们。
import { inferDataFields } from "@xihan-ui/designer-kit/field-inference";
const { fields, notes } = inferDataFields({
no: "SO-001",
date: "2026-10-19",
items: [{ sku: "A", qty: 2 }, { sku: "B", qty: 1, note: "加急" }],
});
// no: value / string,date: value / date,items: list,子字段 sku、qty、note| 函数 | 说明 |
|---|---|
inferDataFields(sample, options?) | 从 JSON 样例推断:对象 → object;对象数组 → list,子字段取各项键的并集,键按首次出现的顺序;标量 → value 并带 type;标量数组 → 没有子字段的 list,type 是元素类型。根是数组时包成一个名为 rootName(缺省 "items")的列表字段。不推断显示名 |
dataFieldsFromJsonSchema(schema, options?) | 从 JSON Schema(draft 2020-12 与 draft-07 的常用子集)推断:properties 按书写顺序成为子字段,array 的 items 成为列表;title 是显示名。根是数组时同样包成列表字段 |
mergeDataFields(current, incoming) | 同级按 name 对齐,现有的显示名、类型与内容优先,缺的取导入的;导入里新出现的字段追加在同级末尾;种类不同的保留现有的,记进 conflicts |
推断的选项:
| 选项 | 缺省 | 说明 |
|---|---|---|
rootName | "items" | 根是数组时,包住它的那个列表字段的名字 |
sampleLimit | 100 | 样例里每个数组最多看几项 |
maxDepth | 8 | 字段树最多几层,超出的对象不再展开 |
detectDates | true | 样例里全是 ISO 日期的记 "date";全是日期或日期时间、且至少一个带时刻的记 "datetime"(末尾的 Z 或 ±hh:mm 也认) |
detectImages | false | 样例里全是 data:image/… 或以图片扩展名结尾的 http(s) 地址时,标 content: "image" |
类型怎么来:
| 样例 | JSON Schema | 字段 |
|---|---|---|
| 字符串 | type: "string";format: "date" / "date-time" 是日期 / 日期时间 | "string" / "date" / "datetime" |
| 数字 | "integer"、"number" | "number" |
| 布尔 | "boolean" | "boolean" |
只见到 null | type: "null" | "string",标可空 |
| — | contentMediaType: "image/…" | content: "image" |
| — | 没写 type:按 properties、items、enum / const 推 | 同上 |
JSON Schema 的其余规则:
$ref只认本文档内的#与#/…(含$defs、definitions),外部引用按文本并记一条说明;循环引用在第二次出现时截断。allOf合并各支,后者覆盖同名属性。oneOf/anyOf:只差一支null的取另一支并标可空;全是对象的取属性并集;其余取第一支。type写成数组时去掉null并标可空,还剩几种就按文本。- 只有
additionalProperties/patternProperties的对象不展开;按位置给出元素的数组(prefixItems,或 draft-07 的items数组)取第一项。
结果是 { fields, notes, annotations }:
notes是值得告诉用户的情况,按字段树的先后排列,每条{ code, path, params? },文案由界面按code生成。path是字段名组成的路径,不含列表下标。code什么时候 invalid-root根是标量,或 Schema 的根不是对象或数组 invalid-name空键名,已跳过 mixed-type同一路径有几种标量( params.types),按文本all-null只见到 null,按文本mixed-shape同一路径既有对象又有标量或数组,取对象 empty-list数组里没有可推断的项 scalar-list标量数组 nested-list数组的数组,只取外层 depth-limit超出层数,这个对象没有展开 sample-limit数组项超出上限( params.limit、params.count)unresolved-ref外部或找不到的 $refcircular-ref循环引用 uniononeOf/anyOf合并了几支(params.count)dictionary字典形状的对象,没有展开 tuple按位置给出元素的数组,取了第一项 annotations是DataField放不下的信息,键是字段路径的formatJsonPath写法,值可含description、required、enum(含const)、default、format(没有映射成字段类型的格式,如email)与nullable。报表参数、流程启动变量这类要必填与候选值的地方从这里取。
视口
视口是 { x, y, zoom }:屏幕坐标 = 世界坐标 × zoom + (x, y)。
| 函数 | 说明 |
|---|---|
screenToWorld / worldToScreen | 点的互转;screenRectToWorld / worldRectToScreen 是矩形的互转 |
visibleWorldRect(viewport, size) | 视口里看得到的世界矩形 |
panBy(viewport, dx, dy) | 平移若干屏幕像素 |
zoomTo(viewport, zoom, anchor, limits?) / zoomBy(viewport, factor, anchor, limits?) | 以屏幕上的锚点为中心缩放,锚点对着的世界点不动;缩放夹在 limits 内,缺省 DEFAULT_ZOOM_LIMITS(0.1–4) |
wheelZoomFactor(deltaY, deltaMode?, speed?) | 滚轮滚动量换成缩放倍数,按行、按页滚动先折成像素 |
fitRect(bounds, size, { padding?, maxZoom?, limits? }) | 让内容整个落在视口中央;maxZoom 缺省 1,小内容不放大 |
centerOn(viewport, point, size) | 缩放不变,把世界点移到视口中央 |
revealRect(viewport, rect, size, padding?) | 目标已整个露出时原样返回,否则把它居中 |
constrainViewport(viewport, content, size, keep?) | 内容每个方向至少留 keep 像素在视口内,不让它被整个拖出视野 |
对齐吸附与参考线
snapRect(rect, { targets, threshold, x?, y?, grid?, lines? }) 把移动中矩形的左 / 中 / 右(上 / 中 / 下)三条线与每个目标的同类线比对,在阈值内取最近的一条,返回应再平移的 dx / dy 与吸附后重合的参考线:
interface SnapGuide {
axis: "x" | "y"; // x:x = position 的竖线;y:横线
position: number;
from: number; // 线的起点
to: number; // 线的终点,覆盖所有在这条线上对齐的矩形
}阈值与矩形同一坐标系,屏幕上的固定像素先除以 zoom。lines: { x?, y? } 给出贯穿画布的吸附线(如从标尺拖出的参考线):x 是竖线的横坐标,y 是横线的纵坐标,与目标矩形的线一同比对;吸上时返回的参考线只覆盖移动的矩形。某一向没吸到目标、又给了 grid 时,矩形左上角落到最近的网格线上。snapToGrid / snapPointToGrid 单独把数值或点落到网格上。
对齐与分布
alignRects(rects, mode) 按选区包围盒对齐:left / right / top / bottom 贴边,center-vertical 把中心落到包围盒的竖直中线上,center-horizontal 落到水平中线上。distributeRects(rects, axis, spacing?) 沿 horizontal 或 vertical 方向分布,按中心排序后首尾不动:gap(缺省)让相邻空隙相等,center 让相邻中心距相等。两者按输入顺序返回各自新的左上角,交给设计器生成移动操作。
框选
marqueeRect(start, current) 由拖拽起止点得出选框;selectInMarquee(marquee, rects, mode?) 返回落在框里的矩形下标,contain(缺省)要求整个包住,intersect 碰到即选;marqueeModeFromDrag(start, current) 按拖拽方向取方式:自左向右为 contain,自右向左为 intersect。
rectFromPoints、unionRects、rectContains、rectsIntersect、expandRect、rectCenter 是这些函数共用的矩形运算,同样公开。
旋转与命中
Rect 可带可选的 rotation:x / y / width / height 是旋转前的框,rotation 为绕中心顺时针旋转的角度(度),缺省 0。
带旋转角的矩形一律按旋转后的包围盒计算:unionRects、rectContains、rectsIntersect、alignRects、distributeRects、snapRect(含网格退路),以及视口的 fitRect、revealRect、constrainViewport 都如此;对齐与分布移动的是包围盒,返回的左上角是旋转前的框跟着平移后的位置;screenRectToWorld、worldRectToScreen、expandRect 保留旋转角。需要按旋转后的实际形状判断时用下表的函数,框选的 intersect 也按实际形状判断。
| 函数 | 说明 |
|---|---|
rotatePoint(point, center, degrees) | 把点绕中心顺时针旋转 |
rectCorners(rect) / rotatedBounds(rect) | 旋转后的四角与轴对齐包围盒 |
pointInRect(point, rect, tolerance?) | 点是否落在旋转后的矩形内 |
hitTest(point, rects, tolerance?) | 命中的最上层下标(数组越靠后越在上),都不命中为 -1 |
rotatedRectIntersects(a, b) | 两个矩形按旋转后的实际形状是否相交,按分离轴判断 |
变换控制柄
| 函数 | 说明 |
|---|---|
handlePoints(rect, rotateOffset?) | 八个缩放柄与旋转柄的位置;旋转柄在上边中点再往外 rotateOffset |
resizeWithHandle(start, handle, pointer, { keepAspect?, fromCenter?, minWidth?, minHeight? }) | 拖动缩放柄到指针处后的矩形:在矩形自身的坐标系里缩放,旋转角不变,对边或对角(fromCenter 时为中心)保持不动,拖过对边时停在最小尺寸上 |
rotateWithHandle(start, pointer, { step? }) | 拖动旋转柄后的角度(0–360,旋转柄朝上为 0);step 按步长吸附,按住 Shift 时通常传 15 |
nudgeDelta(key, { large?, step?, largeStep? }) | 方向键对应的位移,缺省步长 1、大步长 10;不是方向键时为 undefined |
控制柄只给几何,指针与键盘事件、等比与以中心缩放对应哪个修饰键由设计器决定;得到的矩形再由设计器换成自己的缩放或移动操作。
网格布局
看板、工作台这类按整数格排布的卡片用这一组函数。GridItem 是 { id, x, y, w, h },单位是格;列数由调用方给出,行数不限:
import { placeGridItem, setGridItemRect } from "@xihan-ui/designer-kit";
let items = [{ id: "sales", x: 0, y: 0, w: 6, h: 2 }];
items = placeGridItem(items, { id: "trend", w: 6, h: 2 }, 12); // 放到第一个空位:x 6、y 0
items = setGridItemRect(items, "trend", { x: 0, y: 0 }, 12); // 拖到 sales 上:sales 被推到下面| 函数 | 说明 |
|---|---|
clampToGrid(item, columns) | 取整并夹到网格内:宽在 1 到列数之间,高至少 1,不越过右边界,x、y 不小于 0 |
gridCollides(a, b) | 两项是否重叠,边相接不算 |
compactLayout(items, columns) | 自上而下、自左而右把每项向上移到碰到别的项为止,原本重叠的项向下推开 |
setGridItemRect(items, id, target, columns, { compact? }) | 拖动或改大小:压到的项向下推开并连锁推开;向下拖过下方项的顶边时两者对调 |
placeGridItem(items, { id, w, h, x?, y? }, columns, { compact? }) | 放入新项:写了 x、y 放在那里并推开碰撞的项,没写时放到第一个放得下的空位;新项排在末尾 |
removeGridItem(items, id, columns, { compact? }) | 移除一项 |
返回新数组,次序与输入相同,不改输入。调整之后缺省整体向上压实,同位置时被拖动或放入的项优先;{ compact: false } 不压实,项可以悬空。位置大小不是有限数、id 不存在或重复时抛 RangeError。
合并单元格的表格
表单的表格布局、打印的表格元素、报表的网格都有合并单元格,几何运算由这一组函数给出。TableCell 是 { id, row, column, rowSpan, columnSpan },行列从 0 起,跨度至少 1。函数只给计算结果,编辑操作由各设计器按自己的文档形状生成:
import { checkTableCells, planCellMerge, planTableLineInsert } from "@xihan-ui/designer-kit";
const size = { rows: 2, columns: 2 };
const cells = [{ id: "a", row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, { id: "b", row: 0, column: 1, rowSpan: 1, columnSpan: 1 }, { id: "c", row: 1, column: 0, rowSpan: 1, columnSpan: 2 }];
checkTableCells(size, cells); // []
planCellMerge(cells, ["b", "a"]); // { ok: true, anchor: "a", rowSpan: 1, columnSpan: 2, absorbed: ["b"] }
planTableLineInsert(size, cells, "column", 1); // c 加长到 3 列,b 后移到第 2 列,第 0 行第 1 列补一个新格| 函数 | 说明 |
|---|---|
checkTableCells(size, cells) | 全部问题:invalid-size、invalid-cell(位置不是非负整数或跨度小于 1)、out-of-bounds、overlap(报在后出现的单元格上)、gap(按行优先给出没被覆盖的位置) |
planCellMerge(cells, ids) | 所选单元格须至少两个、恰好拼成一个矩形且矩形里没有未选的单元格;成功时给出左上角 anchor、新跨度与按输入顺序并入的 absorbed,失败时给出 unknown-cell、too-few-cells 或 not-rectangle |
planCellSplit(cell) | 跨度还原成 1 后要补新格的位置,行优先;本来就是 1 × 1 时为空数组 |
planTableLineInsert(size, cells, axis, at) | 在第 at 行(列)前插入:跨过的单元格加长,其后的后移,没被覆盖的位置补新格;at 等于行(列)数时追加在末尾 |
planTableLineRemove(size, cells, axis, at) | 删除第 at 行(列):只占这一行(列)的单元格删除,跨过的缩短,其后的前移 |
增删行列返回 { size, updates, added, removed }:新尺寸、位置或跨度变了的单元格(给出新几何)、要新建的 1 × 1 单元格位置与要删除的单元格 id。不改输入;at 越界或表格只剩一行(列)还要删时抛 RangeError。
标尺
rulerTicks({ start, end, pixelsPerUnit, minMajorSpacing?, minMinorSpacing?, steps? }) 返回 { step, minorStep, ticks },每个刻度是 { value, offset, major }。单位由调用方决定:打印设计器传毫米与「每毫米的屏幕像素 × 缩放倍数」,画布设计器传世界坐标。主刻度间隔从 steps(缺省 1-2-5)的十进制倍数里取不小于 minMajorSpacing(缺省 64 像素)的最小一档,主刻度之间按 10、5、2 格依次尝试,取小格间距不小于 minMinorSpacing(缺省 6 像素)的最多分格。
长度单位与纸张
convertLength(value, from, to, { dpi? }) 在 LengthUnit 之间换算:mm、cm、in、pt(1/72 英寸)与 px。像素按 dpi 折算,缺省 96,即 CSS 像素;按打印机分辨率出图时传 203、300 等。毫米、厘米、英寸与磅之间的换算没有浮点零头,如 25.4 mm 恰好是 72 pt。dpi 不是正的有限数或单位不认识时抛 RangeError。
import { convertLength, PAPER_PRESETS } from "@xihan-ui/designer-kit";
const a4 = PAPER_PRESETS.A4; // { width: 210, height: 297 },纵向,单位 mm
const widthPx = convertLength(a4.width, "mm", "px"); // 793.7…
const fontMm = convertLength(12, "pt", "mm"); // 4.233…PAPER_PRESETS 收 ISO 216 的 A3–A6 与 B4、B5,以及北美的 Letter、Legal,都是纵向尺寸,横向时宽高互换。PAPER_PRESET_NAMES 与 LENGTH_UNITS 按声明顺序列出全部取值,供页面设置与属性面板列选项;isPaperPresetName 区分大小写。
尺寸徽标、标尺这类显示与微调长度的地方:
| 名称 | 说明 |
|---|---|
LENGTH_UNIT_SYMBOLS | 每种 LengthUnit 的符号 |
formatLength(value, { fractionDigits?, locale? }) | 把长度数值写成文字(不带单位):按 locale 写小数点,最多 fractionDigits 位小数(缺省 1),不分组,负零写成 0 |
stepLength(value, delta, { min?, max?, fractionDigits? }) | value + delta 按 fractionDigits 位小数(缺省 1)取整,再夹在 [min, max] 里,避开 0.1 + 0.2 这类尾差 |
数值不是有限数、fractionDigits 不是 0–20 的整数、min 大于 max 时抛 RangeError。
属性描述
属性面板与属性检查用同一份描述,从子入口 @xihan-ui/designer-kit/properties 引入(只做画布的页面不为它付字节):
import type { PropertyDescriptor } from "@xihan-ui/designer-kit/properties";
import { checkProperties, groupProperties } from "@xihan-ui/designer-kit/properties";
const descriptors: PropertyDescriptor[] = [
{ path: ["label"], control: { kind: "text" }, group: "basic", required: true },
{ path: ["data", "mode"], control: { kind: "select", options: [{ value: "Any" }, { value: "All" }] }, group: "rule", default: "Any" },
{ path: ["data", "count"], control: { kind: "number", integer: true, min: 1 }, group: "rule", visibleWhen: { path: ["data", "mode"], equals: "All" } },
];
const groups = groupProperties(descriptors, node, ["basic", "rule"]); // 属性面板按组渲染显示中的属性
const issues = checkProperties(descriptors, node, { path: ["nodes", 3], target: { kind: "node", id: node.id } });| 字段 | 说明 |
|---|---|
path | 值相对被编辑对象(节点、元素、字段本身)的 JsonPath,语义同 @xihan-ui/core/json |
control | 编辑方式与取值约束,见下表;产品可用泛型参数扩展自己的种类,如 PropertyDescriptor<PropertyControl | { kind: "stroke" }> |
group | 分组 id,分组的先后与文案由产品决定 |
label | 标签;不写时由产品按 formatJsonPath(path) 去语言包里查 |
required | 显示时必须填写,按 isFilled 判空(undefined、null、空串、空数组、空对象算未填) |
default | 新建时写入的缺省值,applyDefaults 只写还没有值的路径 |
visibleWhen | 显示条件:{ path, equals }、{ path, oneOf }、{ path, filled },可用 all / any / not 组合;path 同样相对被编辑对象 |
enabledWhen | 可用条件,写法同 visibleWhen:不满足时照样显示、但只读,如「依赖另一项开着才能改」;属性面板与设置对话框都照它办 |
meta | 产品自用,内核不解释 |
| 内建编辑方式 | 取值约束 |
|---|---|
{ kind: "text", multiline?, placeholder?, secret? } | 字符串;secret 为密钥这类不该明文显示的文字,编辑器用密码框,与 multiline 不并用 |
{ kind: "number", integer?, min?, max?, exclusiveMin?, step?, unit? } | 有限数字;min / max 含端点,exclusiveMin 不含;step 与 unit 只给编辑器用 |
{ kind: "boolean" } | 布尔 |
{ kind: "select", options, multiple?, appearance? } | 某个选项的 value;multiple 时是这些值组成的数组。appearance 只给编辑器用:auto(缺省)由编辑器定,dropdown 总是下拉,toggle 总是切换按钮组 |
{ kind: "tags" } | 字符串数组 |
{ kind: "color" } | transparent,或 # 开头的 3、4、6、8 位十六进制色 |
{ kind: "json" } | 任意 JSON 值 |
{ kind: "dataField", fields?, kinds?, base? } | 字段路径的 formatJsonPath 写法,空串为没有绑定,写法不对报 invalid-value。字段树:描述里写了 fields 就用它,没写时用属性面板给的(<xh-designer-properties> 的 dataFields);kinds 是能选的字段种类,base 是相对路径的起点(从字段树根开始、只含键),同 listDataFields 的两个选项。这三项只给编辑器用 |
{ kind: "sides", integer?, min?, max?, exclusiveMin?, step?, unit? } | { top, right, bottom, left } 四个数字,每边按 number 的约束检查;缺边或某边不是数字报 invalid-type,多出别的键报 unknown-key |
{ kind: "format", kinds? } | @xihan-ui/expression 的格式说明,按 checkFormatSpec 检查,没过报 invalid-value;kinds 是允许的格式种类,缺省全部 |
{ kind: "filter", fields?, parameters?, maxDepth?, ids? } | 条件树(@xihan-ui/viz 的 FilterGroup):组是 { logic: "and" | "or", conditions },条件是 { field, operator },不是对象报 invalid-type,这层形状不对报 invalid-value;字段在不在、运算符合不合由各款自己的校验报。可筛选的字段:描述里写了 fields(FilterField)就用它,没写时用属性面板给的 dataFields 按 listFilterFields 列出的值字段。parameters 是可引用的参数(FilterParameter),maxDepth 限子组层数,ids 为 true 时新建的条件与组带 id。这四项只给编辑器用 |
{ kind: "swatch", options, custom?, none? } | 某个选项的 value;选项是 PropertySwatchOption:value 是选中时写入的值,color 是格子画出的颜色(能解析的 CSS 颜色串),label 是读屏念的名字。custom 为 true 时另可填任意颜色,取值是 # 开头的 3、4、6、8 位十六进制色。给了 none(一段文字)时另有一项「不写值」,选它即删掉这个键、回到缺省。不是字符串报 invalid-type,两样都不是报 invalid-option |
{ kind: "options", requireLabel?, allowDisabled?, valueType? } | 选项列表,每项是 { value, label?, disabled?, tone?, description? },与选择类组件的选项同一形状,按 @xihan-ui/core/options 的 findOptionViolation 严格检查:不是数组或某项、某个键的类型不对报 invalid-type,有别的键报 unknown-key,tone 不是色调名报 invalid-option;value 是空串或缺了 value 报 required,与前面的项重复报 duplicate-value;requireLabel 为 true 时每项都要有不为空的 label,缺了报 required;allowDisabled 为 false 时选项没有禁用这回事(如值映射),每项不能带 disabled,带了报 unknown-key,编辑器也不显示禁用那一列。valueType 是 value 的类型,缺省 "string":"number" 是有限数字,"boolean" 是 true / false,"date" 是 ISO 日期(如 2026-10-19),"datetime" 是带时刻的 ISO 日期时间(如 2026-10-19T08:30),都按 @xihan-ui/core/date 的 PlainDate / PlainDateTime 严格解析;类型不对报 invalid-type,日期写法不对(如 2026-02-30)报 invalid-value,重复按值相等判(数字按数值)。问题落在那一项的那个键上,路径如 [2, "value"],按项的先后只报第一处 |
{ kind: "treeOptions", requireLabel?, allowDisabled? } | 选项树(树选择、级联这类组件吃的形状),每项是 { value, label?, disabled?, tone?, description?, children?, hasChildren? }:children 是下一层的选项,hasChildren 为 true 表示有子项但还没给出。value 只收字符串,同一层里不重复;各键的检查与 options 相同,requireLabel、allowDisabled 同义;children 不是数组、hasChildren 不是布尔报 invalid-type,超过 32 层报 invalid-value,落在那一层的列表上。问题落在那一层那一项的那个键上,路径如 [0, "children", 2, "value"],按先序(一项先查自己、再查它的下一层)只报第一处 |
number 与 sides 的数值约束是同一个类型 PropertyNumberConstraints;四边的键与次序是 PROPERTY_SIDES(top、right、bottom、left),取值类型是 PropertySides。
| 函数 | 说明 |
|---|---|
evaluateCondition(condition, target) / isPropertyVisible(descriptor, target) / isPropertyEnabled(descriptor, target) | 求值显示条件与可用条件,equals 与 oneOf 按 JSON 值比较;nestProperties 把两种条件里的 path 一并接上前缀 |
groupProperties(descriptors, target, order?) | 只收显示中的属性;order 里的组在前,其余按出现先后,无组的排最后,空组不出现 |
nestProperties(prefix, descriptors) | 把相对子对象的描述换成相对上级对象:path 与显示条件里的 path 都接在 prefix 之后,产品扩展的字段原样保留;同一组描述既给子对象的面板用,又拼进上级对象的描述时用它 |
checkPropertyValue(control, value) | 内建编辑方式的取值检查,返回 invalid-type、invalid-value、out-of-range、not-integer、invalid-option、invalid-color、unknown-key、required、duplicate-value 或 undefined |
findPropertyValueIssue(control, value) | 同 checkPropertyValue,另给出问题落在值里的哪一处(PropertyValueIssue:{ code, path },path 相对这个值):options 落在那一项的那个键上,别的编辑方式落在值本身([]) |
checkProperties(descriptors, target, { path?, target?, controls? }) | 只看显示中的属性:必填未填报 required,内建取值不合法报对应码(路径接到值里出问题的那一处,如选项列表的 [...属性路径, 2, "value"]),登记在 controls 里的编辑方式交给它的 check,既不是内建也没登记的报 unknown-control;返回结构问题,路径以 path 为前缀 |
mergePropertyEditorTables(layers) | 把几层编辑器表叠成一张,逐键合并,后面的压前面的 |
applyDefaults(target, descriptors) | 写入缺省值,中间缺少的对象键补成空对象;没有可写的返回同一个对象 |
resolvePropertyEditor(table, targetKind, descriptor) | 按「对象种类 + 路径」→「路径」→「编辑方式」查编辑器;表里放 PropertyEditor 或编辑器 id 都可以,路径键用 formatJsonPath 的写法 |
属性面板里的某些属性需要宿主自己的编辑器(如选人、选部门)。设计器按 PropertyEditor 调用它:
type PropertyEditor<D, T> = (context: {
value: JsonValue | undefined; // 当前值
descriptor: D; // 属性描述
target: T; // 被编辑的对象,如节点、元素、字段
readonly: boolean;
setValue: (value: JsonValue | undefined) => void; // 写入,连续输入并为一步撤销
commit: () => void; // 一次编辑结束(如失焦),此后的写入另起一步
issues: readonly PropertyIssue[]; // 落在这个属性及其里层的问题
}) => {
element: HTMLElement;
update?: (context: PropertyEditorContext<D, T>) => void;
destroy?: () => void;
nestedIssues?: boolean; // 编辑器自己把里层的问题标到子项上
};
interface PropertyIssue {
path: JsonPath; // 相对这个属性,恰好落在属性上的是 []
message: string; // 显示给用户的文字,由设计器换好
}设计器把 element 放进属性面板,值、只读状态或问题变化时调用 update,移除前调用 destroy。接口本身只是类型,内核不创建任何 DOM。
issues按路径排,同一路径只留一条,没有问题时是空数组。复合编辑器(选项表、列配置、条件格式)据此把里层的问题标到对应的子项上,如[2, "value"]是第 3 项的值。- 实例写了
nestedIssues: true,表示编辑器自己显示里层的问题:只有里层问题时,设计器不在这一行下面重复。没写时设计器在行下显示第一条里层问题,保证问题不会看不到。
结构校验
四款设计器校验自己的 JSON 模板时共用一种问题形状与一组读取器,从子入口 @xihan-ui/designer-kit/schema 引入。问题只带问题码与定位,不带文案,问题面板与文案表因此可以共用:
interface SchemaIssue<C extends string = string> {
code: C; // 通用码或带前缀的产品码,如 node.unreachable
path: JsonPath; // 从文档根算起,只用对象键与数组下标
target?: { kind: string; id: string }; // 问题落在的对象,下标会随编辑变,按对象定位看它
severity?: "error" | "warning"; // 缺省按 error
params?: Record<string, JsonPrimitive | JsonPrimitive[]>;
}通用问题码:required、invalid-type、invalid-value、out-of-range、not-integer、invalid-option、invalid-color、empty、unknown-key、duplicate-key、duplicate-id、duplicate-value(选项列表里 value 重复)、unknown-control。
import { createIssueSink, readNumber, readObject, readString, withSeverity } from "@xihan-ui/designer-kit/schema";
const sink = createIssueSink();
const fields = readObject(sink, value, ["nodes", 0], { required: ["id", "x"], allowed: ["id", "x", "label"] });
readString(sink, fields?.id, ["nodes", 0, "id"], { nonEmpty: true });
readNumber(sink, fields?.x, ["nodes", 0, "x"]);
const issues = withSeverity(sink.issues, { "node.isolated": "warning" }, userOverrides);| 函数 | 说明 |
|---|---|
createIssueSink({ target? }) | 收集问题:report(code, path, { params?, target?, severity? }),路径另存一份;缺省 target 写进没有自带 target 的问题 |
readObject(sink, value, path, { required?, allowed? }) | 不是普通对象报 invalid-type;缺少必填键报 required,给了 allowed 时其余键报 unknown-key;返回对象本身 |
readArray / readString(…, { nonEmpty? }) / readBoolean | 类型不符报 invalid-type(参数 expected),nonEmpty 时空串报 empty |
readNumber(sink, value, path, { integer?, min?, max?, exclusiveMin? }) | 不是有限数字报 invalid-type,不是整数报 not-integer,越界报 out-of-range(参数带上界限) |
readOneOf(sink, value, path, choices) | 不在 choices 里报 invalid-option |
readJson(sink, value, path) | 不是 JSON 报 invalid-value,参数 pointer 指出第一处非 JSON 的位置 |
checkUniqueKeys(sink, items, path, keyOf, code?) | 同一个键第二次及以后出现的项报问题(缺省 duplicate-key) |
withSeverity(issues, table, overrides?) | 定下严重程度:overrides 优先,其次问题自己写的,再次 table,最后 error;定为 off 的去掉 |
issueSeverity(issue) / hasErrors(issues) | 取严重程度;是否有 error 级问题 |
各 read 函数把 undefined 视为缺省,返回 undefined 且不报问题,必填由 readObject 的 required 管;读到的值不拷贝。读取器只要求收集器有 report(IssueReporter),问题码比通用码更宽的产品收集器也能直接传入。按 id 或名字定位数组项属于产品语义:先按下标报出,再由产品换成自己的定位。
设计器扩展
四款设计器都收一个按实例传入的 extensions 属性,值是扩展对象的数组:designer.extensions = [xihanWorkflow, hostExtension]。同页多实例各给各的,不做全局登记。内置的种类、命令、面板与导出也写成内置扩展,放在列表最前面,与传入的走同一条路。这里放四款共用的骨架;种类、渲染、面板这些由各款在骨架之上加。
import type { DesignerExtensionBase } from "@xihan-ui/designer-kit";
const simulation: DesignerExtensionBase<FlowContext> = {
id: "@xihan-flow-pro/simulation", // 全局唯一,也是模板里扩展数据的键
controls: { cron: { check: value => (typeof value === "string" ? undefined : "invalid-type") } },
rules: [doc => doc.nodes.length > 200 ? [{ code: "simulation.too-many-nodes", path: ["nodes"] }] : []],
editors: { byControl: { cron: createCronEditor } },
commands: [{ id: "@xihan-flow-pro/simulation.run", label: ctx => ctx.t.run, placement: ["toolbar"], shortcut: ["Mod", "R"], run: ctx => ctx.simulate() }],
setup: ctx => ctx.designer.addEventListener("schema-change", onChange), // 返回的函数在卸下时调用
};| 字段 | 说明 |
|---|---|
id | 全局唯一,用包名加功能名;问题码的前缀取它的末段(@xihan-flow-pro/simulation → simulation.) |
controls | 新的编辑方式,键是属性描述里的 control.kind,不能与内建的重名;checkProperties 的 controls 用合并后的这张表 |
rules | (document, context) => SchemaIssue[];问题码必须以「id 末段.」开头,否则执行器抛 TypeError |
editors | 属性编辑器表的一层(三张表都可以缺),按扩展顺序压在设计器内建的之上 |
commands | 工具栏、菜单栏、右键菜单与快捷键共用的命令:{ id, label, shortLabel?, icon?, placement?, shortcut?, enabled?, visible?, checked?, tone?, run?, panel?, menu?, select? },见下文「弹层命令与菜单命令」;placement 取 toolbar、menubar:<菜单 id>(菜单栏上的这张菜单,内置 file、edit、view、help)、menu:<主体>(这种主体的右键菜单)或 selection:<主体>(选中这种主体时浮在选中框上的选中工具条),shortLabel 是按钮上看得见的短文字(工具栏只显示图标时没有图标的按钮上写它),不给用 label,名字仍取 label,所以短文字要出现在 label 里(不分大小写),开发期不在时经诊断通道报一条告警;icon 是图标记录(如 @xihan-ui/icons 的图标),按钮与菜单项上显示;shortcut 的写法同 xh-kbd 的 keys;alternateShortcuts 是另外几组也能触发它的快捷键(如重做在 Mod+Shift+Z 之外再认 ["Ctrl", "Y"]),与 shortcut 一样查重、一样命中,提示里只显示 shortcut;会删掉内容的命令 tone 写 danger,按钮与菜单项按这族色显示;visible 返回 false 时这条命令此刻不存在:工具栏、菜单栏、右键菜单与菜单命令里都不列它,快捷键不触发,执行也不跑(与 enabled 不同,enabled 是列着但不可点),如只在开发者模式下才有的调试入口 |
importers / exporters | 粘贴、拖放、打开文件时的导入,以及导出成文件;形状由各款定。导入器可写 fileTypes(MIME 类型如 application/json、image/*,或扩展名如 .json),打开文件时拼进选择框的 accept,见 importerAccept |
fieldSources | 字段来源:导入字段对话框里的一页(如从接口请求取样例),见下文「字段来源」 |
setup | 装上时调用,返回卸下时的清理 |
DesignerExtensionCore 只含 id、controls 与 rules,给只读纯数据与纯函数的 core 函数(校验、计算)用;DesignerExtensionBase 在它之上加设计器层的字段。各款的扩展类型按层是超集:设计器层 ⊇ 运行时层 ⊇ core 层,只用查看器的页面只引运行时层。
合并、冲突与装卸
import { createExtensionHost } from "@xihan-ui/designer-kit";
const host = createExtensionHost({
builtins: [flowBuiltins],
spaces: { "node-type": ext => Object.keys(ext.nodeTypes ?? {}) }, // 各款自己的名字空间
context: () => this.commandContext(),
});
host.assign(this.extensions ?? []); // 给 extensions 赋值时调用;冲突在这里抛
host.dispose(); // 元素离开文档时调用- 顺序:内置在前,传入的按数组顺序在后。物料分组、追加的属性、规则执行、命令的缺省排列、面板的上下次序都按这个顺序。
- 命令的形状也在这一刻查:
panel与menu同时给、或两者都没给又没有run,抛TypeError(assertCommandShape)。 - 冲突在给
extensions赋值、合并登记表的那一刻抛DesignerExtensionError(code为designer.extension-conflict),报出名字空间、名字与先后登记它的两个扩展的 id,设计器保持赋值前的状态。查的名字有:扩展 id、命令 id、快捷键(Mod 按 Ctrl 算,Mod+S与Ctrl+S算同一个)、编辑方式(含内建的那几种)、导入导出 id、字段来源 id,以及spaces里各款的名字(种类、面板区名等)。不支持后登记的就地覆盖先登记的:要换掉内置行为,用自己的 id 另登记一项,再用命令排列或panels把内置那项去掉。 - 编辑器表例外:本来就是层叠模型,按顺序合并,后面的压前面的。
- 装卸按扩展 id 与对象引用比对:去掉的调它
setup返回的清理,新加的调setup;id 没变但换了一个对象,当作卸下再装上。所以宿主要保持扩展对象的引用稳定,不要每次渲染都新建一个同 id 的对象。
| 函数 | 说明 |
|---|---|
mergeExtensions(builtins, extensions, spaces?) | 只合并查重,返回列表 |
createExtensionHost({ builtins?, spaces?, context }) | 合并查重加装卸:assign(extensions) 返回新列表,list 是当前列表,dispose() 全部卸下 |
extensionEditors(base, extensions) | 编辑器表:base 是设计器内建的那层 |
extensionControls(extensions) | 编辑方式合成一张表 |
extensionCommands(extensions) | 命令按登记顺序排成 Map,键是命令 id |
findImporter(importers, sources) | 挑出接下导入的导入器:来源按先后,每个来源按导入器的先后问 accepts,返回第一个接下的与那个来源;都不接时为 undefined。粘贴与拖放的数据由 designer-shell 的 importSources 列成来源 |
importerAccept(importers) | 打开文件时选择框的 accept:各导入器的 fileTypes 按先后去重、用逗号连起来;都没写时为空串,不限类型 |
runExtensionRules(extensions, document, context) | 按扩展顺序逐条跑规则,检查问题码前缀 |
shortcutKey(keys) / formatShortcut(keys, platform) | 快捷键的比较用写法;显示文字,macOS 上 ⌘⇧P,其余平台 Ctrl+Shift+P |
assertCommandShape(command) / isCommandMenuSeparator(entry) | 查一条命令的形状,不合时抛 TypeError(合并时已经查过);菜单命令的一项是不是分隔线 |
commandShortcuts(command) | 一条命令的全部快捷键:shortcut 在前,alternateShortcuts 按先后在后;快捷键表与 aria-keyshortcuts 用它 |
commandVisible(command, context) | 这条命令此刻存不存在:没写 visible 总是存在 |
commandForShortcut(commands, event, context, platform) | 这次按键命中(shortcut 或任一组 alternateShortcuts)的、此刻存在、可用并且有 run 的命令(没给 run 的弹层命令与菜单命令不由快捷键触发);platform 由 @xihan-ui/core/hotkey 的 detectKbdPlatform() 测出;event 只读 key、code 与四个修饰键 |
字段来源
designer-shell 的导入字段对话框内置「JSON 样例」「JSON Schema」两页,扩展的 fieldSources 往后面加页,比如从接口请求取一份样例。页只负责拿到内容,推断、预览、勾选与确认都走对话框自己的那一套:
const request: DesignerFieldSource = {
id: "@acme/request",
label: "From an API",
order: 100, // 内置的两页是 0 与 10,不写按 100;同值按登记顺序
create(host) {
const element = host.document.createElement("form");
element.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("/api/orders/sample", { signal: host.signal });
host.provide({ kind: "sample", value: await response.json() });
});
return { element };
},
};create(host)每次切到这一页时调用;切走或对话框关上时host.signal中止并调页的destroy(),之后provide、fail不再生效。provide(content)的内容是{ kind: "sample", value }(JSON 样例)、{ kind: "json-schema", value },或{ kind: "fields", fields, sample? }(已经算好的字段);再给一次就换掉上一次的。fail(message)把页自己写的出错说明交给对话框显示。- 字段来源 id 与别的扩展的重名时,合并时抛
DesignerExtensionError(名字空间field-source)。
弹层命令与菜单命令
命令除了直接执行,还可以在工具栏上点开一块锚在按钮上的面板或一张菜单,两者只能给一个:
- 弹层命令给
panel(context) => { element, update?, destroy? }:每次打开建一份,element放进锚在按钮上的弹层,弹层开着时设计器状态变了调update,关掉后调destroy。打印的「页面设置」就这样做。 - 菜单命令给
menu(context)与select(value, context):menu每次刷新现算各项,一项是条目{ value, label, icon?, checked?, group?, disabled?, tone? }、另一条命令的 id(按那条命令的文字、图标、可用与语气铺,开关式命令带勾,选中即执行它),或'separator'。条目选中时调select。给了checked是可勾选的条目:写了group是同组单选,选中后菜单收起;没写是复选,选中后菜单不收起。 - 这两类命令可以不给
run:工具栏上点它们是打开弹层或菜单,不调run。要让快捷键也能打开,给一个run,在里面调命令工具栏的open(id)。 - 收进「更多」时,点了打开同一块面板或同一张菜单,锚在「更多」钮上。界面的部分见 designer-shell 那一页的「命令」。
const pageMenu: DesignerCommand<PrintContext> = {
id: "page-menu",
label: ctx => ctx.t.pagePosition(ctx.page, ctx.pages.length),
placement: ["toolbar"],
menu: ctx => [
...ctx.pages.map((page, index) => ({ value: page.id, label: page.name, checked: index === ctx.page, group: "page" })),
"separator",
"move-page-up",
"move-page-down",
],
select: (value, ctx) => ctx.designer.showPage(value),
};模板里的扩展数据
文档与每个对象(节点、元素、字段、卡片、连线)都可以带 extensions: { [扩展 id]: JsonObject }。校验只查它是对象、每一块也是对象,里面不看;编辑操作、复制粘贴与导入导出原样带着走。扩展只读写自己 id 下的那一块,与宿主的业务数据分开。
| 函数 | 说明 |
|---|---|
getExtensionData(object, id) | 读对象上某个扩展的数据,没有时为 undefined |
setExtensionData(id, data, base?) | 写入或去掉(data 为 undefined)的路径编辑操作,base 是对象自己的路径 |
extensionDataPath(id, base?) | 那一块在文档里的路径 |
checkExtensionData(object, path?) | 结构检查,不是对象报 invalid-type |
状态
实验级:四款设计器接入期间,历史栈的事务与通知、视口、吸附与控制柄的选项、属性描述、结构校验与扩展的形状仍可能调整,破坏性变更可以出现在 minor 中,并在更新日志中逐条列出。
