来源：https://ui.docs.xihanfun.com/runtime/designer-shell

# 设计器零件

`@xihan-ui/designer-shell` 是画布式设计器共用的界面零件：属性面板、标尺与参考线、变换覆盖层、物料面板与拖放、页面结构、历史记录、问题列表、小地图、外框、设置对话框、导入字段对话框与新手引导。打印、流程、表单、报表四款设计器用同一套零件，名字、手势与事件对齐。

```ts
import { defineDesignerShell } from "@xihan-ui/designer-shell";
import "@xihan-ui/designer-shell/style.css"; // 配无层皮肤时改引 style.unlayered.css

defineDesignerShell();
```

## 只出 Web Components

这个包是本库「组件三端齐备」惯例的例外：

- 只有 Web Components，没有 headless 契约，也没有 Vue 与 React 版本，不进三端一致性套件。四款设计器都以原生 Web Components 交付，这些零件只给设计器用，不是通用组件。
- 元素自己生成内部结构，不要求作者写 `data-xh-part` 的 Light DOM。部件用 `data-part` 标出，供样式与测试选取。作者内容用 `slot="<名>"` 标在零件的直接子节点上，零件把它搬进对应区域。
- 内部用到的库内元素（字段、开关、选择器、标签等）经 `defineXhElement` 按需注册，不全量注册 `@xihan-ui/web-components`。
- 样式随包发两份，都只消费语义令牌，不进 `@xihan-ui/styles` 的组件槽：`style.css` 在 `xihan.components` 层里，配有层的组件皮肤 `@xihan-ui/styles`；`style.unlayered.css` 内容相同、没有层壳，配无层的组件皮肤 `@xihan-ui/styles/index.unlayered.css`（本站与各设计器的文档站就是这样）。皮肤与零件样式要成对：无层规则不论特指度都压过有层规则，配无层皮肤却引 `style.css` 时，零件覆盖库内组件部件的那几条会静默失效，如 JSON 编辑框的定高与等宽字体、外框分隔条的直角。

## 注册

| 函数 | 说明 |
| --- | --- |
| `defineDesignerShell()` | 注册全部零件，重复调用不报错 |
| `defineDesignerShellElement(tag, ctor)` | 按需注册一个零件；同名标签注册了别的类时抛错 |
| `DESIGNER_SHELL_ELEMENTS` | 标签名到元素类的登记表 |

## 约定

- 零件不持有文档、不碰历史栈：只吃数据、吐事件。改文档与记撤销由设计器做。
- 坐标只认 `@xihan-ui/designer-kit` 的视口：屏幕 = 世界 × `zoom` + (`x`, `y`)。世界单位由设计器定，`zoom` 就是一个世界单位等于多少屏幕像素。控制柄大小、吸附阈值、标尺厚度等固定尺寸一律按屏幕像素。
- 事件一律 `CustomEvent`，冒泡并穿过 shadow 边界，名字用短横线、不带前缀。
- 零件自己生成的结构只用 `div`、`span`、`button` 这类没有正文排版的标签，列表靠 `role="list"` 与 `role="listitem"` 标出。宿主按标签名重置 `p`、`ul`、`li` 等的边距与列表样式（文档站的示例舞台、CSS 框架的基础重置），影响不到零件。
- 每个零件都有 `readonly`：只能浏览与选择，不出编辑手势、不派发修改类事件。零件的布尔 attribute 与库内元素同一个三态读法：`readonly="false"` 不只读，不写交给缺省。
- 文案键是零件的标签名，自带英文缺省。各语言的文案在 `@xihan-ui/designer-shell/locale`，与 UI 的内建语言包一一对应，导出名是 `designerShell` 加 UI 的语言包名，如 `jaJP` 对 `designerShellJaJP`。各产品把同一语言的这一份并进自己的语言包，宿主只合并一份产品包。

## 属性面板

`<xh-designer-properties>` 按 `@xihan-ui/designer-kit/properties` 的属性描述列出字段。设计器给出被编辑的对象，面板分组、挑编辑器、派发修改，自己不写文档。

面板生成的部件都带 `data-scope="designer-properties"`，样式只认它：`header`、`footer` 插槽与宿主编辑器里的作者内容，即使也用了 `data-part="title"` 之类的部件名，也不受面板样式影响。

```ts
const panel = document.createElement("xh-designer-properties");
panel.targets = [{ kind: "task", id: "t1", value: node, descriptors }];
panel.addEventListener("property-change", (event) => {
  const { path, value, targets, merge } = event.detail;
  history.execute(opsFor(targets, path, value), { merge, mergeWithin: Infinity });
});
panel.addEventListener("property-commit", () => history.seal());
```

| 属性 | 说明 |
| --- | --- |
| `targets` | 被编辑的对象 `{ kind, id, value, descriptors }`；空数组显示空状态，多个时只显示共同属性 |
| `editors` | 宿主编辑器表，按 `resolvePropertyEditor` 查找；查不到的内建种类用自带编辑器 |
| `groupOrder` / `groupLabel` | 分组的先后与标题，标题缺省显示分组 id |
| `labelOf` / `hintOf` | 字段的标签与说明；标签缺省取描述的 `label`，再缺省取 `formatJsonPath(path)` |
| `optionLabel` / `unitLabel` | select 选项与 number、sides 单位的文案；单位文案为空串时不显示后缀 |
| `dataFields` | `dataField` 选字段与 `filter` 列字段用的字段树（designer-kit 的 `DataField`）：描述里没写 `fields` 的绑定字段与条件树共用这一份；两处都没有时 `dataField` 是输入字段路径的文本框，`filter` 没有可选的字段 |
| `errors` | 字段错误 `{ target, path, message }`，文字由设计器换好，`path` 相对对象；落在属性里层的错误归到它所属的那一行，见下文 |
| `heading` | 标题下一行，如「表格 · t1」「已选中 3 个」 |
| `empty-text` | 空状态文案 |
| `toggle-limit` | select 单选且选项不超过它时改用切换按钮组；缺省不用。描述写了 `appearance: "dropdown"` 或 `"toggle"` 的 select 不看它，总是下拉或总是切换按钮组（多选也可以），绑定字段这类运行时生成的选项宜写 `dropdown` |
| `collapsible` | 分组可折叠，折叠状态按分组 id 保留，换选择不重置 |
| `readonly` | 字段只读，不派发修改 |
| `headless` | 放进外框的卡片时写：不画自己的标题与 `heading`，由面板头显示；页头插槽有内容时页头照旧显示 |

| 事件 | detail |
| --- | --- |
| `property-change` | `{ path, value, targets, merge }`：每次输入都派发，`value` 为 `undefined` 表示清除；`merge` 是合并键，交给历史栈的 `execute` |
| `property-commit` | `{ path }`：一次编辑结束（失焦、改选、切换、回车），设计器封口 |

- 共同属性是每个对象都有、`path` 相同、`control` 按 JSON 相等的那些；任一对象上不显示即不显示。取值不同时字段留空，显示占位「多个值」，宿主编辑器拿到的 `value` 为 `undefined`。
- 字段用 `<xh-field>` 包库内控件，标签、说明与错误接到控件的可聚焦部件上。换选择只换数据时，同一属性、同一编辑器的字段原样复用，焦点与光标不丢。
- 宿主编辑器的 `setValue` 与 `commit` 也走这两个事件。
- 宿主编辑器返回的 `element` 自己写了 `data-xh-part="control"` 时（库内表单控件，或原生 `input` / `select` / `textarea`），面板把它放进 `<xh-field>` 当字段的控件，标签、说明与校验状态由字段接上。没写时按一组控件处理：整行 `role="group"`，标签与说明经 `aria-labelledby` / `aria-describedby` 接上，组里的控件各自给可访问名称。
- 没有编辑器的编辑方式显示只读的一行：左边是标签文字，右边是占位说明，不进字段接线。
- 数字字段按描述的 `integer`、`exclusiveMin`、`min`、`max` 依次校验：不合法时就地报错、不派发，框里保留输入；失焦时越出 `min` / `max` 的值夹回端点。JSON 字段不能解析时同样就地报错。
- 绑定字段（`dataField`）的字段树先取描述里的 `fields`，没写时取面板的 `dataFields`。有字段树时是可搜索的树选择：按描述的 `kinds` 与 `base` 列出起点之下的字段，同 `listDataFields`（进入对象的子字段，不进入列表的子字段）；不能选的对象是禁用的分支，没有可选后代的字段不列，分支起初都展开。字段有 `label` 时条目显示它，下面一行是字段路径。选中写入字段路径的文字，清空即清除。没有字段树时是文本框，写法不对时就地报错、不派发。
- 四边（`sides`）是一组控件，整行 `role="group"`：「四边相同」开关加一个数字框，关掉后换成上、右、下、左四个（两列），每个框上方是这一边的名字、后缀是单位；四边相同时那一个框以「四边」为名。四边都按描述的数值约束校验，有边空着或违反约束时就地报错、不派发；打开开关时四边不同就按上边统一。换了取值的对象按四边是否相同摆开关；多个对象取值不同时开关禁用，只能四边一起改。
- 格式（`format`）是 `<xh-format-input>`：种类限在描述的 `kinds`，不是必填时种类里多一项「不设格式」，选它即清除；改动随输入派发，失焦时封口。多个对象取值不同时格式输入框标 `mixed`，种类显示「多个值」，选了种类就写给每个对象。
- 写了 `secret` 的文本是 `<xh-password-input>`：缺省不显示明文，可切换显隐，浏览器不代填也不记住。
- 颜色预设（`swatch`）是一组控件，整行 `role="group"`：上面是 `<xh-color-swatch-picker>`，一项一格，格子画选项的 `color`，选中写入选项的 `value`；描述写了 `custom` 时下面另有一个颜色框，填任意颜色。取值不在选项里时显示在颜色框里，色板不选中任何一格。描述写了 `none` 时色板前另有一颗开关钮（`<xh-toggle>`），文字就是它：取值没写时按下，按下即不写值（删掉这个键）；松开不算一种取值，等选了色板或填了颜色才松开。
- 选项列表（`options`）是 `<xh-field-array>` 的表格排布，放在这一行的字段里，字段的标题就是表格的名字。一项一行，三格依次是值、文字与禁用（描述写了 `allowDisabled: false` 时没有禁用这一列，写出的选项也不带 `disabled`），每格是一个字段，控件以「列名 + 第几项」为名（中文如「第 3 项的值」）；行号、拖动、上移、下移与删除把手和下面的新增把手由字段数组铺出：按住拖动把手把这一项拖到别处换位，键盘用上移、下移，新增与删除也都能按到。值与文字边输入边写入、失焦封口；勾选禁用，以及新增、删除与换序，各是一步。新增的一项只有空的 `value`，加好后焦点落到它的值上；文字为空、不禁用时不写这两个键，项上别的键（`tone`、`description`）原样留着；删掉最后一项即清除。值与文字两列有最小宽，数字、日期与日期时间的值列不窄过字段的压缩底线（`--xh-control-min-w`），放得下长一些的数与整个日期时间，禁用列放得下列头；格里的控件不超出自己那一格。面板放不下时表格横向滚动，行号钉在起始侧，把手随行一起滚动（字段数组的 `stickyActions` 关掉），滚到末端或 Tab 到时露出来，值格因此总有一个滚动位置整个看得见。值格按描述的 `valueType` 换控件：`"string"`（缺省）是文本框；`"number"` 是数字框，写回数值，清空即去掉 `value`，新增的一项是空对象；`"date"` / `"datetime"` 是日期框（到日 / 到分），写回 `YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`；`"boolean"` 固定 `true`、`false` 两行，值格只读、显示「是 / 否」（随面板语言），没有新增、删除与换序，文字与禁用照常可改，写回时两项都写、`true` 在前。多个对象取值不同时没有行，新增的一项整体写给这几个对象。
- 选项树（`treeOptions`）是 `<xh-table>` 的树形表（声明了 `tree`，平铺的各项也报 `role="treegrid"`），单元格级导航（`navigation="cell"`），整行按一组控件排，表格的名字是这一行的标题。一项一行，四格依次是值、文字、禁用与操作（描述写了 `allowDisabled: false` 时没有禁用这一列），各行报层级与层内位次；值格按层缩进，有下一层的项带展开把手；操作列里是「加子项」与「删除」两颗钮，钉在行尾，面板放不下时表格横向滚动、操作列留在原处；表格下面是「新增一项」，加在顶层末尾。键盘照表格的单元格导航：方向键在格子之间走，Enter 或 F2 进格内编辑（输入框里的左右键归输入框），Esc 或 F2 回到格子，焦点在行上时左右键收起与展开；Alt + 上下键在同一层换序，Alt + 左右键改一层缩进（往里是认上一项当上级，各项还是平铺时也行），也可以按住行拖动，拖到哪一项的中段就放进那一项下面（叶子项也行）；叶子项不带 `aria-expanded`；写回整棵树，焦点留在原来那一格。格里的控件以「列名 + 大纲号」为名（中文如「第 1.2 项的值」），两颗钮也按大纲号起名（「给第 1.2 项加子项」「删除第 1.2 项」）。值与文字边输入边写入、失焦封口；勾选禁用，以及加子项、新增、删除与换位，各是一步。新的一项只有空的 `value`，加好后焦点落到它的值上；删掉一项连同它的下一层，下一层删空时不再写 `children`；项上别的键（`tone`、`description`、`hasChildren`）原样留着。问题按路径标到那一项那一格，如 `[0, "children", 1, "value"]` 标在第 1.2 项值那一格。多个对象取值不同时没有行，新增的一项整体写给这几个对象。
- 选项列表与选项树自己不另做校验：值为空、值重复、缺了要求的文字这些问题由 designer-kit 的取值检查报（`checkProperties` 与 `findPropertyValueIssue`，路径落在那一项的那个键上），设计器照常把它们交给面板的 `errors`。编辑器按路径把问题标到格上：`[i, "value"]`、`[i, "label"]`、`[i, "disabled"]` 落在那一格（选项树先按 `children` 找到那一层那一项），项上别的键与这一项本身落在它的值上；那一格的控件 `aria-invalid="true"`，错误文字显示在格子里、经 `aria-describedby` 接上。两种编辑器都写了 `nestedIssues`，只有里层问题时行下不重复。
- 条件树（`filter`）是 `<xh-filter-builder>`：字段先取描述里的 `fields`，没写时由面板的 `dataFields` 按 `listFilterFields` 列出值字段（字段没写 `type` 的按文本）；参数与子组层数取描述的 `parameters`、`maxDepth`，`ids` 为 true 时新建的条件与组按 `c1`、`g1` 这样取树里没用过的 id。一次增删改就是一步：写入即封口。多个对象取值不同时显示空树，空态写「多个值」，在上面加的条件整体写给这几个对象。
- 错误按路径段归到行：路径以某一行属性的路径开头（含相等）就属于那一行，有嵌套描述时归路径最长的那一行；按段比较，`options` 不沾 `optionsExtra` 的错误。哪一行都不沾的不显示。
- 行下的文字依次取：控件自己发现的错误、恰好落在这一行的错误；只有里层错误时显示第一条，宿主编辑器的实例写了 `nestedIssues: true` 的不显示，由编辑器自己标到子项上。
- 宿主编辑器与内建编辑器的上下文都带 `issues`（designer-kit 的 `PropertyIssue`）：这一行的错误，路径相对这个属性，按路径排；多选时合并各对象的，同一路径只留先出现的一条。四边按 `["top"]` 这类路径把那一边的数字框标成无效（四边相同时标那一个框），文字仍在行下。
- 插槽 `header` 在标题下方（如部位导航），`footer` 在字段之后（如「删除列」）。
- `focusField(path)` 把焦点交给该属性的字段，找不到时返回 `false`。

### 复合编辑器里的字段

宿主编辑器常要拼几个字段，比如四边边框的每一边有线宽、线型与颜色。`createPropertyField` 按一种内建编辑方式建出一行字段，控件、单位后缀、数字约束与 JSON、字段路径的就地校验、空值、「多个值」与只读，都和属性面板里的同一套。复合编辑器在 `setValue` 里拼出整个值，再交给自己的 `setValue`：

```ts
import type { DesignerPropertyEditor } from "@xihan-ui/designer-shell";
import { createPropertyField } from "@xihan-ui/designer-shell";

const stroke: DesignerPropertyEditor = (initial) => {
  let context = initial;
  const current = (): Record<string, unknown> => ({ ...(context.value as Record<string, unknown> | undefined) });
  const width = createPropertyField({
    ownerDocument: document,
    control: { kind: "number", min: 0, step: 0.05, unit: "mm" },
    label: "线宽",
    setValue: value => context.setValue({ ...current(), width: value ?? 0 }),
    commit: () => context.commit(),
  }, { value: current().width as number | undefined });
  const element = document.createElement("div");
  element.append(width.element);
  return {
    element,
    update: (next) => {
      context = next;
      width.update({ value: current().width as number | undefined, readonly: next.readonly });
    },
  };
};
```

| 选项 | 说明 |
| --- | --- |
| `control` | 内建的编辑方式；别的种类抛 `TypeError` |
| `label` / `required` | 字段的标题与必填标记 |
| `setValue` / `commit` | 写入新值（`undefined` 为清除）与一次编辑结束；只读时不调用 |
| `optionLabel` / `unitLabel` / `dataFields` / `toggleLimit` | 同属性面板的同名属性 |
| `translations` | 压在配置文案之上；配置取字段所在位置的（`xh-designer-properties` 那一桶） |

`update(state)` 每次整份给状态：`value`、`readonly`、`mixed`（多个值）、`label`（给了就换掉建字段时的标题，如切换语言后）、`hint`（说明）、`error`（外面给的错误，控件自己发现的错误优先显示）与 `issues`（落在这个字段值里层的问题，路径相对字段的值，四边按它标出对应那一边；行下的文字仍取 `error`）。返回的 `element` 是一整行：控件在 `<xh-field>` 里，标题、说明与错误由字段接上；四边（`sides`）这类一组控件时整行是 `role="group"`。

复合编辑器把里层的问题标到子项上（如选项表某一行的错）：读 `context.issues`，按路径分给子项，实例上写 `nestedIssues: true`，行下就不重复。读屏与 UI 字段接线同一个约定：

- 子项控件的名字仍是子项自己的标题（「选项 3」），问题文字不并进名字。
- 问题落在子项控件可聚焦的部件上：`aria-invalid="true"`，错误文字经 `aria-describedby` 接上，与 `<xh-field>` 的 `error-text` 一样。子项用 `createPropertyField` 拼时把那一项的问题交给它的 `error`，字段会接好；自己铺的控件就自己写这两个属性，错误文字放在那一项旁边看得见的地方。
- 问题清掉后收回 `aria-invalid` 与描述。写了 `nestedIssues` 的编辑器必须自己显示这些问题，不然它们就看不到了。

```ts
const options: DesignerPropertyEditor = (initial) => {
  let context = initial;
  const element = document.createElement("div");
  let rows: PropertyField[] = [];
  const render = (): void => {
    const items = Array.isArray(context.value) ? context.value : [];
    if (rows.length !== items.length) {
      rows = items.map((_, index) => createPropertyField({
        ownerDocument: document,
        control: { kind: "text" },
        label: `选项 ${index + 1}`,
        setValue: value => context.setValue(items.map((item, i) => (i === index ? { ...item, label: value } : item))),
        commit: () => context.commit(),
      }, { value: undefined }));
      element.replaceChildren(...rows.map(row => row.element));
    }
    rows.forEach((row, index) => row.update({
      value: items[index]?.label,
      readonly: context.readonly,
      error: context.issues.find(issue => issue.path[0] === index)?.message,
    }));
  };
  render();
  return { element, nestedIssues: true, update: (next) => { context = next; render(); } };
};
```

## 问题列表

`<xh-designer-issues>` 列出结构问题。文字由设计器换好，零件只管排列、过滤与定位。

```ts
const list = document.createElement("xh-designer-issues");
list.items = issues.map(issue => ({ issue, message: t(issue), subject: nameOf(issue.target) }));
list.addEventListener("issue-activate", event => select(event.detail.issue.target));
list.addEventListener("issue-hover", event => highlight(event.detail.issue?.target));
```

| 属性 | 说明 |
| --- | --- |
| `items` | `{ issue, message, subject?, source? }[]`：`issue` 是 `SchemaIssue`，`subject` 是对象名，`source` 显示为来源小标签 |
| `group-by` | `severity`（缺省）错误在前、警告在后；`target` 按问题所在对象分组；`none` 按传入顺序 |
| `empty-text` | 没有问题时的提示 |
| `readonly` | 列表没有修改类操作，只读时照常浏览与定位 |
| `headless` | 放进外框的卡片时写：不画自己的标题，由面板头显示，列表仍以标题为名；严重程度的过滤照旧显示，要放进面板头见下文 `summary` |
| `activate` | 哪些条目能点：`target`（缺省）只有落在对象上的问题能点，设计器据此选中对象；`path` 凡有路径的都能点，JSON 编辑框据此把光标移过去 |

| 只读属性 | 说明 |
| --- | --- |
| `summary` | 头部那组带计数的严重程度开关 |

| 事件 | detail |
| --- | --- |
| `issue-activate` | `{ issue, index }`：点击或回车；`index` 是在 `items` 里的下标 |
| `issue-hover` | `{ issue }`：指针移到条目上；移出列表时 `issue` 为 `undefined` |

- 头部的错误、警告两个开关带条数，过滤状态在换 `items` 后保留。
- 放进外框的卡片时，可以把 `summary` 移到面板头的操作区：写上 `slot="<区名>-actions"` 交给外框。零件照样更新计数与开关状态，不再把它收回头部；`headless` 时头部随之收起。

  ```ts
  issues.headless = true;
  issues.summary.slot = "issues-actions";
  frame.append(issues.summary);
  ```
- 按对象分组时，组按对象在 `items` 里第一次出现的先后排列，组内错误在前。组标题取组内第一条的 `subject`，都没有时显示对象的 `id`；条目里不再重复对象名。没有 `target` 的问题归在最后一组，标题取文案 `noTarget`。
- 有 `target` 的条目是按钮，方向键、Home、End 在它们之间移动焦点；没有 `target` 的条目只显示，不可激活。`activate="path"` 时每一条都是按钮。

## 小地图

`<xh-designer-minimap>` 按比例画出设计器给的图形与当前视口的范围。按下或拖动时派发 `navigate`，由设计器把视口中心移过去。

```ts
const map = document.createElement("xh-designer-minimap");
map.shapes = nodes.map(node => ({ kind: "rect", rect: node.rect }));
map.viewport = viewport;
map.canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
map.addEventListener("navigate", event => setViewport(centerOn(viewport, event.detail.center, map.canvasSize)));
```

| 属性 | 说明 |
| --- | --- |
| `shapes` | 世界坐标的图形：`{ kind: "rect", rect, tone? }`（`tone` 为 `muted` 时取淡色）或 `{ kind: "path", d }` |
| `bounds` | 内容范围，缺省取全部矩形的外包；路径伸出矩形之外时由设计器给出 |
| `viewport` / `canvasSize` | 画布的视口与屏幕尺寸，两者都给时画出视口框 |
| `readonly` | 小地图只做导航，只读时照常可用 |

- 比例把内容与视口范围一并装下；拖动期间比例与位置保持不变，松手后重新适配。
- 没有图形时收起（`data-empty`），整块 `aria-hidden`，不进 Tab 顺序。
- 零件只画自身的框，摆在画布的哪个角由设计器决定。

## 物料面板与拖放

`<xh-designer-palette>` 按分组列出物料：每组是组名一行、下面是物料的方形小卡片，图标在上、名称在下，按面板宽度自动排成几列。点击、回车、空格或触屏轻点派发 `item-activate`，设计器一般把物料加到视口中央；按下拖动（触屏长按）经下面的通用拖放拖向画布。

```ts
const palette = document.createElement("xh-designer-palette");
palette.groups = [{ id: "human", label: "人工处理", items: [{ id: "approve", label: "审批", payload: { type: "approve" } }] }];
palette.addEventListener("item-activate", event => addAtCenter(event.detail.payload));
```

| 属性 | 说明 |
| --- | --- |
| `groups` | `{ id, label, items }[]`，物料为 `{ id, label, icon?, description?, disabled?, browseOnly?, payload?, children? }`；`icon` 是卡片上的图标，`description` 是名称之外的一句说明（卡片上不显示，悬停提示里与名称一起显示，读屏作为说明念出），`children` 是下级物料 |
| `searchable` | 显示搜索框，按名称与说明过滤；命中的条目连同它的上级一起展开显示 |
| `collapsible` | 分组可折叠，折叠状态按分组 id 保留；搜索时匹配的分组一律展开 |
| `readonly` | 物料只能浏览，不派发 `item-activate`、不能拖出 |
| `headless` | 放进外框的卡片时写：不画自己的标题，由面板头显示；外框的区已是这块地标，正文不再是地标，搜索框照旧 |

| 事件 | detail |
| --- | --- |
| `item-activate` | `{ id, payload }` |

- 卡片的边长不小于两倍 md 控件高（随密度变），列宽按面板宽度均分；名称只占一行，放不下就截断。悬停或用键盘走到一张卡片时，提示显示「名称 · 说明」，没有说明的只显示名称。
- 读屏照旧是带组名的一组按钮，整块一个 Tab 位：卡片的名字只有名称，说明另行念出。没有改用 grid 角色：列数随面板宽度变，网格的行列不是物料本身的结构，读屏念「第几行第几列」没有意义。
- 左右键按排列的先后走（右到左排列时对调），上下键走到上一行、下一行里与当前卡片对得最齐的那张，跨组也是；Home、End 到第一张、最后一张。禁用的物料也能停留，读屏照样念出。
- 搜索时平铺的组只留命中的卡片，照样排成网格；一张都没命中的组整组不显示。
- 从物料拖出时的载荷是 `{ source: "palette", id, payload }`。
- 扩展往面板里加的分组（如「我的物料」）经物料来源接进来，见下文「扩展的面板与命令」的「物料来源」。
- `disabled` 是用不了的物料：显示为禁用，不派发 `item-activate`、不能拖出。`browseOnly` 是只作浏览的物料：照常显示、能用方向键走到，同样不派发、不能拖出，用于只起分组作用的上级与只供查看的下级。

### 带下级的物料

组里有物料带了 `children` 时，这一组用 [`<xh-tree>`](../components/tree) 一行一个地显示，不排成卡片：层级与展开收起要靠缩进与箭头读出来，字段名通常也比卡片放得下的长。没有下级的组照样是卡片。适合字段树这类有层级的物料：

```ts
palette.groups = [{
  id: "fields",
  label: "字段",
  items: [
    { id: "no", label: "单号", description: "no", payload: { field: "no" } },
    { id: "items", label: "明细", description: "items", payload: { list: "items" }, children: [
      { id: "items.qty", label: "数量", browseOnly: true },
    ] },
  ],
}];
```

- 键盘与读屏按树的约定：这一组是一个 Tab 停靠点，上下键在看得见的物料间移动，右键展开、左键收起，回车与空格激活；层级、展开态与禁用报给读屏。
- 带下级的物料缺省收起，展开状态按物料 id 保留，换了 `groups` 仍在；搜索时命中的物料连同上级一起展开，清空后回到原来的展开状态。
- 点选与确认键只派发 `item-activate`，树上不留选中的痕迹；按在展开箭头上只展开、收起，不起拖。
- 上级与下级都能激活、拖出，不需要的写 `browseOnly` 或 `disabled`。

### 通用拖放

物料面板之外的拖动源（如字段树）也用这两个函数。坐标一律是浏览器视口坐标，设计器自己换算成世界坐标。

```ts
import { registerDropTarget, startDrag } from "@xihan-ui/designer-shell";

const unregister = registerDropTarget(canvas, {
  over: (point, payload) => ({ accept: payload.source === "palette", ghost: "hide" }),
  drop: (point, payload) => addAt(clientToWorld(point), payload.payload),
  leave: () => clearDropPreview(),
});
canvas.addEventListener("pan-request", event => setViewport(panBy(viewport, -event.detail.dx, -event.detail.dy)));

field.addEventListener("pointerdown", event => startDrag(event, { payload: { source: "field", path } }));
```

| 函数 | 说明 |
| --- | --- |
| `startDrag(event, { payload, source?, preview?, onEnd? })` | 从一次指针按下开始拖动，返回 `{ active, cancel }`。`preview` 缺省为拖动源的缩小副本，按下处保持在指针下；给 `null` 不显示。`onEnd` 收到 `{ dragged, dropped }`，拖过之后可借它吞掉随后的点击 |
| `registerDropTarget(element, { over, drop, leave? })` | 把元素登记为放置目标，返回取消登记的函数。`over(point, payload)` 答复 `{ accept, ghost? }`，`ghost` 为 `hide` 时藏起预览、由目标自己画落点；`leave` 在指针离开、取消或在不接受处松手时调用 |

- 鼠标与笔移动过激活距离才开始拖；触屏长按开始拖，长按前先移动即视为滚动，开始拖之后不再滚动页面。
- 命中取指针下最内层的放置目标，预览不挡命中。
- 贴着接受的目标边缘时逐帧在目标上派发 `pan-request` `{ dx, dy }`：视口要朝哪个方向看过去多少屏幕像素，贴右边缘时 `dx` 为正。
- Esc、指针离开窗口、在不接受处松手即取消。

## 页面结构

`<xh-designer-outline>` 按树列出模板里的内容（容器、分组与元素），像图层面板一样定位、删除与挪动。零件只吃节点、吐事件：选中、在画布上标出、删除与挪动都由设计器翻成自己的操作，进撤销栈也归设计器。

```ts
const outline = document.createElement("xh-designer-outline");
outline.nodes = [
  { id: "name", label: "姓名", description: "name" },
  { id: "grid", label: "栅格", children: [
    { id: "col-1", label: "第 1 列", movable: false, deletable: false, children: [{ id: "phone", label: "手机号" }] },
  ] },
];
outline.selection = designer.getSelection().nodes;
outline.allowMove = move => canMove(move);
outline.addEventListener("node-select", event => designer.select({ nodes: event.detail.ids }));
outline.addEventListener("node-highlight", event => markOnCanvas(event.detail.id));
```

| 属性 | 说明 |
| --- | --- |
| `nodes` | `DesignerOutlineNode[]`：`{ id, label, description?, icon?, children?, expanded?, selectable?, movable?, deletable? }`。`children` 是数组就是分支（空数组也能放进东西），没有就是叶子；分支缺省展开，`expanded` 为 false 的第一次出现时先收起，之后随用户展开收起，选中它下面的行时照样展开；`selectable` 为 false 的行只作分组，点了只展开、收起；`movable` 为 false 的行自己不能挪（按住拖不起来，Alt + 方向键也挪不动），仍能当落点，区域、页、页眉页脚这类行就这样写；`deletable` 为 false 的行没有删除钮，Delete 不删它 |
| `selection` | 选中的行 id，受控、可以多个：在树上改选中只派发 `node-select`，等设计器换上新值。从外面换的（不是照着 `node-select` 写回的）把新选上的第一行滚进可见区，按住 Shift 点击也从新选上的最后一行连起 |
| `allowMove` | `(move) => boolean`，一次挪动许不许；不给就不能拖。`movable` 为 false 的行先被拦下，不问它 |
| `searchable` | 显示搜索框，按名字与副文本过滤；命中的行连同上级一起展开显示，过滤时不能拖 |
| `readonly` | 能选、能标出，不能删、不能挪 |
| `headless` | 放进外框的卡片时写：不画自己的标题，由面板头显示；标题仍给树命名 |

| 事件 | detail |
| --- | --- |
| `node-select` | `{ ids }` 用户在树上改了选中，`ids` 是整份新的选中，可能为空 |
| `node-highlight` | `{ id \| null }` 悬停或焦点所在的行变了，离开整棵树时为 `null`；同一行不重复派发 |
| `node-delete` | `{ ids }` 按了删除钮或 Delete / Backspace |
| `node-move` | `{ id, parent, index }` 拖动或 Alt + 方向键挪了一行，已经过 `allowMove` 判定；`parent` 为 `null` 即顶层，`index` 已按先摘后插修正 |

- 树用 [`<xh-tree>`](../components/tree)，键盘与读屏照它的约定：整棵树一个 Tab 位，上下键走行，右键展开、左键收起，Enter 与 Space 选中，连打检索；多选照文件管理器（树的 `selection-mode="extended"`）：点击换成这一行，按住 Ctrl（macOS 为 ⌘）点击或按 Ctrl / ⌘ + Space 加选与取消，按住 Shift 点击从锚点选一段，一段里夹着的分组行（`selectable` 为 false）不算；Alt + 上下键在同一层挪一位，Alt + 左右键改一层缩进，挪动的结果由树的播报区念出。分支缺省展开，展开状态按节点 id 保留；`selection` 换了新值时，被收起的上级跟着展开。
- 删除：Delete 或 Backspace 删焦点所在的一行；焦点在选中的行上时，删全部可删的选中项。行尾的删除钮只接指针，悬停或焦点在这一行时出现（粗指针设备常显），对读屏隐藏、不占 Tab 位，按下不夺焦点；可及名取 `translations.remove(名字)`。
- 换了 `nodes` 时整棵树重新铺，焦点原在树里的回到同一 id 的行上。
- 文案键 `xh-designer-outline`：`title`、`help`（面板头里的帮助）、`search`、`empty`、`noMatches`、`remove(label)`。

### 页面结构面板

各款不必自己接零件的事件：写一个适配器交给 `createOutlinePanel`，得到一块面板，放进内置扩展的 `panels`。

```ts
import { createOutlinePanel } from "@xihan-ui/designer-shell";
import { ListTreeIcon } from "@xihan-ui/icons";

const outline = createOutlinePanel<MyContext>({
  nodes: context => toOutlineNodes(context.designer.schema),
  selection: context => context.designer.getSelection(),
  select: (context, ids) => context.designer.select(ids),
  highlight: (context, id) => context.designer.highlight(id),
  remove: (context, ids) => context.designer.remove(ids),
  canMove: (context, move) => context.designer.canMoveTo(move),
  move: (context, move) => context.designer.moveTo(move),
}, { region: { icon: ListTreeIcon }, readonly: context => context.designer.readonly });

const builtin = { id: "my-designer", panels: [outline] };
```

| 适配器 | 说明 |
| --- | --- |
| `nodes(context)` | 整棵树，每次刷新都会再取；同一份文档要给出稳定的 id |
| `selection(context)` | 画布上当前选中的行 id |
| `select(context, ids)` | 换成这些行：`ids` 是整份新的选中，空数组为全部取消；新选上的行里至少把一个滚进画布的可见区（必要时切页、切页签、展开折叠项）。设计器的选择分种类、不能混选时，按新选上的那一行取舍 |
| `highlight(context, id)` | 在画布上标出这一行对应的东西，`null` 撤掉；不改选择、不滚动 |
| `remove(context, ids)` | 删除这些行，通常是先选中再走设计器自己的删除 |
| `canMove(context, move)`、`move(context, move)` | 一次挪动许不许、执行挪动（记为一步撤销）；两个要么都给，要么都不给，不给就不能挪 |

| 选项 | 说明 |
| --- | --- |
| `region` | 面板登记里要换的项，如 `position`、`icon`、`defaultHidden`；缺省区名 `outline`、停在左栏 |
| `searchable` | 显示搜索框，缺省 `true` |
| `readonly(context)` | 返回 `true` 时只读：能选、能标出，不能删、不能挪 |
| `translations()` | 这块面板自己的文案，压在零件文案之上：标题、帮助与零件里的搜索框、空状态都用它，没给的项照旧取零件文案；读的时候现取。同一页面放几款设计器时，各款经它换掉自己那块的说法，不改全局的那一桶 |

- 面板的标题与帮助取零件文案 `xh-designer-outline` 的 `title`、`help`（给了 `translations` 时它压在上面），读的时候按全局配置现取：设计器每次把区交给外框时跟着语言变。
- 每次 `update(context)` 重取树与选中的行，内容没变时不重设，树的展开与焦点不受影响；树上的事件转给适配器，用的是最近一次刷新的上下文。
- 同一行不重复调 `highlight`；面板撤掉时撤掉标出。
- 一个面板对象可以放进各款共用的内置扩展：每个设计器实例各建一份零件，互不相干。
- 适配器只给了 `canMove`、`move` 之一时，`createOutlinePanel` 抛 `TypeError`。

## 历史记录

`<xh-designer-history>` 按先后列出撤销栈里的每一步，标出当前位置；选一行就跳回那一步。零件只吃 designer-kit 历史的快照、吐事件，跳转与清空都由设计器调历史栈，零件不碰文档。

```ts
import { snapshotHistory } from "@xihan-ui/designer-kit";

const panel = document.createElement("xh-designer-history");
const refresh = () => (panel.snapshot = snapshotHistory(editor.history));
editor.history.subscribe(refresh); // 换语言时也调一次：说明按当时的语言取成字符串
panel.addEventListener("history-go", event => editor.history.goTo(event.detail.position));
panel.addEventListener("history-clear", () => runCommand(commands, "clear-history", context));
```

| 属性 | 说明 |
| --- | --- |
| `snapshot` | designer-kit 的 `snapshotHistory(history)`：`{ entries, position, origin, truncated, originSaved }`。历史变了、换了语言都重交一份 |
| `readonly` | 能浏览，不能跳转，清空钮禁用 |
| `headless` | 放进外框的卡片时写：不画自己的标题与清空钮，标题由面板头显示；标题仍给列表命名 |
| `announce` | `(message) => void`，读屏播报：从这里选一行、跳转完成后礼貌级地读一句「跳到哪一步、走了几步」；不给就不播 |

| 事件 | detail |
| --- | --- |
| `history-go` | `{ position }` 用户选了起点或某一步，`position` 是快照里的 `origin` 或那一步的 `position` |
| `history-clear` | `{}` 用户按了头部的清空钮；设计器转给 `clear-history` 命令，先确认再清空 |

- 第一行是起点：打开、重新载入或清空之后的状态，写「打开时」；起点之前有被步数上限丢掉的步骤时写「更早的步骤已丢弃」。其后按先后列出各步，最新的在最下面；没写说明的一步写「编辑」，已撤销的一步字色减淡、副文本写「已撤销」，保存点那一行副文本写「已保存」。
- 列表用 [`<xh-listbox>`](../components/listbox)，单选，选中的就是当前位置：整张列表一个 Tab 位，上下键与 Home、End 只移焦点、不跳转，Enter 与 Space 跳到焦点那一行，点一行同样跳。选中受控：选了一行只派发 `history-go`，列表仍选在当前位置，等设计器交来新快照。
- 头部的清空钮在列表之前占一个 Tab 位；没有步骤或只读时禁用。
- 快照换了时按位置号复用行，只改文字与状态，焦点所在的行不被换掉；当前位置变了就把它滚进可见区。
- 文案键 `xh-designer-history`：`title`、`help`（面板头里的帮助）、`origin`、`originTruncated`、`untitled`、`undone`、`saved`、`empty`、`clear`，清空确认框的 `clearConfirmTitle`、`clearConfirmContent`、`clearConfirmOk`，播报的 `announceUndo(label)`、`announceRedo(label)`、`announceJump(label, steps)`、`announceCleared`。

撤销、重做与清空历史三条命令由 `designerHistoryCommands` 统一给出，见下文「命令」。

### 历史记录面板

各款不必自己接零件：写一个适配器交给 `createHistoryPanel`，得到一块面板，放进内置扩展的 `panels`。

```ts
import { createHistoryPanel } from "@xihan-ui/designer-shell";
import { HistoryIcon } from "@xihan-ui/icons";

const history = createHistoryPanel<MyContext>({
  snapshot: context => context.designer.historyState,
  goTo: (context, position) => context.designer.goToHistory(position),
  announce: (context, message) => context.frame?.announce(message),
}, { region: { icon: HistoryIcon }, readonly: context => context.designer.readonly });

const builtin = { id: "my-designer", commands: [...designerHistoryCommands(options)], panels: [history] };
const host = createPanelHost(frame, { commands: context => commandTable });
```

| 适配器 | 说明 |
| --- | --- |
| `snapshot(context)` | 此刻的快照，说明已按此刻的语言取成字符串（`snapshotHistory`）；每次刷新都会再取 |
| `goTo(context, position)` | 跳到某一步或起点（快照里的位置号）；只读、预览这类不许改的状态由各款自己挡住 |
| `announce(context, message)` | 读屏播报，通常交给外框的 `announce`；不给就不播 |

| 选项 | 说明 |
| --- | --- |
| `region` | 面板登记里要换的项，如 `icon`、`position`、`defaultHidden`；缺省区名 `history`、停在右栏、缺省隐藏 |
| `readonly(context)` | 返回 `true` 时只读：只能看，不能跳 |

- 缺省隐藏，从「面板」菜单打开；登记在右栏的内置面板之后，打开后在属性下面。外壳不依赖图标包，图标由各款经 `region.icon` 给。
- 面板头是清空历史：面板登记了 `headerCommands: ["clear-history"]`，设计器的命令表里要有这条命令（`designerHistoryCommands`），`createPanelHost` 要给 `commands` 才铺得出来。零件是 headless 的，不画自己的清空钮。
- 面板的标题与帮助取零件文案 `xh-designer-history` 的 `title`、`help`，读的时候按全局配置现取：设计器每次把区交给外框时跟着语言变。
- 每次 `update(context)` 重取快照，内容没变时不重设；选一行转给适配器的 `goTo`，跳到以后零件经适配器的 `announce` 读一句。用的都是最近一次刷新的上下文。清空、保存这类不改文档的变化也要刷新面板。
- 一个面板对象可以放进各款共用的内置扩展：每个设计器实例各建一份零件，互不相干。

## 变换覆盖层

`<xh-designer-transform>` 叠在画布上方，画选中框、控制柄、旋转柄、对齐线、框选框、悬停框与尺寸徽标，并跑移动、缩放、旋转与框选的手势。命中判断归设计器：设计器在选中项上按下后调 `startMove`，在空白处按下后调 `startMarquee`；控制柄由覆盖层自己接。覆盖层盖满画布、左上角就是视口的屏幕原点，除控制柄外不挡指针。

```ts
const overlay = document.createElement("xh-designer-transform");
canvas.append(overlay);
overlay.viewport = viewport;
overlay.items = selection.map(node => ({ id: node.id, rect: node.rect, rotatable: false }));
overlay.getSnap = () => ({ targets: others.map(node => node.rect), lines: guides, grid: 8 });
canvas.addEventListener("pointerdown", (event) => {
  const hit = hitTest(clientToWorld(event), rects);
  if (hit >= 0)
    overlay.startMove(event);
  else
    overlay.startMarquee(event, { additive: event.shiftKey });
});
overlay.addEventListener("transform", (event) => {
  const { phase, rects, copy } = event.detail;
  if (phase === "end")
    commit(rects, copy);
});
```

| 属性 | 说明 |
| --- | --- |
| `viewport` | 画布的视口 |
| `items` | 选中项 `{ id, rect, handles?, rotatable?, keepAspect?, minSize? }`。`handles` 缺省 `true` 为八向，`false` 不能缩放，数组为只给这些方向；`rotatable` 缺省不显示旋转柄；`keepAspect` 为总是等比；`minSize` 是缩放的下限 |
| `hover` | 悬停框，世界坐标 |
| `getSnap(kind)` | 每次手势开始时取一次吸附选项，交给 `snapRect`；`threshold` 不给时取屏幕上的固定像素除以 `zoom` |
| `measure` | 尺寸徽标何时显示：`gesture`（缺省）只在拖动、缩放中只读显示；`selection` 选中即常驻，单选能缩放时可以直接改宽高，见下文「尺寸徽标」 |
| `measureOptions` | 徽标的单位、换算、精度与步长 `{ unit?, displayUnit?, convert?, unitLabel?, fractionDigits?, step?, largeStep? }`；不给不显示徽标 |
| `rotate-step` | 按住 Shift 旋转时吸附的角度，缺省 15 |
| `readonly` | 不画控制柄，`startMove` 不起作用，徽标只显示不能改；框选照常 |
| `translations` | 覆盖 `xh-designer-transform` 桶里的文案：`measure`（徽标这一组的名字）、`width`、`height`（两个数字框的名字）、`measureText(width, height, unit)`（只读显示的整句） |

| 方法 | 说明 |
| --- | --- |
| `startMove(event, { copy? })` | 越过激活距离后移动全部选中项 |
| `startMarquee(event, { additive? })` | 越过激活距离后开始框选 |
| `cancel()` | 取消进行中的手势，派发 `cancel` |
| `focusToolbar()` | 把焦点交给上下文工具条：先交给工具条里 roving 的当前条目，没有就交给第一个能聚焦的；工具条没显示时什么都不做。返回是否交了 |
| `focusMeasure()` | 把焦点交给尺寸徽标里第一个能改的数字框；徽标没显示数字框时什么都不做。返回是否交了 |

| 事件 | detail |
| --- | --- |
| `transform` | `{ phase, kind, rects, guides, copy? }`：`phase` 为 `start`、`move`、`end`、`cancel`，`kind` 为 `move`、`resize`、`rotate`，`rects` 是 id 到新矩形的表，`cancel` 时为开始时的矩形；`copy` 只在移动时有 |
| `marquee` | `{ phase, rect, mode, additive }`：`rect` 是世界坐标的选框，`mode` 自左向右拖为 `contain`、反向为 `intersect`，交给 `selectInMarquee` |
| `pan-request` | `{ dx, dy }`：拖到画布边缘时逐帧派发，含义与通用拖放相同 |
| `measure-change` | `{ id, rect, axis, source }`：用户在徽标里改了宽或高。`rect` 是改后的模型矩形，左上角不动；`axis` 为 `width` 或 `height`；`source` 为 `step`（加减钮、方向键）或 `input`（输入后确认）。设计器按自己的编辑命令改模型、记撤销，再更新 `items` |

- 修饰键：缩放时 Shift 等比、Alt 以中心为基准；旋转时 Shift 按 `rotate-step` 吸附；移动时 Shift 锁定水平或竖直、Alt 为复制。修饰键在拖动中按下也算。Esc 取消。
- 设计器在 `move` 阶段是只看预览还是执行事务自行决定；手势进行中覆盖层按自己算出的矩形画框，不等 `items` 更新。
- 单选按该项的能力出控制柄。多选画各自的细框与整体包围框，可以整体移动；每一项都能八向缩放时，包围框上出八个控制柄（不出旋转柄），拖动按包围框等比缩放全部选中项：位置与尺寸乘同一比例，旋转角不变，Alt 以中心为基准，任一项缩到 `minSize` 即停。`transform` 事件的 `kind` 为 `resize`，`rects` 带全部选中项。
- 缩放时只吸附被拖动的那条边；单选时旋转过的矩形缩放时不吸附。
- 控制柄是固定的屏幕尺寸，按旋转后的方向画，指针形状随角度换；粗指针设备放大命中区。
- 画的框、线、控制柄与只读显示的徽标都是 `aria-hidden`；上下文工具条与带数字框的徽标不是。

### 上下文工具条

写 `slot="toolbar"` 的子节点是选中时浮在选中框上的一条工具条，内容由各款放，通常是一条横排的命令工具栏：各款按当前选中算出主体（如 `element`、`text`、`node`、`multiple`），用 `arrangeCommands("selection:<主体>", …)` 排出分组，宿主经 `commandLayout` 改排列，扩展登记命令时在 `placement` 里写 `selection:<主体>` 就会出现在这里。

- 位置：在选中框（多选时是整体包围框）上方居中，有旋转柄时让开它；上方放不下时翻到下方，尺寸徽标显示着就排在徽标下面；左右夹在覆盖层里。落在哪一侧写在工具条部件的 `data-placement` 上（`top` 或 `bottom`）。
- 宽度：最宽到覆盖层的宽减去两侧间距。里面放的是带「更多」的命令工具栏时，画布窄得放不下，多的钮收进「更多」，工具条不伸出画布；画布或工具条的大小变了随即重排。
- 显示：有选中才显示；只读、手势进行中（移动、缩放、旋转、框选）或没有内容时收起，松手后再出现。工具条跟着画布裁剪，不进浮层。
- 外观：实体底、描边与一层柔和投影，接指针。
- 键盘：各款在画布的 keydown 里认 Alt+F10，调 `focusToolbar()` 把焦点交过去；在工具条里按 Esc，焦点回到交之前的位置。

```ts
const format = document.createElement("xh-toolbar");
format.slot = "toolbar";
overlay.append(format);
canvas.addEventListener("keydown", (event) => {
  if (event.altKey && event.key === "F10" && overlay.focusToolbar())
    event.preventDefault();
});
```

### 尺寸徽标

给了 `measureOptions` 时，选中框（多选时是整体包围框）底边下方居中有一块尺寸徽标，与上下文工具条一样左右夹在覆盖层里，元素贴着画布边缘时也不被裁掉：

- `measure` 为 `gesture`（缺省）：只在拖动、缩放中显示实时尺寸，只读。
- `measure` 为 `selection`：选中即常驻。单选、不是只读、这一项能缩放时，宽、高各一个数字框（库内 `<xh-number-field>`，带加减钮），整块接指针；多选时写整体包围框的尺寸，`handles: false` 的项与只读时写元素自己的宽高，都只读。拖动、缩放中只读显示实时尺寸，松手回到数字框。只给了某些方向的控制柄时，只有能缩放的那一边可以改。
- 单位：`unit` 是模型坐标的长度单位，`displayUnit`（缺省同 `unit`）是显示单位，数值按 designer-kit 的 `convertLength` 换算、`formatLength` 按 locale 写出，最多 `fractionDigits`（缺省 1）位小数；单位文字缺省取显示单位的符号。不是长度的设计器不给 `unit`，徽标直接显示模型数值，单位文字写在 `unitLabel` 里。单选写元素自己的宽高，不是旋转后的外接框。
- 逐轴换算：模型坐标与显示值不是同一种长度、两轴换算还不一样时（如按格计的看板，模型是画布像素，显示格数，列距与行距不同、还要扣掉间隙），给 `convert: { toDisplay(value, axis), toModel(value, axis) }`。给了它，数值只按它换算，`unit`、`displayUnit` 只用来取缺省的单位文字；显示、步进、取整与 `minSize` 的下限都在显示值上算，交出的 `rect` 按 `toModel` 换回模型。不给时照旧按 `unit`、`displayUnit` 换算。
- 单位文字随语言：`unitLabel` 由设计器按自己的语言包给（如「格」「cells」），换了语言重交一份 `measureOptions`，徽标跟着刷新；外壳不写死任何单位文字。
- 改值：加减钮、上下方向键按 `step`（缺省 1），Shift 加上下方向键与 PageUp / PageDown 按 `largeStep`（缺省 10），马上派发 `measure-change`；输入数字后按 Enter 或离开数字框才派发，输入途中不派发，Esc 放弃输入。新值按精度取整、不小于 `minSize`；`keepAspect` 的项改一边，另一边按比例跟着变；值没变不派发。覆盖层不改 `items`，由设计器改完模型后更新。
- 键盘与指针：工具条在徽标之前，Alt+F10 交给工具条后，在工具条里按 Tab 接着进到徽标的数字框；没有工具条时各款可以让 Alt+F10 改调 `focusMeasure()`。在徽标里按 Esc，焦点回到交之前的位置。徽标里的按键与按下不冒泡到画布，打字时的 Delete 不会删掉元素。
- 外观：带数字框时与上下文工具条同一种实体面，数字框按内容定宽；只读显示时是一枚品牌色的小标签。

```ts
overlay.measure = "selection";
overlay.measureOptions = { unit: "mm", fractionDigits: 1, step: 1, largeStep: 10 };
overlay.addEventListener("measure-change", (event) => {
  const { id, rect } = event.detail;
  resize(id, rect);
});
```

按格计的看板：模型是画布像素，列距、行距各含一道间隙。

```ts
const pitch = { width: metrics.column, height: metrics.row };
const gap = { width: metrics.columnGap, height: metrics.rowGap };
overlay.measureOptions = {
  convert: {
    toDisplay: (value, axis) => (value + gap[axis]) / pitch[axis],
    toModel: (value, axis) => value * pitch[axis] - gap[axis],
  },
  unitLabel: t.cells, // 设计器自己的文案，换语言时重交
  fractionDigits: 0,
};
```

## 标尺与参考线

`<xh-designer-ruler>` 包住画布：上、左两条标尺，左上角角块，中间是作者内容，画布上方一层参考线。不带 `slot` 的子节点进画布区，`slot="corner"` 的进角块。画布区左上角就是视口的屏幕原点，覆盖层等放在画布区里即可对齐。画布区是只有一格的网格：子节点不写尺寸也撑满整块，多个子节点叠在同一块上。标尺的样式只认标尺自己生成的部件，画布区与角块里的作者内容即使也用了 `data-part="tick"`、`"marker"`、`"guide"` 之类的部件名，也不受标尺样式影响。

```ts
const ruler = document.createElement("xh-designer-ruler");
ruler.append(canvasContent);
ruler.viewport = viewport;
ruler.unitSize = 1;
ruler.guides = page.guides;
ruler.snap = (axis, value) => snapToGrid(value, 1);
ruler.addEventListener("guides-change", event => setGuides(event.detail.guides));
```

| 属性 | 说明 |
| --- | --- |
| `viewport` | 画布的视口 |
| `unit-size` | 一个刻度单位等于多少世界单位，只管刻度显示，缺省 1 |
| `guides` | `{ x, y }`：竖向（x 等于定值）与横向参考线的位置，世界单位；受控，零件不自己改 |
| `highlight` | 在两条标尺上画淡底的范围，世界坐标，如选中项的包围框 |
| `format(value)` | 刻度与参考线数值的文字，入参是刻度单位；缺省按主刻度间隔取整数或所需的小数位 |
| `snap(axis, value)` | 拖动参考线时的吸附，入参与返回值都是世界单位 |
| `readonly` | 照常显示参考线，不能新建、挪动或删除 |

| 事件 | detail |
| --- | --- |
| `guides-change` | `{ guides, change }`：`guides` 是改后的整组参考线，`change` 为 `{ type, axis, index }`，`type` 为 `add`、`move`、`remove`，新增的 `index` 在末尾 |

- 从上标尺拖进画布新建横向参考线，从左标尺拖出竖向参考线；松手时还在标尺上即取消。
- 拖动已有参考线挪动，拖回对应的标尺即删除，拖到别处不变；拖动中显示预览线与数值。
- 点参考线选中并取得焦点，Delete 或 Backspace 删除；Esc 放弃进行中的拖动。
- 指针在标尺或画布上时，两条标尺上画跟随的细线；参考线在对应标尺上另画一道标记。
- 参考线是带名称的 `separator`，名称取文案 `horizontalGuide` / `verticalGuide`。

## 外框

`<xh-designer-frame>` 是设计器的整体版式：最上一条菜单栏，下面一条工具栏，中间一行左、中、右三栏，中间一栏的下方再分一栏，最下一条状态栏。版式按命名区排，缺省登记菜单栏 `menubar`、工具栏 `toolbar`、物料 `palette`、画布 `canvas`、属性 `properties`、问题 `issues`、状态栏 `status` 七个区。作者内容用 `slot="<区名>"` 标在直接子节点上，没写 `slot` 的进中间区。

左、右、下三栏里的区是面板：停在哪一栏、先后、显隐、收成标题条、浮出成小窗，都记在一份布局 `layout` 里。外框改了布局就换成改完的整份，并派发 `layout-change`；设计器把它存进设置的 `layout.panels`。栏与栏、同一栏的面板之间是 `<xh-splitter>` 的分隔条，可以拖动或用键盘调整大小。

```ts
import { DESIGNER_SETTING_KEYS } from "@xihan-ui/designer-shell";

const frame = document.createElement("xh-designer-frame");
frame.append(toolbar, palette, canvas, properties, issues); // 各自写好 slot="toolbar" 等
frame.layout = settings[DESIGNER_SETTING_KEYS.layout]; // 上次存下的布局，没有就用缺省布局
frame.addEventListener("layout-change", event => save(DESIGNER_SETTING_KEYS.layout, event.detail.layout));
```

| 属性 | 说明 |
| --- | --- |
| `regions` | 产品登记的命名区，缺省为 `DESIGNER_FRAME_REGIONS` |
| `layout` | 面板布局 `DesignerLayout`，见下文「面板布局」；不给即缺省布局 |
| `defaultLayout` | 宿主的缺省布局：用户没存过布局时用它，「恢复默认布局」也回到它；写了的字段整项覆盖内置缺省 |
| `layoutPolicy` | 宿主对布局的管控 `{ locked?, panels? }`，通常由 `designerLayoutPolicy(settingsPolicy)` 换算得来 |
| `resolvedLayout` | 只读：此刻的整份布局，每个字段都在 |
| `narrow` | 只读：视口窄于断点、左右两栏收进抽屉时为 `true`，见下文「窄屏」 |
| `openDrawer` | 窄屏时开着的那侧抽屉（`start` 或 `end`），都收着时为 `undefined`；赋值开、关抽屉，不是窄屏时读出 `undefined`、赋值不起作用 |
| `canvasTool` | 画布上此刻的工具：`select`（缺省，点选与框选）或 `pan`（拖动：按住拖动平移画布）。拖动时画布区标上 `data-canvas-tool="pan"`、显示手形光标；外框里按 Esc（里面的元件没接走）退回 `select`；换了就派发 `canvas-tool-change`。怎么平移由各款在自己的画布上接，见下文「拖动工具」 |

| 方法 | 说明 |
| --- | --- |
| `showsPanel(name)` | 这个区是否显示：中间区与工具栏总是显示，菜单栏与状态栏看有没有内容，面板与画布浮动件看布局（浮出的也算），没登记的区为否 |
| `panelEntries()` | 面板菜单的数据：能停靠的面板与画布浮动件，按登记顺序，`closable: false` 的不列。每项 `{ name, label, icon?, shown, dock?, minimized, floating, locked, movable, floatable, minimizable }`（后三项是此刻能不能换栏、浮出、收起，算上了管控）；`label` 依次取 `title`、`label`、`name`，`shown` 是它此刻看得见：没隐藏，所在栏没在分隔条上收起，窄屏时左右两栏的面板还要它所在的抽屉开着 |
| `togglePanel(name)` | 开关一块面板：显示着就隐藏、隐藏着就显示。所在栏在分隔条上收起着时是把那一栏放回收起前的大小；窄屏时左右两栏的面板是开关它所在那一侧的抽屉 |
| `setPanelVisible(name, visible)` | 显示或隐藏一块面板 |
| `movePanel(name, dock, index?)` | 停到某一栏的第 `index` 位（按布局里那一栏的先后算，含隐藏的；缺省末尾），浮着的一并吸回 |
| `setPanelMinimized(name, minimized)` | 收成标题条或展开 |
| `floatPanel(name, rect?)` | 浮出成小窗；`rect` 是相对外框左上角的像素位置与大小，不给时按卡片此刻的大小（不小于 280 × 240，不超过外框的八成）放在中间一行的中央 |
| `dockPanel(name)` | 把小窗吸回原来的栏与位置 |
| `revealPanel(name)` | 把显示着的面板亮出来：收在窄屏抽屉里的打开抽屉，所在栏在分隔条上收起的放回，收成标题条的展开；面板隐藏着时返回 `false`。引导与问题定位用 |
| `toggleAllPanels()` | 有面板显示着就全部隐藏，否则放回全部隐藏前的那一组；不动菜单栏、工具栏与状态栏 |
| `restorePanels()` | 各面板的显隐回到缺省，位置与大小不动 |
| `resetLayout()` | 整份布局回到缺省：显隐、停靠、先后、大小、收起、浮出一起复原 |
| `announce(message)` | 礼貌级地读一句，经 core 的页面级播报器（[`announce`](../guide/behavior#页面级播报)）；外框自己在挪动、浮出、停靠、收起与隐藏之后各读一句 |

宿主锁定了整份布局或某块面板时，改那块面板的方法什么都不做。

| 事件 | detail |
| --- | --- |
| `canvas-tool-change` | `{ tool }`：画布上的工具换了（写 `canvasTool`、`pan-tool` 命令或按 Esc 退出拖动）。设计器收到时重画工具栏，让「拖动」钮的按下状态跟上 |
| `layout-change` | `{ layout, reason }`：布局变了，`layout` 是改完的整份（`ResolvedDesignerLayout`，可以直接存）。`reason` 取 `toggle`、`move`、`minimize`、`float`、`dock`、`resize`、`reset`。指针拖分隔条只在松手时派发，方向键调的、在分隔条上收起或放回的当场派发；拖小窗的标题栏挪动在松手时派发，用键盘挪动与改尺停 300 毫秒后派发。调方法改布局时先按新布局排好再派发：新的分隔条在随后的微任务里接好线，在这个事件里排下的更新量到的就是排好以后的大小。重排前后焦点留在原来持有它的元素上 |

命名区就是面板的登记，是 `{ name, position, size?, minSize?, label?, title?, subtitle?, help?, icon?, anchor?, defaultHidden?, closable?, movable?, floatable?, minimizable? }`：

- `position` 取 `menubar`、`top`、`start`、`center`、`end`、`bottom`、`status`、`overlay`。`start`、`end`、`bottom` 上的区是面板，这里写的是它缺省停的栏；同一栏的多块面板按登记顺序上下排列，共用一栏的大小，之间有分隔条，可拖动，也可聚焦后用上下方向键调整（Shift 加大步长）。`overlay` 是画布浮动件，见下文「画布区的浮动条」。
- `size` 是那一栏的缺省大小：左右两栏占中间一行的宽度，下栏占中间一栏的高度；`minSize` 是拖动的下限，缺省 10。都取那一栏第一块面板的值。中间区至少留 20。外框还按尺寸槽给的像素下限折算，两者取大的，见下文「尺寸槽」。
- `label` 是一栏里两块面板之间那条分隔条名称里的区名，缺省用 `title`，再缺省用 `name`，名称取文案 `resize`；左右两栏与画布之间、下栏与画布之间的分隔条按栏命名，取文案 `resizeLeft`、`resizeRight`、`resizeBottom`（左右按看到的方向，从右往左书写时开头那一栏在右边）；分隔条所在的布局取文案 `layout`。
- `icon` 是面板菜单与浮出小窗标题栏里的图标。`defaultHidden` 的面板缺省不显示。`closable`、`movable`、`floatable`、`minimizable` 缺省都是 `true`，写 `false` 就不出对应的钮与菜单项；没给 `title` 的区没有面板头，只能显示或隐藏。
- 隐藏的面板移出文档，内容保留，再显示时原样放回。
- 区的结构变了（比如换了一份不带标题的区表，卡片变回普通区）时，已经搬进去的作者内容跟着挪到新结构里；新结构里没有对应位置的（面板头操作区里的内容），收进外框里一个不显示的停放处（`data-part="parked"`），等结构里再有这个位置时自动放回。从 `regions` 里去掉的区（如关掉状态栏、切到预览时不登记的区），里面的作者内容同样收进停放处，不论内容自己的 `display` 怎么写都不显示，区再登记时放回。带 `slot` 而外框没有对应位置的作者内容也收在那里。
- 外框自己生成的结构（上方条、中间行、栏、分隔条、区容器、小窗与落点层）都带 `data-scope="designer-frame"`，区容器另带 `data-region`（区名）与 `data-position`（所在的栏或位置，浮出的写 `float`）。外框的样式只认这个 scope，区里的作者内容即使也用了 `data-part="region"` 之类的部件名，也不受外框样式影响。

### 面板布局

`DesignerLayout` 是一份 JSON，除 `version` 外都可以不写：

| 字段 | 说明 |
| --- | --- |
| `version` | 恒为 `1`（`DESIGNER_LAYOUT_VERSION`）；读到别的版本按缺省布局 |
| `docks` | `{ start?, end?, bottom? }`：三栏里各面板 id 的先后。浮出的面板也留在原来的栏里，记着吸回去的位置；没列到的面板按缺省位置排在那一栏的后面 |
| `hidden` | 隐藏的面板与画布浮动件 |
| `minimized` | 收成标题条的面板 |
| `floating` | 面板 id → `{ x, y, width, height }`：浮出的小窗相对外框左上角的像素位置与大小 |
| `sizes` | `{ start?, end?, bottom?, panels? }`：三栏的大小（百分比），以及同一栏里各面板分到的份额（百分比，键为面板 id） |
| `collapsed` | 在分隔条上收起的栏 |

- 写了的字段整项覆盖缺省布局，没写的取缺省：缺省布局是宿主的 `defaultLayout`，再缺省是各面板按登记顺序停在 `position` 那一栏、`defaultHidden` 的隐藏。
- 认不得的面板 id、不合法的值去掉，不报错：存下的布局里可能有已经卸掉的扩展面板。不能挪的面板只认它缺省的那一栏。登记了却没出现在布局里的面板（后装的扩展）按缺省位置放。
- 同一栏当前显示的面板按 `sizes.panels` 的比例分高度，合计不必是 100，外框按比例归一；没有记录的面板按等分值（100 ÷ 面板数）补上，都没有记录时即等分。每块至少占 10%（面板多到放不下时取等分），归一后低于下限的定在下限，其余按比例分剩下的。隐藏其中一块时，其余按各自的比例分掉它的高度；记录仍在，再显示时恢复。
- 外框派发的 `layout` 总是整份、只含登记着的面板，同一份布局派发出去又赋回来不会重排。

宿主的管控：

| `layoutPolicy` | 效果 |
| --- | --- |
| `{ locked: true }` | 整份布局只读：面板头不出收起、更多与关闭钮，不能拖，面板菜单里的面板不可点，改布局的方法什么都不做 |
| `{ panels: { <id>: "hidden" } }` | 这块面板不存在：不显示、不进面板菜单，布局里写了也不认 |
| `{ panels: { <id>: "locked" } }` | 这块面板固定：不能隐藏、挪动、浮出、收起，别的面板照常 |

`designerLayoutPolicy(settingsPolicy)` 从设置的管控换算：`layout.panels`（或整个 `layout` 分区）被藏或锁时整份锁定，`layout.panels.<面板 id>` 管一块面板。

布局的纯函数不碰 DOM，设计器或宿主改布局、读布局时可以直接用：

| 函数 | 说明 |
| --- | --- |
| `resolveDesignerLayout(regions, layout?, defaults?, policy?)` | 合成此刻的整份布局 |
| `defaultDesignerLayout(regions, defaults?, policy?)` | 缺省布局 |
| `designerLayoutEquals(a, b)` | 两份布局是不是同一份 |
| `designerPanelAbilities(region, policy?)` | 一块面板此刻能做的事：`{ closable, movable, floatable, minimizable }` |
| `designerPanelDock(layout, id)` | 面板停在哪一栏 |
| `withDesignerPanelVisible`、`withDesignerPanelMoved`、`withDesignerPanelMinimized`、`withDesignerPanelFloating`、`withDesignerDockSize`、`withDesignerPanelShares` | 几种改法，返回新的一份 |

### 菜单栏

菜单栏是最上整宽的一条，在工具栏上面，不可拖动，不是面板，按段放内容：

| 区 | 段与插槽 | 只写区名时进 |
| --- | --- | --- |
| `menubar` | `menubar-start`（菜单栏本身，`createCommandMenubar` 建的那一条）、`menubar-center`（宿主的标题，如模板名称与编码）、`menubar-end`（宿主的其他内容） | `menubar-start` |

- 各段有内容才显示这一条，内容拿走就收起。菜单栏常开，设置里没有关掉它的开关；宿主没有菜单栏时不登记 `menubar` 区，里面的内容收进停放处不显示，再登记时放回。
- 行首一段按内容宽；行尾一段按内容宽、贴右，放不下时先收窄，里面的作者内容自己截断（如写 `text-overflow: ellipsis`）；行尾一段的文字取弱一档的颜色。
- 正中一段按整条菜单栏的宽度居中，不随两边内容的宽窄偏。行首、行尾两段的内容各挡住一边，外框取离中线近的那一边，定出以中线对称的空当：内容放得下照原宽；放不下截到空当的宽，里面的作者内容（这一段的直接子元素）截断加省略号；空当窄过几个字宽就整段藏起，变宽了再出来。窄屏菜单栏收成「菜单」钮时照同一个规矩，不压那颗钮。文字取正常字色、小一号。
- 键盘：设计器里有焦点时按 F10（不带修饰键），焦点交给菜单栏里第一个看得见、能用 Tab 走到的元素（宽屏是菜单栏，窄屏是那颗「菜单」钮）；再按 F10，或在菜单栏里按 Esc（菜单收着时），回到按 F10 之前持有焦点的地方。用 Tab 走进菜单栏的，Esc 不带回去。Shift+F10 仍是右键菜单，Alt+F10 仍是选中工具条。外框在自己身上接这个键，各款不用另接；里面的元件已经接走（`preventDefault`）的 F10 与 Esc 外框不管。
- 外框由设计器自己建在设计器元素里，宿主够不着它的段：设计器用 `forwardFrameSlots(设计器元素, 外框, ["menubar-center", "menubar-end", "status-end"])` 把宿主写在设计器元素上、`slot` 在名单里的子元素搬进外框，后来加的跟着搬，宿主就能往菜单栏正中放模板标题、往状态栏右端放保存状态。名单里只写要转交的段，别的子元素不动；转过去的内容在同一段里排在设计器自己放的内容前面（状态栏右端的缩放比例这类各款自己的状态项留在最右）。返回停止转交的函数，已经搬过去的不搬回来。
- 菜单栏本身怎么从命令表铺，见下文「扩展的面板与命令」的「菜单栏」。

### 状态栏

状态栏是最下整宽的一条，不可拖动，不是面板，按段放内容：

| 区 | 段与插槽 | 只写区名时进 |
| --- | --- | --- |
| `status` | `status-start`（选中对象的信息）、`status-end`（缩放、保存与连接状态；宿主经设计器转来的排在设计器自己的前面） | `status-start` |

- 各段有内容才显示这一条，内容拿走就收起。不要状态栏（比如设置里关掉了）时从 `regions` 里去掉 `status` 区，里面的状态项收进停放处不显示，再登记时放回。
- 段用 `data-part="zone"` 与 `data-zone` 标出，左段贴左、右段贴右；状态栏的文字取弱一档的颜色与小一号的字。放不下时左段先收窄、截掉多出来的，右段按内容宽整段留着；同一段里相邻两样之间一道细分隔，宿主直接写的文字也一样。
- 状态栏上的每一项用 `createStatusItem` 建，标签、值与分隔四款一致：

  ```ts
  import { createStatusItem } from "@xihan-ui/designer-shell";

  const selection = createStatusItem(document, { label: "已选", value: "2 个节点" });
  const zoom = createStatusItem(document, { label: "缩放", value: "80%", action: () => designer.zoomToActual() });
  selection.element.slot = "status-start";
  zoom.element.slot = "status-end";
  frame.append(selection.element, zoom.element);
  zoom.update({ label: "缩放", value: "100%" });
  ```

  | 选项 | 说明 |
  | --- | --- |
  | `label` | 标签，取弱一档的颜色显示在值前面；不给只显示值 |
  | `value` | 值 |
  | `tone` | 值的语气，如保存失败写 `danger` |
  | `action` | 给了这一项就是一颗 ghost 小按钮（`<xh-button>`），点了调用它；建好之后不能再改成纯文字或反过来 |

  返回 `{ element, update(state) }`，`update` 换标签、值与语气。同一段里相邻两项之间一道细分隔。

### 面板卡片

三栏里的区给了 `title` 就画成一张卡片：承载面的底、描边与圆角，四周留出间距，上面是面板头，下面是可以滚动的正文，作者内容放进正文。

- 面板头左边是标题与一行副标题（`subtitle`，不给不显示）；右边依次是操作区、帮助钮、收起钮、「更多」钮与关闭钮。标题至少留四个字宽（`--xh-designer-frame-title-min`），窄了先让操作区：面板头命令条放不下的钮收进「更多」菜单，排在布局条目的前面，点了照点钮那样执行。
- 操作区放 `slot="<区名>-actions"` 的作者内容，没有内容时收起。扩展的面板给 `headerCommands` 时，面板宿主在这里铺一小条命令，见下文「扩展的面板与命令」。操作区里有不会自己收起的内容（命令条以外的，如问题列表挪进来的严重程度开关 `summary`），和标题、面板头的钮挤一行放不下时，操作区排到面板头末尾、另起一行占满，键盘顺序跟着在钮后面；放得下了回到标题后面。
- `help` 给了才出帮助钮，悬停或聚焦时用 `<xh-tooltip>` 弹出这段说明，按钮的名字取文案 `panelHelp`。
- 收起钮把面板收成标题条：只剩面板头，留在原位、照旧占满这一栏的宽度，同一栏的其余面板分剩下的高度；一栏全收起时几条面板头贴着栏顶，下栏全收起时下栏只剩面板头那么高。再点展开。双击面板头也是收起与展开。名字取文案 `minimizePanel` / `expandPanel`，带 `aria-expanded`。
- 「更多」钮打开一张 `<xh-menu>`，按此刻的布局列出能做的事：移到左栏、右栏、下栏，上移、下移，浮出或停靠，收成标题条或展开，隐藏。键盘与读屏挪面板靠它。从右往左写时左右两栏对调叫法。选了一项菜单就收起，焦点回到这块面板的「更多」钮（面板挪到别的栏也一样）；窄屏抽屉里的菜单先收起，再动布局。
- 关闭钮隐藏这块面板，名字取文案 `closePanel`。
- 卡片是 `role="region"`，以标题为名。区后来加了或去掉标题，外框连同已经搬进去的内容一起换成新的结构。
- 物料、属性、问题三个零件放进卡片时写 `headless`：不画自己的标题，由面板头显示。

### 拖动面板与浮出

- 按住面板头（不在按钮与操作区上）拖动，越过激活距离才开始，跟手的是面板头的副本。外框盖上一层落点层：左、右、下三条停靠条，栏空着也能停；拖进一栏两块面板之间画一条插入线；拖到画布上画出浮出的落位框。松手在停靠条上停到那一栏末尾，在插入线处插在那里，在画布上就地浮出；停回原处不算。Esc 取消。窄屏不拖。
- 浮出的面板放进一个 `<xh-floating-panel>` 小窗：标题栏是图标、标题与拖动把手，加停靠、收拢与关闭三颗钮，八个边角可以改尺。卡片的标题、收起、更多与关闭由小窗的标题栏代替，帮助钮也挪到小窗的标题栏上，面板头只留副标题与操作区，两样都没有就整条收起。小窗里卡片的正文按小窗的高度滚动。
- 小窗限在外框里：挪出去、改得比外框大时夹回框内；外框变小、页面滚动时跟着外框重新落位。位置按外框左上角记，`floating` 里存的就是这个。
- 拖着小窗的标题栏时外框同样盖上落点层：松在停靠条上停到那一栏末尾，松在一栏两块面板之间插在那里，小窗随之收起；松在别处就是挪动，松手时派发。停靠钮把小窗吸回浮出前的栏与位置；收拢就是小窗的收拢形态，记在 `minimized` 里；关闭是隐藏这块面板，再显示时仍是小窗。

### 画布区的浮动条

中间区的四角与右侧中间各有一个落点，放浮动在画布内容之上的小工具条或小地图：

| 插槽 | 落点 |
| --- | --- |
| `<中间区名>-top-start` / `-top-end` | 左上、右上 |
| `<中间区名>-bottom-start` / `-bottom-end` | 左下、右下 |
| `<中间区名>-end` | 右侧中间，竖排 |

缺省中间区名是 `canvas`，所以插槽是 `canvas-top-start` 等。落点没有内容时不占位置。右侧中间的竖条不超出中间区，里面放竖排的命令工具栏（`createCommandToolbar(document, { orientation: "vertical" })`）时，画布矮了放不下的条目按高度收进「更多」。

要能在面板菜单里开关的浮动件（小地图、翻页条）登记成画布浮动件：`{ name: "minimap", position: "overlay", anchor: "bottom-end", label: "小地图" }`，内容写 `slot="minimap"`。外框把它放进 `anchor` 那个落点（缺省 `bottom-end`），排在作者在同一落点写的内容后面；它跟着布局的 `hidden` 显示或隐藏，进面板菜单，不能挪、浮出或收起。

### 尺寸槽

外框上的几个 CSS 自定义属性，缺省取 UI 的语义令牌：

| 尺寸槽 | 缺省 | 作用 |
| --- | --- | --- |
| `--xh-designer-frame-start-min` | `--xh-layout-col-min-sm` | 左栏拖动的下限 |
| `--xh-designer-frame-end-min` | `--xh-layout-col-min-md` | 右栏拖动的下限 |
| `--xh-designer-frame-bottom-min` | `--xh-layout-col-min-xs` | 下栏拖动的下限 |
| `--xh-designer-frame-gap` | `--xh-space-2` | 卡片与四周、画布浮动条与边缘的间距 |
| `--xh-designer-frame-panel-px` / `-py` | `--xh-space-3` / `--xh-space-2` | 面板头与状态栏的内边距 |
| `--xh-designer-frame-status-h` | `--xh-control-h-sm` | 状态栏的最小高度 |

- 三个下限是像素宽度，外框量出后按自己当前的宽高折成百分比交给分隔条，外框变宽变窄时重新折算。收成标题条的面板同样按量出的面板头高度折算。
- 三栏在分隔条上可以收起：双击分隔条，或聚焦后按 Enter，收起它旁边那一栏，再来一次按收起前的大小放回；左边那条管左栏，右边那条管右栏，中间一栏下方那条管下栏。
- 收着期间外框重排（换了显示的面板、外框变宽变窄、窄屏来回）后仍收着，再双击或按 Enter 照样放回收起前的大小，焦点留在那条分隔条上。布局的 `sizes` 记的是收起前的大小，不记 0，收起的栏记在 `collapsed` 里。

### 拖动工具

工具栏与「视图」菜单里的「拖动」（内置命令 `pan-tool`，快捷键 `H`）把外框的 `canvasTool` 切成 `pan`，再按一次或按 Esc 退回 `select`。外框只管模式、光标与 Esc，平移由各款在自己的画布上接：

- 拖动开着时，在画布任意处（节点、元素上也一样）按住拖动都是平移画布，不选中、不挪对象；只点一下什么都不改。
- 画布区的光标是手形（`grab`）；画布里自己设了光标的内容（节点、控制柄）按 `[data-canvas-tool="pan"]` 改回手形，拖动中换成握拳（`grabbing`）。
- 滚轮、缩放与键盘照旧；画布浮动件（小地图、翻页条）照旧能点。
- 收到 `canvas-tool-change` 时重画工具栏，「拖动」钮的按下状态跟着变。

### 窄屏

视口窄于断点令牌 `--xh-breakpoint-md` 时外框进窄屏，外框上写 `data-narrow`，`narrow` 为 `true`：

- 中间一栏占满整行，下栏照旧在中间一栏下面；左右两栏收进从两边滑出的 `<xh-drawer>`，缺省收着。抽屉在外框里面（`contained`）、非模态，压在画布上，不挤窄画布。
- 「面板」命令与 `togglePanel` 开关抽屉，同时只开一侧，开另一侧时这一侧收起，不改布局；隐藏着的面板开抽屉时先显示并派发一次 `layout-change`。
- 面板头的关闭钮与 Esc 只收起抽屉，布局不变。抽屉以它里面各面板的标题为名，从右往左写时两侧对调。
- 浮出的面板窄屏时按停靠显示在原来那一栏的抽屉里，不改存下的布局，回到宽屏再浮出来。窄屏不能拖动面板，「更多」菜单里不出换栏与浮出。
- 菜单栏（`createCommandMenubar` 建的）收成行首一颗「菜单」钮，点开一张 `<xh-menu>`，每张菜单是它的一个子菜单。焦点在菜单栏里时宽窄一换，焦点落到此刻看得见的入口上（变窄落到「菜单」钮，变宽落到菜单栏）。
- 视口变宽回到三栏，抽屉收起；再变窄时两侧都收着。
- 没有 `matchMedia` 或断点令牌缺席时不进窄屏。

## 设置

设计器的设置是用户偏好，每人每机一份，不是模板数据：主题、主题色与状态栏，语言，工具栏的显示方式与按钮，面板布局，再加各款与扩展自己的条目。`<xh-designer-settings>` 是设置对话框；`createDesignerSettingsHost` 把主题、主题色与语言落到设计器上，并读写存储。主题色与语言清单都是内置的，宿主不传。

```ts
import { collectSettingsSections, createDesignerSettingsHost } from "@xihan-ui/designer-shell";
import { loadDesignerLocale } from "@xihan-ui/designer-shell/language";
import { mergeXhConfig } from "@xihan-ui/web-components";

const host = createDesignerSettingsHost({
  root: designerRoot,
  // 选了语言时：UI 与 designer-shell 的同语言包，再并上本款自己的
  locale: async language => mergeXhConfig(await loadDesignerLocale(language), productLocales[language]),
  onLocaleChange: () => designerRoot.requestUpdate(),
  storageKey: "flow-designer:settings",
  onStorageError: detail => console.warn(detail),
});
// 设计器根把 host.localeConfig 作为自己的 xhConfig 交出去：get xhConfig() { return host.localeConfig }

const dialog = document.createElement("xh-designer-settings");
dialog.sections = collectSettingsSections([productSettings, ...extensions]);
dialog.settings = host.load();
host.apply(dialog.settings);
dialog.addEventListener("settings-change", (event) => {
  host.apply(event.detail.settings);
  host.save(event.detail.settings, dialog.sections);
});
designerRoot.append(dialog);
dialog.show();
```

### 分区与条目

- 分区是 `{ id, label, order?, items }`，对话框左边一页。`order` 小的在前，内建的「基础」是 0、「语言」是 1、「布局」是 2、「工具栏」是 3，各款从 100 起，扩展从 200 起，900 以后留给内建的「帮助」「快捷键」「关于」，同值按登记顺序。
- 条目就是 `@xihan-ui/designer-kit` 的属性描述：`path` 只写一段条目 id，`label` 是标签，`default` 是缺省值，说明写在 `meta.description`。
- 值是扁平的键值：键是 `<分区 id>.<条目 id>`，没写的键取条目的 `default`。
- 内建编辑方式与属性面板同一套字段；别的种类交给对话框的 `editors`，被编辑的对象是整份设置。
- 条目的 `enabledWhen`（designer-kit 属性描述的可用条件）按此刻生效的整份设置求值，键是扁平的，path 只写一段，如 `{ path: ["general.developer"], equals: true }`；不满足时条目照样显示、只读，说明照旧。

| 函数 | 说明 |
| --- | --- |
| `collectSettingsSections(sources)` | 合起各来源（`{ id, settings? }`，扩展的 `settings` 字段就是这个形状）的分区。分区 id 重名时抛 `DesignerExtensionError`，名字空间 `settings-section`；`general`、`language`、`layout`、`toolbar`、`help`、`shortcuts`、`about` 留给内建 |
| `sortSettingsSections(sections)` | 按 `order` 排好，同值按传入顺序 |
| `settingKey(section, item)` | 条目的键 |
| `settingValue(settings, section, item)` | 条目此刻的值：写了用写的，没写取缺省值 |
| `resolveSettings(sections, settings)` | 此刻生效的设置：写了的键原样保留，各分区里没写的条目取缺省值（没有缺省值的不写）。交给命令、面板与扩展的上下文用这一份；`sections` 通常是 `collectSettingsSections` 合出的分区，基础分区要缺省值时把 `designerGeneralSettings` 的分区也传进来 |
| `settingPolicy(policy, section, item)` | 条目在宿主管控下的状态：先看条目键，再看分区 id |
| `designerGeneralSettings(t, options?)` | 内建的「基础」分区；对话框自己会生成，各款要单独拿条目时用 |
| `designerLanguageSettings(t)` | 内建的「语言」分区（`DESIGNER_LANGUAGE_SETTINGS`）：一条 `locale`；对话框自己生成 |
| `designerLayoutSettings(t)` | 内建的「布局」分区（`DESIGNER_LAYOUT_SETTINGS`）：一条 `panels`，编辑方式 `PANEL_LAYOUT_CONTROL`；对话框给了 `frame` 时自己生成 |
| `designerToolbarSettings(t, { layout? })` | 内建的「工具栏」分区（`DESIGNER_TOOLBAR_SETTINGS`）：按钮显示方式、显示工具栏，`layout` 为真时加按钮表（编辑方式 `TOOLBAR_LAYOUT_CONTROL`）；对话框自己生成 |

### 「基础」分区

| 条目 | 键 | 编辑方式 | 缺省 | 何时出现 |
| --- | --- | --- | --- | --- |
| 主题 | `general.theme` | 分段：跟随页面、跟随系统、亮色、暗色 | `follow` | 总有 |
| 主题色 | `general.brand` | 色板：「跟随页面」钮、UI 的 8 个预设品牌（`@xihan-ui/tokens` 的 `BRAND_PRESETS`，格子画 `BRAND_PRESET_COLORS`，名字随语言）、自定义颜色 | 不写即跟随页面 | 总有 |
| 状态栏 | `general.statusBar` | 开关 | `true` | 总有 |
| 自动保存 | `general.autoSave` | 开关：有未保存的修改时停手约一秒，在这台浏览器里存一份草稿 | `false` | 总有 |
| 开发者模式 | `general.developer` | 开关：关闭后将隐藏 JSON / Blob 调试查看入口 | `false` | 总有 |
| 分页调试日志 | `general.debugPagination` | 开关：开启后在控制台输出分页拆分与回流日志，建议仅在排查问题时使用 | `false` | 总有，开发者模式开着才能改 |
| 渲染调试日志 | `general.debugRender` | 开关：开启后在控制台输出各渲染环节的耗时，用于性能优化 | `false` | 总有，开发者模式开着才能改 |

开发者模式是调试总开关，本身不加功能：`developerModeOf(settings)` 只有写了 `true` 才为真，各款的调试查看入口（如编辑 JSON、查看生成的打印数据）写成 `visible` 跟着它的命令；宿主可以在 `settings` 里预先写开，或用 `settingsPolicy` 锁住。

自动保存只存这台浏览器里的草稿（localStorage），宿主不参与；Ctrl / ⌘ + S 照旧派发 `save`，与它无关。外壳出 `createDesignerAutosave`，各款接上：

```ts
import { autoSaveOf, createDesignerAutosave } from "@xihan-ui/designer-shell";

const autosave = createDesignerAutosave({
  root: designerRoot,
  kind: "xh-flow-designer",
  key: () => designerRoot.draftKey, // 宿主给的草稿键；不给按页面地址区分
  enabled: () => autoSaveOf(resolvedSettings),
  snapshot: () => designerRoot.schema,
  restore: document => designerRoot.replaceDocument(document), // 整份替换，进撤销栈
  confirm: options => dialogService.confirm(options),
  translations: () => commandTranslations,
});
autosave.changed(designerRoot.dirty); // 模板或保存状态每次变了都调
await autosave.loaded(designerRoot.schema); // 宿主载入一份模板时调
```

- `changed(dirty)`：有未保存的修改且开着自动保存，停手约一秒（`delay`，缺省 1000 毫秒）把整份模板写成草稿，连着改只写最后一次；变干净了（宿主存完把 dirty 置回，或撤销回到保存点）删掉草稿；关着自动保存时不写，也不删已有的草稿。
- 草稿的键是 `xh-designer-draft:<kind>:<草稿键>`；`key()` 返回 `undefined` 时用页面地址（路径、查询与锚点）。读写都包在 try/catch 里，存不了就安静跳过，不影响编辑。
- `loaded(document)`：同一个草稿键下有和它不一样的草稿就调 `confirm` 问，标题是「发现未保存的草稿（时间）」（时间按设计器那棵子树的语言写），确认按钮是恢复、取消按钮是丢弃；恢复交给 `restore`，丢弃即删掉草稿；一样就悄悄删掉。问着的时候再调不会再问一次。
- 文案在 `xh-designer-commands` 那一桶：`draftFound(time)`、`draftFoundDescription`、`draftRestore`、`draftDiscard`。

两条调试日志只在开发者模式开着时生效，关掉开发者模式随即都停。四款都显示这两个开关；没有分页的款开了也没有分页日志。日志内容写在各款里，外壳出记日志的小工具：

```ts
import { createDesignerDebugLogger } from "@xihan-ui/designer-shell";

const debug = createDesignerDebugLogger({ name: "xh-print-designer", settings: () => resolvedSettings });
debug.log("pagination", "第 2 页放不下，拆到第 3 页", () => ({ page: 2, height }));
const dom = debug.measure("render", "生成 DOM", () => buildPages());
const end = debug.start("render", "出图");
await renderImages();
end(() => ({ pages: dom.length }));
```

- 每次调用都按 `settings()` 此刻的值判断这一路开没开（`debugLogOf(settings, channel)`：开发者模式与这一路的开关都写了 `true`）。
- 开着时写到浏览器控制台，折叠成一组，组名是「[设计器名] 这一路 · 标题」，`measure`、`start` 记的组名末尾带耗时（毫秒，一位小数）；`details` 是现取的函数，结果写在组里。
- 关着时不碰控制台、不调 `details`；`measure` 只跑 `run` 并返回它的结果，`start` 返回什么都不做的函数。标题本身要算很久时先问 `enabled(channel)`。
- 渲染日志记哪次重画、因为什么、用了多久，把原因写进标题；分页日志记分页的每一步，内容由各款自己定。

键也在 `DESIGNER_SETTING_KEYS` 里。主题与主题色由 `createDesignerSettingsHost` 落到设计器上；状态栏由各款自己接，例如状态栏关掉时从外框的 `regions` 里去掉 `status` 区。面板布局也存在设置里，键是 `layout.panels`（`DESIGNER_SETTING_KEYS.layout`），对话框里对应「布局」页，见下文与「外框」的「面板布局」。

### 「语言」分区

| 条目 | 键 | 编辑方式 | 缺省 | 何时出现 |
| --- | --- | --- | --- | --- |
| 界面语言 | `language.locale` | 下拉：跟随页面、跟随系统，再是外壳自带的全部语言 | `follow` | 总有 |

- 语言清单是外壳自带的 `DESIGNER_LANGUAGES`，与 UI 组件库的内建语言包一一对应，名字是各自的本名：简体中文、繁體中文、English、日本語、한국어、Deutsch、Français、Español、Português (Brasil)、Русский。宿主不再传语言清单。
- 只作用于设计器：设计器根把语言配置作为自己的 `xhConfig` 交出去，根下的元素（含搬进浮层根的对话框、菜单）按它取 `locale` 与文案，页面别处照旧。设计器自己的文案、外壳零件的文案与 UI 元素的文案（日期、分页、确认框之类）一起换。
- `designerLanguageOf(settings, preferred?)`：设置里的语言此刻落成哪一种。没写、跟随页面或不认识的值是 `undefined`；跟随系统按 `preferred`（缺省取 `navigator.languages`）挑，挑不到用 `en-US`；写了一种语言就是它。
- `matchDesignerLanguage(preferred)`：按语言偏好的先后挑最接近的一种：完全一致的优先，否则取同一语言的那一种；中文按书写系统定简繁（`Hant` 繁、`Hans` 简），没写书写系统时台湾、香港、澳门是繁体。挑不到是 `undefined`。
- 语言包按需加载：子入口 `@xihan-ui/designer-shell/language` 的 `loadDesignerLocale(language)` 取这种语言的 UI 组件库与 designer-shell 语言包，并成一份配置（`locale` 与各桶文案）。各款再并上自己这一款的同语言包，交给设置宿主的 `locale` 选项，见下文「落到设计器上」。

### 「工具栏」分区

| 条目 | 键 | 编辑方式 | 缺省 | 何时出现 |
| --- | --- | --- | --- | --- |
| 按钮显示方式 | `toolbar.display` | 分段：图标、文字、图标和文字 | `icon` | 总有 |
| 显示工具栏 | `toolbar.visible` | 开关 | `true` | 总有 |
| 按钮 | `toolbar.layout` | 按钮表（`toolbarLayout`） | 不写即缺省排列 | 对话框给了 `toolbar` 的缺省排列与命令 |

工具栏只走设置，各款不另设属性：`toolbarDisplayOf(settings)` 取显示方式（没写或不认识的值是 `icon`），交给命令工具栏的 `setDisplay`；`toolbarVisibleOf(settings)` 只有写了 `false` 才是不显示，各款这时从外框的 `regions` 里去掉工具栏的区，命令照样能经快捷键与菜单执行。菜单栏常开，没有显示菜单栏的设置；设置命令在「文件」菜单里，还有快捷键 Mod+,。

按钮表只管顶部工具栏：菜单栏、右键菜单与选中工具条都不进用户定制，只由宿主的 `commandLayout` 改。

- **列什么**：对话框的 `toolbar` 是 `{ defaults, commands }`：`defaults` 是缺省排列（各款 `arrangeCommands("toolbar", …)` 排出的分组，已含宿主 `commandLayout` 与扩展追加的那一组），`commands` 是其中每条命令的短文字、图标与快捷键。各款每轮更新时用 `toolbarSettingsSource(defaults, 命令表, 上下文)` 现算，交给对话框；文字取 `commandShortLabel`（撤销这类文字随下一步变的命令写固定的短名），按此刻的语言取，所以每编辑一步算出来的数据不变、表不重铺。
- **样子**：一组一块，组头是三态勾选框（全选、部分、全不选）与拖动把手，下面每条命令一行：勾选框（图标与名字）、快捷键、拖动把手。组与组之间、组里的命令都能拖，命令能从一组拖进另一组；键盘在把手上用空格拿起、方向键挪、空格放下（竖排的命令列表用左右键挪到相邻的组）。表下一颗「恢复默认」删掉这个键。组这一层是一个 `<xh-sortable>`，组里的命令各是一个同 `group` 的 `<xh-sortable>`。
- **值**：`DesignerToolbarCustomization`，`{ groups, hidden? }`：`groups` 按用户的先后列出全部命令（含藏起的），藏起的写进 `hidden`，重新勾上时回到原位；整组藏起就是组里每条都在 `hidden` 里。
- **生效**：先 `arrangeCommands("toolbar", 命令表, 缺省排列, commandLayout)`，再 `applyToolbarCustomization(上一步的分组, toolbarCustomizationOf(settings))`。用户值里有、命令表里已经没有的命令跳过，不抛错（宿主 `commandLayout` 写错 id 照旧抛错）；命令表里新有、用户值里没有的命令（后装的扩展）放在缺省排列里同组前面最近一条之后，没有就放在后面最近一条之前，整组都新就另起一组。
- **管控**：`toolbar.layout` 锁定时按钮表只读，藏掉时没有这张表；显示方式与显示工具栏照常。整个 `toolbar` 分区也能藏或锁。

| 函数 | 说明 |
| --- | --- |
| `toolbarCustomizationOf(settings)` | 设置里的 `toolbar.layout`；没写或形状不对时为 `undefined` |
| `mergeToolbarCustomization(arranged, customization)` | 合起缺省排列与用户定制，列出全部命令（含藏起的），规则如上；按钮表用它 |
| `applyToolbarCustomization(arranged, customization)` | 合起来以后去掉藏起的命令与空组，就是工具栏此刻的分组 |
| `toolbarSettingsSource(defaults, commands, context)` | 交给对话框 `toolbar` 的数据，命令表里没有的跳过 |
| `toolbarVisibleOf(settings)` | 显示工具栏，只有写了 `false` 才为 `false` |

### 「帮助」「快捷键」「关于」分区

对话框给了 `help`、`shortcuts`、`about` 才有这三页，排在最后（900、910、920）。它们由只显示、不存值的条目组成：没有缺省值，编辑器不写设置，`resolveSettings` 里也不出现。

| 页 | 对话框的属性 | 内容 |
| --- | --- | --- |
| 帮助 | `help: { notes?, actions? }` | `notes` 是几段操作说明（各款的文案，按语言取好）；`actions` 是几颗钮 `{ command, label }`，如「开始引导」（`start-tour`）、「打开文档」（`help`），点了先收起对话框，等焦点回到打开它的元素再派发 `settings-command { command }`，由设计器跑这条命令；这时开始的引导收起后焦点回到那里。两样都没有时没有这一页 |
| 快捷键 | `shortcuts: { groups }` | 一张快捷键表，从命令表生成，不手写：各款用 `shortcutSettingsSource(命令表, 上下文, { extensionCommands?, keys?, commonKeys? })` 现算。凡有快捷键的命令一行（名字取 `commandShortLabel`，第二快捷键写在同一行），designer-shell 内置的命令归「通用」，四款共有的编辑命令（`DESIGNER_EDIT_COMMANDS`）归「编辑」，扩展的归「扩展」，其余归「本设计器」；`keys` 是本设计器不是命令的按键，排在「本设计器」末尾。对话框给「通用」补上四款都有的按键（`DESIGNER_COMMON_KEYS`：方向键微移、Shift 加方向键大步微移、按住空格拖动画布、F10 进出菜单栏、Alt+F10 聚焦选中工具条、Esc 取消），各款经 `commonKeys` 换掉这几行，见下文。键铺成 `<xh-kbd>`，按平台写 |
| 关于 | `about: { product, version, license?, rows? }` | 产品、版本、界面库（XiHan.UI，即 `DESIGNER_SHELL_VERSION`）的版本与许可证，后面接 `rows`：扩展的 `about` 字段（附加包的版本与授权）由各款接在这里。各款的版本号也从构建时注入的常量取（如流程的 `FLOW_VERSION`） |

各款与扩展的分区也能用这几种条目：

| `control.kind` | 形状 |
| --- | --- |
| `note` | `{ kind: "note", paragraphs }`：几段纯文本 |
| `action` | `{ kind: "action", command, text }`：一颗钮，点了派发 `settings-command`；宿主锁定这一条时不可用 |
| `info` | `{ kind: "info", rows }`：名字与值的几行 |
| `shortcutTable` | `{ kind: "shortcutTable", groups }`：几组 `{ label, rows }`，每行 `{ label, keys }` |

| 函数 | 说明 |
| --- | --- |
| `designerHelpSettings(t, help)`、`designerShortcutSettings(t, shortcuts)`、`designerAboutSettings(t, about)` | 三页的分区（`DESIGNER_HELP_SETTINGS`、`DESIGNER_SHORTCUT_SETTINGS`、`DESIGNER_ABOUT_SETTINGS`）；对话框自己生成 |
| `shortcutSettingsSource(commands, context, options?)` | 交给对话框 `shortcuts` 的快捷键表，规则如上 |
| `isDesignerDisplayControl(control)` | 这条条目只显示、不存值（`DESIGNER_DISPLAY_CONTROL_KINDS`） |

「通用」组末尾四款共有的那几行，各款可以换掉：在导出的 `DESIGNER_COMMON_KEYS` 基础上去掉本设计器没有的操作、改写说法不同的那一行，或另加几行，经 `shortcutSettingsSource` 的 `commonKeys` 交进去。

- 一行是 `{ id, keys, label? }`（`DesignerCommonKey`）：`id` 是内建的几种（`DesignerCommonKeyId`：`nudge`、`nudgeLarge`、`pan`、`selectionToolbar`、`cancel`）时可以不写 `label`，名字由对话框按语言取；写了 `label` 就用它，取各款自己的文案（按此刻的语言取好），外壳不写死。另加的行必须写 `label`，不写时类型报错，`shortcutSettingsSource` 也抛 `TypeError`。
- 不给 `commonKeys` 时照旧是 `DESIGNER_COMMON_KEYS`；给空数组就没有这几行。

```ts
import { DESIGNER_COMMON_KEYS, shortcutSettingsSource } from "@xihan-ui/designer-shell";

// 看板上没有按住空格拖动画布；Shift 加方向键是把卡片改大或改小一格
const commonKeys = DESIGNER_COMMON_KEYS
  .filter(key => key.id !== "pan")
  .map(key => (key.id === "nudgeLarge" ? { ...key, label: t.shortcutResize } : key));
settingsDialog.shortcuts = shortcutSettingsSource(commands, context, { commonKeys });
```

### 「布局」分区

对话框给了 `frame`（设计器的外框）且外框里有面板时，「基础」后面多一页「布局」：一张面板表，逐块面板一行，列出显示开关、位置（左栏、右栏、下栏、浮动）、上移下移与收成标题条，表下一颗「恢复默认布局」。它和面板头的「更多」菜单一样，是不靠拖动也能改布局的路。

- 表直接调外框的方法改布局（`setPanelVisible`、`movePanel`、`floatPanel`、`dockPanel`、`setPanelMinimized`、`resetLayout`），外框派发 `layout-change`，设计器照常把它写进设置；这一页自己不派发 `settings-change`。
- 三栏按布局里的先后排，画布浮动件排在最后、只有显示开关；不能挪的面板位置与上下移不可用，不能浮出的没有「浮动」，不能收起的收起开关不可用；浮着的面板不能上下移。从右往左写时左右两栏的叫法对调。
- 管控：`layout.panels`（或整个 `layout` 分区）`hidden` 时没有这一页，`locked` 时表只读、说明换成文案 `locked`；锁定一块面板（外框的 `layoutPolicy`）只锁那一行。

### 设置对话框

`<xh-designer-settings>` 里是 `<xh-dialog>`，左边竖排的分区页，右边按正文行宽逐条列出条目；分区页定高，换页时对话框不跳。宿主元素不占盒。

| 属性 | 说明 |
| --- | --- |
| `sections` | 各款与扩展的设置分区；没有可显示条目的分区不出现 |
| `general` | `false` 不要「基础」这一页。主题色与语言清单都是内置的，不用传。「语言」页总有，不要时用 `policy` 把 `language` 藏掉 |
| `frame` | 设计器的外框（`DesignerSettingsFrame`，外框元素本身即可）；给了且有面板时有「布局」页 |
| `help` | 「帮助」页：几段操作说明与几颗跑命令的钮，见上文 |
| `shortcuts` | 「快捷键」页：`shortcutSettingsSource` 现算的快捷键表 |
| `about` | 「关于」页：产品、版本、许可证与扩展给的几行 |
| `toolbar` | 「工具栏」页：`{ defaults, commands }`（`toolbarSettingsSource` 现算）给了才有按钮表；不给只有显示方式与显示工具栏两条；`false` 不要这一页 |
| `settings` | 设置的值 |
| `policy` | 宿主的管控：键是条目键或分区 id，值 `hidden` 不显示、`locked` 只读并把说明换成文案 `locked` |
| `editors` | 内建编辑方式之外的条目按 `control.kind` 用这里的编辑器；没登记的只显示值 |
| `open` | 对话框打开。同名 attribute |
| `section` | 当前显示的分区 id；不写或那一页不显示时显示第一页。同名 attribute |
| `translations` | 元素自己的文案，压在全局配置之上 |

| 方法 | 说明 |
| --- | --- |
| `show(section?)` | 打开对话框，给了分区 id 就显示那一页 |

| 事件 | 说明 |
| --- | --- |
| `settings-change` | `{ key, value, settings }`：改了一条，`value` 为 `undefined` 表示删掉这个键、回到缺省值；`settings` 是改过之后的整份值，元素已经换成它 |
| `open-change` | `{ open }`：按 Esc、点遮罩或关闭钮时对话框要关上，元素已经换成这个状态 |
| `settings-command` | `{ command }`：点了只显示条目里跑命令的钮（帮助页的开始引导、打开文档），由设计器跑这条命令。对话框开着时先收起，退场完、焦点回到打开它的元素后才派发；设计器在事件里再写 `open = false` 不起作用，可以不写 |

对话框打开时内容搬到浮层根：主题、主题色等视觉轴经视觉桥跟着设计器根走；标题、字段与对话框自己的文案照它们在设计器里的原位取配置，设计器外面包一层局部 `<xh-config>` 时用的是那一层的语言与文案。

### 落到设计器上

`createDesignerSettingsHost(options)` 返回 `{ load, save, apply, localeConfig, dispose }`：

| 选项 | 说明 |
| --- | --- |
| `root` | 设计器根：主题写成它的 `data-theme`，主题色写成它的 `data-brand`，只作用于设计器 |
| `locale` | `(language) => 配置或它的 Promise`：选了语言（「语言」分区不是跟随页面）时这台设计器用的配置，通常是 `loadDesignerLocale(language)` 再并上本款的同语言包。不给时语言那一条不生效 |
| `onLocaleChange` | `localeConfig` 换了时调用（语言换了、跟随系统时系统语言变了、`dispose` 时回到跟随页面），设计器据此重画自己 |
| `storageKey` | 设置存在根所在窗口 localStorage 的哪个键下；不给就不读写 |
| `onStorageError` | 读写失败（无痕窗口、配额满、内容不是对象）时回调 `{ operation, key, error }`，设置留在内存里。给了 `storageKey` 就必须给，否则抛 `TypeError` |

- `apply(settings)`：主题跟随页面时把根的 `data-theme` 还原成接手前的值，跟随系统时按 `prefers-color-scheme` 写、系统改了跟着改；主题色是 UI 的预设品牌就直接写它；别的颜色经 `@xihan-ui/tokens` 的 `registerBrand` 在根所在的文档里注册成这台设计器自己的品牌（一份 `[data-brand]` 取值块），换走时撤销；取不出品牌梯度的值不生效，按跟随页面处理。
- `data-brand` 要与 `data-theme` 在同一个元素上才换得动品牌：主题跟随页面又选了主题色时，根上照抄外层的 `data-theme`（外层没写就是亮色）。根上新写了主题时，语义令牌在这里重新声明，外层的 `data-contrast` 也一并照抄。外层这两个属性变了，根上跟着改。
- `apply(settings)` 也按语言那一条换 `localeConfig`：跟随页面当场换回空对象；选了语言或跟随系统时调 `locale` 取配置，取到后记下、通知根下的子树重画，再调 `onLocaleChange`；取的期间又换了语言，只落最后要的那一种。跟随系统时盯着窗口的 `languagechange`，浏览器的语言偏好变了跟着换。
- `localeConfig`：设计器根此刻的语言配置，跟随页面时是空对象。设计器根声明 `xhConfig` 返回它，根下的元素就按它取 `locale` 与文案。
- `save(settings, sections?)`：`sections` 里标了 `secret` 的文本条目不存。
- `load()`：没给存储键、没存过或读不出时是空对象。
- `dispose()`：撤掉跟随系统与外层的监听、注册的主题色，根上的 `data-theme`、`data-brand`、`data-contrast` 还原成接手前的样子，`localeConfig` 回到空对象。

## 导入字段

`<xh-designer-field-import>` 从一份 JSON 样例或一份 JSON Schema 导入字段树。里面是 `<xh-dialog>`：上面一排来源页签，下面是预览表，用户勾选、改显示名与类型后按「导入」。推断用 designer-kit 的 `@xihan-ui/designer-kit/field-inference`，全在本机做，不发请求。对话框不改设计器的字段，派发 `fields-import`，由设计器自己换上。宿主元素不占盒。

```ts
const dialog = document.createElement("xh-designer-field-import");
dialog.fields = designer.fields; // 现有字段：不为空时出「合并 / 替换」
dialog.contents = ["text", "image", "barcode", "qrcode"]; // 给了才有「内容」列
dialog.inference = { detectImages: true };
dialog.offerSample = true;
dialog.addEventListener("fields-import", (event) => {
  designer.fields = event.detail.fields;
  if (event.detail.sample !== undefined)
    designer.sampleData = event.detail.sample;
});
dialog.show();
```

| 属性 | 说明 |
| --- | --- |
| `sources` | 扩展登记的字段来源（designer-kit 的 `DesignerFieldSource`，见那一页的「字段来源」），按 `order` 排在内置两页之后；id 与内置页重名时抛 `TypeError` |
| `fields` | 现有字段：不为空时出「合并 / 替换」，合并时预先标出同名而种类不同的字段 |
| `contents` | 给了才有「内容」列，值字段的内容种类只能在这几种里选；不给时保留推断出的内容 |
| `inference` | 推断选项，交给 `inferDataFields`；JSON Schema 只用其中的 `rootName` 与 `maxDepth` |
| `offerSample` | 内容带样例时出「同时作为示例数据」，缺省勾上。attribute 是 `offer-sample` |
| `open` | 对话框打开。同名 attribute |
| `source` | 当前显示的页：`sample`、`json-schema`（`DESIGNER_FIELD_IMPORT_SOURCES`）或字段来源的 id；不写或找不到时显示第一页。同名 attribute |
| `translations` | 元素自己的文案，压在全局配置之上 |

| 方法 | 说明 |
| --- | --- |
| `show(source?)` | 打开对话框，给了页 id 就显示那一页 |

| 事件 | 说明 |
| --- | --- |
| `fields-import` | `{ fields, mode, source, sample?, annotations }`：用户按了「导入」，对话框随即关上。`fields` 已按勾选、显示名、类型、内容与合并 / 替换算好；`mode` 在现有字段为空时恒为 `replace`；`sample` 只在内容带样例、又勾了「同时作为示例数据」时有；`annotations` 是推断出的注解（说明、必填、候选值等），键是 `formatJsonPath(字段路径)` |
| `open-change` | `{ open }`：按 Esc、点遮罩、关闭钮、取消或导入时对话框关上，元素已经换成这个状态 |

- 内置两页各有一个多行输入框，下面是读文件的投放区：把 `.json` 文件拖进来或点它选一个，文件在本机读进输入框。输入一变就解析；解析失败时写出第几行第几列与原因（core/json 的 `parseJsonText`），预览表收起，导入钮不可用。
- 字段来源页切到时才建，切走或对话框关上时中止并销毁，它交来的内容一并清掉；`fail(message)` 给的话写在页下。
- 预览表一个字段一行，下级按层级缩进：勾选框、字段名、显示名输入框、类型下拉（值字段才有，对象与列表写种类）、内容下拉。去掉一行就连同它的下级一起不导入，下级的控件不可用。内容换了时，路径没变的行保留用户改过的显示名、类型与内容。表下列出推断说明（如「列表里是单个值，导入为没有子字段的列表」）与合并时种类不同的字段。
- 每次打开都从空白开始。
- 文案键 `xh-designer-field-import`，英文表 `DESIGNER_FIELD_IMPORT_EN_US`；推断说明按 designer-kit 的说明码各一条。

内置命令 `import-fields` 打开它，见下文「命令」的 `designerShellCommands`。

## JSON 编辑

`<xh-designer-json-editor>` 编辑整份模板的 JSON。里面是 `<xh-dialog>`：一个等宽字体、不折行的多行编辑框，下面是解析失败的说明与问题列表（`<xh-designer-issues>`），底边是格式化、复制、取消与应用。对话框不改设计器的文档，派发 `json-apply`，由设计器自己换上；四款把它作为一步进撤销栈，说明写「编辑 JSON」。宿主元素不占盒。

```ts
const editor = document.createElement("xh-designer-json-editor");
editor.value = designer.schema; // 每次打开都从它写起，缩进两格
editor.validate = value => issuesOf(migrate(value)).map(issue => ({ issue, message: t(issue) }));
editor.readonly = designer.readonly;
editor.addEventListener("json-apply", event => designer.replaceDocument(event.detail.value));
editor.show();
```

| 属性 | 说明 |
| --- | --- |
| `value` | 打开时编辑框里的 JSON（设计器此刻的整份文档）；只走 property |
| `validate` | `(value) => DesignerIssueItem[]`：各款先走读入迁移、再走 core 的校验，与粘贴整份模板同一套，换好文字交回来。不给时只查能不能解析 |
| `readonly` | 只读：能看、能格式化、能复制，不能改，也没有「应用」。同名 attribute |
| `open` | 对话框打开。同名 attribute |
| `translations` | 元素自己的文案，压在全局配置之上 |

| 方法 | 说明 |
| --- | --- |
| `show()` | 打开对话框 |

| 事件 | 说明 |
| --- | --- |
| `json-apply` | `{ value }`：用户按了「应用」，对话框随即关上。只有文字解析得了、没有错误级问题、又与打开时的值不同时「应用」才可用 |
| `open-change` | `{ open }`：按 Esc、点遮罩、关闭钮、取消或应用时对话框关上，元素已经换成这个状态 |

- 文字一变就重新解析（core/json 的 `parseJsonText`）并交给 `validate`。解析失败时编辑框标成 invalid，写出第几行第几列与原因，后面一颗「转到出错处」把光标放过去。
- 点问题列表的一条，光标选中那个路径在文字里的值（core/json 的 `locateJsonPath`，见下），编辑框滚到那一行；路径在文字里没有时退到最近的上级。整块的对象或数组只选它起头的那一行。
- 打开时落焦在编辑框，光标在开头、滚到顶，不全选。
- 复制把编辑框里的文字写进剪贴板（core/clipboard 的 `writeToClipboard`，要安全上下文），钮旁写一句「已复制」或「复制失败」，再改文字时清掉。
- 每次打开都从 `value` 重新写起，没应用的改动丢掉。
- 文案键 `xh-designer-json-editor`，英文表 `DESIGNER_JSON_EDITOR_EN_US`。

`locateJsonPath(text, path)`（`@xihan-ui/core/json`）在 JSON 文本里找路径指向的值，返回 `{ start, end, line, column }`：值在文本里的起止下标与起点的行列（从 1 数起，换行的算法同 `parseJsonText`）。路径写法同 `getJsonAt`，空路径指整个值；文本不是合法 JSON 或路径走不通时为 `undefined`；对象里键重复时取最后一个，与 `JSON.parse` 一致。

内置命令 `edit-json` 打开它，见下文「命令」的 `designerShellCommands`。

## 新手引导

`<xh-designer-tour>` 是设计器的新手引导：库内 `<xh-tour>` 包一层，designer-shell 的通用步骤加上各款与扩展的步骤，按 `order` 排，一步步高亮外框里的面板、区或工具栏上的命令。放在设计器元素里、外框旁边，和设置对话框一样；宿主元素不占盒。

```ts
import { collectTourSteps, resolveTourSteps, tourDoneOf } from "@xihan-ui/designer-shell";

const steps = collectTourSteps([productExtension, ...extensions]); // id 重名时抛 DesignerExtensionError
const tour = document.createElement("xh-designer-tour");
tour.frame = frame;
tour.steps = resolveTourSteps(steps, context); // 每轮按上下文取文字、过一遍 when
designerRoot.append(tour);
tour.addEventListener("tour-end", () => writeSetting("help.tourDone", true));
if (autoTour && !tourDoneOf(settings))
  tour.start(); // 第一次连上文档、画布铺好之后；同一页已有引导开着时不开始
// 用户手动开始（start-tour、帮助页的「开始引导」）写 tour.start({ force: true })，先收起同一页别的引导
```

### 步骤

| 字段 | 说明 |
| --- | --- |
| `id` | 全局唯一：designer-shell 的写 `shell.<名>`，各款写 `<产品>.<名>`，扩展写扩展 id 加点 |
| `target` | 要高亮的目标，不给就不锚定、浮层居中：`{ panel, part? }` 面板卡片（给了 `part` 就是面板里 `data-part` 为它的节点）；`{ command }` 工具栏上这条命令的按钮，收在「更多」里时是「更多」钮；`{ region }` 外框的区（`menubar`、`toolbar`、`canvas`、`status`）；`{ menubar }` 菜单栏上这张菜单的入口（菜单 id，如 `file`，或各款自己的 `insert`、`data`），窄屏菜单栏收成「菜单」钮时是那颗钮，菜单栏上没有这张菜单或菜单栏没显示时那一步跳过；`{ selector }` 设计器里的任意节点 |
| `title`、`description` | 标题与说明，可以是按上下文现算的函数 |
| `placement` | 浮层相对目标的首选位置，缺省 `bottom` |
| `order` | 排序：designer-shell 的通用步骤 0–60 与收尾的 900，各款 100 起，扩展 200 起；同值按登记顺序 |
| `when` | 返回 `false` 时跳过，如只读模式下的编辑步骤 |

通用步骤：`shell.welcome`（居中，0）、`shell.menubar`（菜单栏区，5：各张菜单里有什么，F10 进出菜单栏）、`shell.toolbar`（工具栏区，10）、`shell.palette`（物料面板，20）、`shell.canvas`（画布区，30）、`shell.properties`（属性面板，40）、`shell.issues`（问题面板，50）、`shell.status`（状态栏，60）、`shell.settings`（900，排在各款与扩展的步骤之后收尾：指向「文件」菜单的入口，说设置在「文件」菜单里、引导能从「帮助」重新开始；外框没有菜单栏时跳过），文案在 `xh-designer-tour` 那一桶。目标面板隐藏、区不在外框里或没有内容、命令没有按钮、菜单不在菜单栏上的那一步跳过，所以没有物料面板的报表自然少一步，各款补自己的步骤即可；各款自己的菜单（如打印的「插入」「页面」、报表的「数据」）用 `{ menubar: "<菜单 id>" }` 指到它的入口。

| 函数 | 说明 |
| --- | --- |
| `resolveTourSteps(steps, context)` | 按上下文取好文字、过一遍 `when`，得到交给零件的 `DesignerTourStepView[]`；顺序不变 |
| `collectTourSteps(sources)` | 合起各来源（`{ id, tourSteps? }`，扩展的 `tourSteps` 字段就是这个形状）的步骤，保持登记顺序。id 重名或用了留给 designer-shell 的 `shell.` 前缀时抛 `DesignerExtensionError`，名字空间 `tour-step` |
| `tourDoneOf(settings)` | 设置里 `help.tourDone`（`DESIGNER_TOUR_DONE_KEY`）为 `true`：引导已经完成或跳过过，不再自动开始。这个键不对应设置对话框里的条目 |

### 零件 `<xh-designer-tour>`

| 属性 | 说明 |
| --- | --- |
| `frame` | 设计器的外框：解析目标、判断面板显隐，进入一步前经 `revealPanel` 把面板亮出来（展开分隔条上收起的栏、标题条，窄屏时开抽屉） |
| `steps` | 各款与扩展的步骤（`resolveTourSteps` 取好的） |
| `builtin` | 带上通用步骤，缺省 `true`；同名 attribute 写 `"false"` 不带 |
| `translations` | 元素自己的文案，压在全局配置之上 |

| 方法与只读属性 | 说明 |
| --- | --- |
| `start(options?)` | 从第一步开始；没有能走的步骤时返回 `false`。同一页里已有别的引导开着时缺省不开始、返回 `false`（自动开始用这个）；`{ force: true }` 先收起那一个再开始（用户手动开始用这个） |
| `stop()` | 收起引导 |
| `plannedSteps()` | 此刻会走的各步：排好序、去掉了走不了的 |
| `running` | 引导此刻开着 |

| 事件 | 说明 |
| --- | --- |
| `tour-end` | `{ reason, step }`：引导收起了。`reason` 是 `complete`（走完）、`skip`（跳过或 Esc）或 `close`（关闭钮或 `stop()`）；`step` 是收起时那一步的 id。各款收到后把 `help.tourDone` 写成 `true` |

- 浮层里依次是标题、说明、「第几步，共几步」、一步一个的圆点，底部一行跳过、上一步与下一步（最后一步写「完成」），右上角关闭钮；键盘与读屏照 `<xh-tour>`。
- 引导限在外框里（`<xh-tour>` 的 `boundary` 是外框）：遮罩只盖外框，高亮框连同暗幕裁在外框里，居中的那步摆在外框中间，气泡以外框为界避让；页面别处照常可读可点，焦点不困在气泡里。同一页同时只有一个引导开着。
- 窄屏时，目标面板收在抽屉里的那步把抽屉打开，抽屉排在引导之下，不盖住提示框，Esc 仍先收引导；抽屉滑进来之后高亮框按面板滑到的位置重新量。换到目标不在抽屉里的一步（不是面板，或面板在下栏）、以及引导收起时，抽屉还原成开始引导时的样子。元素从文档上摘下时引导一并收起。
- 收起时焦点回到开始引导时持有它的元素；那个元素已不在文档里时，退回外框画布区里能聚焦的第一个（画布区没有就取外框里的第一个）。
- 宿主不要自动开始时设计器给 `autoTour = false`；内置命令 `start-tour`（`designerShellCommands` 的 `startTour` 选项）与帮助页里的「开始引导」随时重新开始。

## 扩展的面板与命令

设计器扩展的骨架、合并查重与装卸在 `@xihan-ui/designer-kit`（见那一页的「设计器扩展」）。这里放跟界面有关的几样：面板登记到外框的区，命令铺成工具栏按钮、菜单栏与右键菜单条目，粘贴与拖放的数据列成导入来源，设置分区放进设置对话框，物料来源接进物料面板。

### 面板

```ts
import { createPanelHost } from "@xihan-ui/designer-shell";

// 内置区缺省为 DESIGNER_FRAME_REGIONS，产品有自己的就传 regions；给了 commands，面板头的命令按它铺
const panels = createPanelHost(frame, { regions, commands: () => commandTable, actionsLabel: (context, title) => title });
panels.assign(host.list, context); // 每次 extensions 变化后调用
panels.update(context); // 设计器状态变化后刷新
panels.dispose();
```

- 扩展的 `panels` 是 `{ region, create(context) => { element, update?, destroy? }, headerCommands? }`：`region` 是外框的命名区，也就是面板的登记（名字、缺省停的栏、标题、图标、能不能挪与浮出等，见「外框」），追加在内置区之后，同一栏按扩展顺序上下排；`element` 以 `slot` 交给外框，放进那个区。
- 区名与内置区或别的扩展重名时 `assign` 抛 `DesignerExtensionError`（名字空间 `panel`），什么都不变。
- 按面板对象的引用比对：留下来的不重建，去掉的调 `destroy` 后撤掉。
- `headerCommands` 是面板头操作区里的命令 id（或按上下文现算的函数）：面板宿主按选项 `commands` 给的命令表铺成一小条只显示图标的工具栏，放进 `slot="<区名>-actions"`，每次 `update` 刷新，命令表里没有的 id 跳过；一条命令都没有时整条藏起，里面不放 `<xh-toolbar>`，有了命令再放回。内置面板（物料、属性、问题这类不是扩展登记的面板）用 `createPanelActions(document, 面板 id)` 建同样的一条，返回 `{ element, update(ids, commands, context, label) }`，由各款放进外框。这条命令条放不下的钮不收进自己的「更多」，由外框列进面板头的「更多」菜单。
- 扩展登记的面板与内置面板一样进布局：能换栏、浮出、收起、隐藏，记在 `layout` 里。宿主写在 HTML 里的内容照样用 `slot="<区名>"` 交给外框，不必经扩展。

### 命令

工具栏与右键菜单的排列用 `commandLayout`：位置 → 若干组命令 id，内置命令与扩展命令混排。

```ts
import { arrangeCommands, createCommandToolbar, renderCommandMenu, runCommand } from "@xihan-ui/designer-shell";

const commands = extensionCommands(host.list); // designer-kit，内置在前
const toolbar = createCommandToolbar(document, { display: "icon" }); // 显示方式缺省 icon，快捷键的平台缺省当场测
toolbar.update(arrangeCommands("toolbar", commands, BUILTIN_TOOLBAR, designer.commandLayout), commands, context, t.toolbar);

renderCommandMenu(menuContent, arrangeCommands("menu:node", commands, BUILTIN_NODE_MENU, designer.commandLayout), commands, context, { platform });
menu.addEventListener("item-select", event => runCommand(commands, event.detail.value, context));
```

| 函数 | 说明 |
| --- | --- |
| `arrangeCommands(placement, commands, defaults, layout?)` | 排出某个位置的分组（工具栏、`menu:<主体>` 的右键菜单、`selection:<主体>` 的选中工具条都用它；菜单栏的 `menubar:<菜单 id>` 用下文的 `arrangeMenubar`）：`layout` 写了这个位置就只用它；否则用 `defaults`（各款内置的缺省排列），再把 `placement` 含这个位置、又不在缺省排列里的命令按登记顺序追加成一组。排列里写了没登记的命令 id 时抛 `TypeError` |
| `createCommandToolbar(document, { display?, orientation?, platform?, overflow? })` | 一条 `<xh-toolbar>`，外面包一个委托模式的 `<xh-tooltip>`（`element` 是这层外壳，布局上不占盒子；`toolbar` 是里面那条工具栏）。`update(groups, commands, context, label)` 分组变了才重铺按钮，文字、图标、`aria-label`、`aria-keyshortcuts`（快捷键按 `platform` 写成规范写法，如 `Control+Shift+Z`；命令有第二快捷键时依次接在后面，提示只显示第一条）、可用、`aria-pressed`（开关式命令）与 `data-tone`（命令的语气）每次刷新；点击按钮执行命令，放不下的收进行尾的「更多」菜单，图标与语气随按钮带进菜单项。`display` 是 `icon`（缺省）、`text` 或 `both`，没有图标的命令在 `icon` 档照样显示文字，写的是短文字（`shortLabel`，没给就是 `label`），名字仍是全名；`setDisplay(display)` 换显示方式不重铺按钮，「更多」里的条目按新的宽度重新收放。悬停或键盘聚焦到按钮时提示显示命令的文字与快捷键；文字看得见时只给带快捷键的按钮提示。`orientation` 缺省 `horizontal`；`vertical` 时按钮上下排，提示、弹层与菜单在按钮的行首一侧弹出（从左往右写时在左边），放不下的按高度收进「更多」。`overflow` 缺省 `menu`；写 `external` 时放不下的钮由调用方列在别处，自己的「更多」钮不占位、看不见（只留作弹层与菜单的锚点），收起的条目变了时外壳派发冒泡的 `overflow-change`；`overflowed()` 是此刻收起的命令 `{ id, label, disabled }`，`activate(id)` 照点按钮那样执行一条。弹层命令与菜单命令见下文 |
| `renderCommandMenu(content, groups, commands, context, { platform? })` | 把分组铺成 `<xh-context-menu>` content 里的条目，组间一条分隔线；不可用的写 `aria-disabled`，命令的语气写 `data-tone`，带图标的在文字前铺 `item-icon`，带快捷键的在行尾写提示（只写主快捷键）并写进 `aria-keyshortcuts`（第二快捷键接在后面）。没有 `run` 的弹层命令与菜单命令只能放在工具栏上，排进右键菜单时抛 `TypeError` |
| `runCommand(commands, id, context)` | 命令存在、有 `run` 且此刻可用才执行，返回是否执行了 |
| `commandLabel(command, context)` / `commandShortLabel(command, context)` / `commandEnabled(command, context)` | 命令的文字；短文字（没给 `shortLabel` 时同 `label`；短文字要出现在 `label` 里，开发期工具栏发现不在时报一条 `core.warn` 告警，一条命令只报一次）；此刻能否执行 |
| `designerHistoryCommands({ history, disabled?, translations, historyTranslations, confirm, announce?, icons? })` | 撤销、重做与清空历史，四款共用：`history` 返回设计器的历史栈（designer-kit 的 `History` 直接合用），返回 `undefined`、在事务中（如拖动）或 `disabled` 返回 true 时三条都不可用。`undo`（Mod+Z）与 `redo`（Mod+Shift+Z，另认 Ctrl+Y）缺省放在工具栏上，全名带上下一步的说明，如「撤销：添加卡片」，按钮上看得见的短文字仍是「撤销」；`clear-history` 是危险语气，不上工具栏，由历史面板或 `commandLayout` 放置，没有步骤时不可用，执行时先经 `confirm(context, { title, content, okText, tone })` 弹确认框（交给各款的 `DialogService.confirm`），确定后才清空。三条做完都经 `announce(context, message)` 礼貌级地读一句。文案取 `translations`（`xh-designer-commands` 那一桶的 `undo`、`redo`、`undoWith`、`redoWith`、`clearHistory`）与 `historyTranslations`（`xh-designer-history` 那一桶）。id 与各款原有的 `undo`、`redo` 相同，替换后宿主的 `commandLayout` 不用动 |
| `designerShellCommands({ translations, save?, frame?, openSettings?, helpUrl?, openFieldImport?, fieldImportDisabled?, openJsonEditor?, startTour?, settings?, icons? })` | designer-shell 内置的命令。给了 `save` 有 `save`（保存，`Mod+S`），通常派发各款的 `save` 事件，各款原来在画布上自己接的 Ctrl / ⌘ + S 改走这条命令。给了 `frame`（返回设计器的外框）有五条：`panels` 点开一张菜单，逐块面板一项（外框的 `panelEntries()`，勾选的显示，锁定的不可点，选中经 `togglePanel` 开关），分隔线后列出另外三条；`panels-toggle-all`（显示或隐藏所有面板，`Mod+\`）、`panels-restore`（还原面板显隐，`Mod+Shift+\`）与 `layout-reset`（恢复默认布局）只在菜单与快捷键上，全部面板都锁定时不可用；`pan-tool`（拖动，`H`）是开关式，按下状态跟着外框的 `canvasTool`，执行时在 `select` 与 `pan` 之间切换。给了 `openSettings(context, section?)` 有三条：`settings`（打开设置对话框，`Mod+,`）、`shortcuts`（翻到快捷键页）与 `about`（翻到关于页）。给了 `helpUrl` 有 `help`（在新窗口打开各款文档站的帮助页，返回 `undefined` 时不可用），给了 `openFieldImport` 有 `import-fields`（打开导入字段对话框，`fieldImportDisabled` 返回 true 时不可用），给了 `openJsonEditor` 有 `edit-json`（打开 JSON 编辑框，`Mod+Shift+J`；只读时也能打开，编辑框里只能看与复制；同时给了 `settings` 时只在开发者模式开着时存在，关着时菜单栏、工具栏、右键菜单都不列它，快捷键也不响应），给了 `startTour` 有 `start-tour`（开始新手引导）。给了 `settings: { read, change, policy? }` 有两条开关式命令 `show-toolbar`、`show-status-bar`：勾选读 `toolbar.visible`、`general.statusBar`，执行时把那一条取反经 `change(context, key, value)` 写回；`policy` 里藏起或锁定了那一条（或它所在的分区）时不可用；另有一条菜单命令 `appearance`（外观），点开就是这两条。菜单栏常开，没有它的开关。落点：`save`、`settings` 在「文件」菜单，`edit-json` 在「编辑」菜单，`panels`、`pan-tool` 与 `appearance` 在「视图」菜单（`panels`、`pan-tool` 同时上工具栏），两条显示开关只在「外观」子菜单里，`start-tour`、`help`、`shortcuts`、`about` 在「帮助」菜单；`import-fields` 不排位置，各款放进面板头或菜单。文案取 `xh-designer-commands` 那一桶，图标由各款从 `@xihan-ui/icons` 传入。各款把它们并进自己的内置扩展；宿主不要哪一条，用 `commandLayout` 排掉 |
| `designerPanelCommands({ panels, frame })` | 每块面板一条开关命令 `panel:<面板 id>`（`designerPanelCommandId(id)`）：文字取面板标题，`checked` 是它此刻看得见，执行即 `togglePanel`；不上工具栏、不带快捷键，给 `commandLayout`、引导与扩展用。外框里没有这块面板或宿主锁定了它时不可用。`panels` 列设计器登记的面板与画布浮动件，含扩展登记的 |

两个铺法用到的 `<xh-toolbar>`、`<xh-context-menu>`、`<xh-tooltip>`、`<xh-icon>`，以及弹层命令与菜单命令用到的 `<xh-popover>`、`<xh-menu>` 按需注册。快捷键的命中用 designer-kit 的 `commandForShortcut`。

命令工具栏上的弹层命令与菜单命令（命令的 `panel` 与 `menu`，见 designer-kit 那一页的「弹层命令与菜单命令」）：

- 点按钮打开锚在它上面的 `<xh-popover>` 或 `<xh-menu>`（用两个组件的 `anchor`），再点一下收起，点别的弹层或菜单命令是换过去。弹层的 content 与菜单都以命令文字命名；弹层里放 `panel` 建出的元素，开着时每次 `update` 调它的 `update`，收起后调 `destroy`；菜单每次 `update` 按上下文重算条目与勾选。
- 按钮收在「更多」里时，从「更多」菜单里点它，打开的是同一块面板或同一张菜单，锚在「更多」钮上。开着的时候工具栏变窄把它收进「更多」、或变宽又放回来，弹层与菜单跟着换锚点，不关掉。
- 锚点按钮上的 `aria-haspopup`、`aria-expanded`、`aria-controls` 由弹层与菜单在展开期间写上，收起后还原；Escape 或点在外面收起时焦点回到锚点。
- 重铺按钮（分组变了）、命令不在了或此刻不可用时，开着的弹层与菜单关掉。
- 菜单里写成命令 id 的开关式命令（有 `checked`）是复选条目，勾照那条命令此刻的状态，点了执行它。
- 菜单里点了复选条目、菜单仍开着时，按点过之后的上下文重算条目与勾选，不必等设计器下一次 `update`。
- `open(id)` 打开一条弹层命令或菜单命令，给快捷键用：命令的 `run` 里调它；不是这两类命令、不在工具栏上或此刻不可用时返回 `false`。`close()` 关掉开着的那一个，`opened()` 是开着的命令 id。

四款都有的通用命令，文案收在 designer-shell 语言包的 `xh-designer-commands` 一桶里：撤销、重做（以及带上下一步说明的 `undoWith`、`redoWith`）、清空历史、剪切、复制、粘贴、创建副本、删除、全选，六种对齐、两种分布、四种层级，放大、缩小、适合窗口、实际大小，设计与预览，内置命令的保存、面板、拖动、设置、快捷键、关于、帮助、导入字段、编辑 JSON、开始引导与工具栏、状态栏两条显示开关及收着它们的「外观」，命令工具栏的名字 `toolbar`，以及菜单栏的名字 `menubar`、窄屏那颗钮上的 `menu` 与四张菜单的名字 `menuFile`、`menuEdit`、`menuView`、`menuHelp`。各款引用这一桶，不再各写一份；产品专属的命令仍写在各款自己的语言包里。

```ts
import { DESIGNER_COMMANDS_EN_US, DESIGNER_HISTORY_EN_US } from "@xihan-ui/designer-shell";
import { XhConfigController } from "@xihan-ui/web-components/authoring";

const config = new XhConfigController(designer);
const t = config.translations("xh-designer-commands", DESIGNER_COMMANDS_EN_US); // t.undo、t.alignCenter……
const history = config.translations("xh-designer-history", DESIGNER_HISTORY_EN_US); // 给 designerHistoryCommands 的 historyTranslations
```

### 菜单栏

菜单栏放通用的功能：保存、设置、关于、帮助这类，按菜单分组，下面是工具栏。命令照样来自命令表，用 `placement` 的 `menubar:<菜单 id>` 或排列落进某张菜单。

```ts
import { arrangeMenubar, collectMenubarMenus, createCommandMenubar, designerMenubarLayout, designerMenubarMenus } from "@xihan-ui/designer-shell";

const menubar = createCommandMenubar(document, { menuIcon: MenuIcon }); // 快捷键提示的平台缺省当场测
menubar.element.slot = "menubar";
frame.append(menubar.element);

// 内置四张在前，扩展的在后；每轮更新时排一次、铺一次
const menus = collectMenubarMenus(designerMenubarMenus({ translations: () => t }), host.list);
menubar.update(arrangeMenubar(menus, commands, designerMenubarLayout(commands), designer.commandLayout), commands, context, t);
```

| 函数 | 说明 |
| --- | --- |
| `designerMenubarMenus({ translations })` | 内置的四张菜单 `{ id, label, order }`：文件 `file`（0）、编辑 `edit`（10）、视图 `view`（20）、帮助 `help`（90），名字取 `xh-designer-commands` 的 `menuFile`、`menuEdit`、`menuView`、`menuHelp`；id 也在 `DESIGNER_MENUBAR_MENUS` 里 |
| `designerMenubarLayout(commands)` | 外壳认得的命令在四张菜单里的缺省排列，只留命令表里有的，空组去掉：文件是保存 / 打开文件 / 设置；编辑是撤销、重做 / 剪切、复制、粘贴、删除、创建副本 / 全选 / 编辑 JSON；视图是设计、预览 / 放大、缩小、适合窗口、实际大小 / 拖动 / 面板、外观（工具栏、状态栏两条显示开关收在这个子菜单里）；帮助是开始引导、帮助、快捷键 / 关于 |
| `arrangeMenubar(menus, commands, defaults, layout?)` | 排出菜单栏，返回 `{ menu, groups }[]`：菜单按 `order` 排（不写按 50，同值按登记顺序）；每张菜单照 `arrangeCommands` 的规则，位置是 `menubar:<菜单 id>`：`layout` 写了就只用它（给空数组就不要这张菜单），否则用 `defaults` 里这张菜单的排列，`placement` 落在这张菜单、又不在排列里的命令按登记顺序成一组，排在缺省排列的最后一组之前（最后一组是收尾，如文件菜单的设置；缺省排列不到两组时接在后面）。没有命令的菜单不出现；排列里写了没登记的命令 id 时抛 `TypeError` |
| `collectMenubarMenus(builtins, sources)` | 合起内置菜单与各来源（`{ id, menubarMenus? }`，扩展的 `menubarMenus` 字段就是这个形状）登记的菜单，内置在前。菜单 id 与内置的或彼此重名时抛 `DesignerExtensionError`，名字空间 `menubar-menu` |
| `createCommandMenubar(document, { platform?, menuIcon? })` | 一条数据模式的 `<xh-menubar>`（`menubar`），外面一层外壳（`element`，`data-scope="designer-menubar"`）。`update(menus, commands, context, labels)` 按排好的菜单铺：每张菜单一个入口，条目带图标、快捷键提示（按 `platform` 写，如 `Ctrl+S`、`⌘S`）、读屏念的 `aria-keyshortcuts`（同样按平台写，含第二快捷键）与不可用，组与组之间一道分隔线；开关式命令是勾选条目，选中即收起；菜单命令（如「面板」「外观」）是子菜单，里面就是它原来那张菜单，勾选与单选照菜单命令此刻的状态（写成命令 id 的开关式命令同样带勾），勾选条目选中即收起；弹层命令不进菜单栏（菜单条目当不了弹层的锚点），开发期报一条 `core.warn` 告警。选中条目执行命令（此刻不可用的不执行），子菜单的条目交给菜单命令的 `select`。数据没变时不重设；换了数据时开着的菜单不收、焦点不丢。每次打开菜单（换到另一张、点开窄屏那颗钮也算）都用最近一次 `update` 交来的命令表与上下文，按此刻的状态重算勾选与可用：状态变了而设计器没有刷新菜单栏时（如外框变窄收起面板），打开的菜单里也是对的。`labels` 是 `{ menubar, menu }`：整条菜单栏的名字与窄屏那颗钮上的字，直接交 `xh-designer-commands` 那一桶 |

- 条目的名字用命令的 `label`，不用 `shortLabel`。
- 菜单栏不进用户定制：设置的「工具栏」页只管顶部工具栏，菜单栏只由宿主的 `commandLayout` 改。菜单栏常开，设置里没有关掉它的开关。
- 窄屏（外框写着 `data-narrow`）时这一条收起，换成一颗「菜单」钮（`menu-trigger` 部件，文字取 `labels.menu`，给了 `menuIcon` 在前面铺图标），点开一张 `<xh-menu>`，每张菜单是它的一个子菜单，开关式命令的勾照样跟着状态，选中照样执行。
- 键盘照 `<xh-menubar>`：整条一个 Tab 位，左右键在菜单之间走，下键、Enter、Space 展开，展开后左右键换到相邻的菜单，Esc 收起回到入口；F10 进出菜单栏由外框接，见「外框」的「菜单栏」。

宿主自己的操作（新建、预览、打印、返回列表）写成一个扩展的命令，`placement` 写 `menubar:file` 就进「文件」菜单，常用的同时写 `toolbar` 上工具栏；保存用内置的 `save`，照旧接各款的 `save` 事件；模板标题写成设计器元素的子元素 `slot="menubar-center"`，保存状态写成 `slot="status-end"`，其他要常显的写 `slot="menubar-end"`，各款经 `forwardFrameSlots` 转进外框；状态栏在设置里关掉时，宿主放在状态栏里的内容跟着不显示。这样设计器外面不必另起一条操作条。

```ts
designer.extensions = [...presets, {
  id: "acme.host",
  commands: [
    { id: "acme.new", label: "新建", shortcut: ["Mod", "Alt", "N"], placement: ["menubar:file"], run: () => createTemplate() },
    { id: "acme.preview", label: "预览", placement: ["menubar:file", "toolbar"], run: () => preview() },
  ],
}];
```

没写进排列的宿主命令在「文件」菜单里另起一组，排在收尾的设置之前；要定位置时写 `commandLayout["menubar:file"]` 的整份排列。某张菜单不要时把它的排列写成空数组。

扩展的 `menubarMenus` 往菜单栏上加菜单 `{ id, label, order? }`，如打印的「插入」「页面」、报表的「数据」，`order` 写在 30–80 之间；命令经 `placement` 的 `menubar:<菜单 id>` 落进去，命令落到哪张菜单只看 `placement` 与排列，不看菜单由谁登记。

### 设置分区

扩展的 `settings` 是若干设置分区，形状见上文「设置」。用 `collectSettingsSections` 把各款自己的分区与扩展的合起来交给设置对话框；分区 id 与内建的 `general`、`layout`、`toolbar` 或别的扩展重名时抛 `DesignerExtensionError`（名字空间 `settings-section`）。

### 引导步骤

扩展的 `tourSteps` 是若干引导步骤，形状见上文「新手引导」，`order` 从 200 起，排在各款的步骤之后。用 `collectTourSteps` 把各款自己的步骤与扩展的合起来，按上下文用 `resolveTourSteps` 取好交给 `<xh-designer-tour>`；步骤 id 重名或用了 `shell.` 前缀时抛 `DesignerExtensionError`（名字空间 `tour-step`）。

### 关于

扩展的 `about` 是「关于」页里的几行 `{ label, value }`，如附加包的版本与授权；各款把它们接在自己那几行后面，交给设置对话框的 `about.rows`。

### 物料来源

扩展的 `paletteSources` 往物料面板里加几组，排在各款内置分组之后，如专业版的「我的物料」、行业模板。开源版不登记，就没有这几组。

```ts
import { collectPaletteSources, resolvePaletteSources } from "@xihan-ui/designer-shell";

const sources = collectPaletteSources([builtinExtension, ...extensions]); // id 重名时抛 DesignerExtensionError
const view = resolvePaletteSources(BUILTIN_GROUPS, sources, context); // 每轮刷新现取
palette.groups = view.groups;
palette.addEventListener("item-activate", (event) => {
  if (!view.insert(event.detail.id, context))
    insertKind(event.detail.payload); // 内置的物料照旧
});
// 画布放下时一样先问来源：view.insert(payload.id, context, 落点)
```

| 字段 | 说明 |
| --- | --- |
| `id` | 全局唯一，写法同命令 id；名字空间 `palette-source` |
| `order` | 先后：各款内置的分组在前，来源按它排在后面，不写按 100；同值按登记顺序 |
| `groups(context)` | 这一来源此刻的分组，形状同物料面板的 `groups`；组 id 与条目 id（含下级）在整个物料面板里唯一 |
| `insert(item, context, at?)` | 这一来源的条目被激活或拖到画布上放下：`at` 是放下处，激活时没有。插入怎么做由来源自己定，通常调设计器的 `paste(clip, { at })` |

| 函数 | 说明 |
| --- | --- |
| `collectPaletteSources(sources)` | 合起各来源（`{ id, paletteSources? }`，扩展本身就是这个形状）的物料来源，按 `order` 排；id 重名时抛 `DesignerExtensionError`，名字空间 `palette-source` |
| `resolvePaletteSources(builtin, sources, context)` | 返回 `{ groups, insert(itemId, context, at?) }`：`groups` 是内置分组在前、各来源按上下文现取的接在后面；`insert` 在条目（含下级）属于某个来源时交给它的 `insert` 并返回 `true`，内置条目返回 `false`。组 id 或条目 id 重名时抛 `TypeError` |

落点 `at` 的形状由各款定（流程是画布坐标，打印还带页与带区），在扩展类型上写：`DesignerShellExtension<Ctx, Editor, Doc, RuleCtx, At>` 的最后一个类型参数。

### 导入来源

`importSources(data, kind)` 把粘贴事件的 `clipboardData` 或拖放事件的 `dataTransfer` 列成导入来源：先是每个文件（带类型与文件名），再按链接、HTML、纯文本的顺序列出不为空的文本。交给 designer-kit 的 `findImporter`，挑出第一个接下的导入器。

```ts
import { findImporter } from "@xihan-ui/designer-kit";
import { importSources } from "@xihan-ui/designer-shell";

canvas.addEventListener("drop", (event) => {
  const found = findImporter(model.importers, importSources(event.dataTransfer!, "drop"));
  if (!found)
    return;
  event.preventDefault();
  void found.importer.import(found.source, context).then(place);
});
```
