跳转到内容

设计器 ​

<xh-print-designer> 是可视化的模板设计器:画布按纸张显示一张页面模板的页眉、正文与页脚,旁边是物料、属性与问题列表。它只吃模板与数据、吐模板与事件,模板存在哪里、谁能改,都由宿主决定。属性、方法与事件的完整列表见设计器参考。

接入 ​

bash
pnpm add @xihan-print/designer @xihan-print/runtime @xihan-ui/styles @xihan-ui/designer-shell

设计器用到 XiHan.UI 的设计器零件,样式要四份一起引入。definePrintDesigner() 注册设计器、它内部用到的预览与 UI 元素,重复调用不报错:

ts
import type { PrintDesignerEventMap } from '@xihan-print/designer'
import type { DataField } from '@xihan-ui/designer-kit'
import { definePrintDesigner } from '@xihan-print/designer'
import { data, schema } from '../templates/delivery'
import '@xihan-ui/styles/index.css'
import '@xihan-ui/designer-shell/style.css'
import '@xihan-print/runtime/style.css'
import '@xihan-print/designer/style.css'

definePrintDesigner()

把模板交给 schema,可绑定的字段树交给 fields:属性面板里选字段时从这棵树里选,物料面板也多出「字段」一组,字段可以直接拖上画布(见下文「字段物料」):

ts
const fields: DataField[] = [
  { name: 'customer', label: '客户', kind: 'object', children: [{ name: 'name', label: '名称', kind: 'value' }] },
  { name: 'date', label: '日期', kind: 'value' },
  {
    name: 'items',
    label: '明细',
    kind: 'list',
    children: [
      { name: 'name', label: '品名', kind: 'value' },
      { name: 'qty', label: '数量', kind: 'value' },
      { name: 'price', label: '单价', kind: 'value' },
      { name: 'amount', label: '金额', kind: 'value' },
    ],
  },
]
ts
const designer = document.createElement('xh-print-designer')
designer.schema = schema
designer.fields = fields
designer.sampleData = data
designer.data = data
  • sampleData 是画布上显示用的示例数据,给了就按它显示实际值,不给时字段显示成 {path} 占位。
  • data 是切到预览态与打印时用的数据。

界面 ​

外框从上到下是菜单栏、工具栏、中间三栏(左边物料与页面结构、中间画布、右边属性)、下方的问题列表与最底下的状态栏。各块面板停在哪一栏、显不显示、收没收起、浮没浮出,记在一份面板布局里,见下文「面板布局」;预览态只留菜单栏、工具栏与画布。

部分位置面板 id作用
菜单栏最上面—文件、编辑、视图、插入、页面、帮助几张菜单,见下文「菜单栏」;正中放宿主的模板标题,右边一段放宿主别的常显内容。常开
工具栏菜单栏下面—撤销重做;剪切、复制、粘贴、创建副本、删除;对齐(左、水平居中、右、顶端、垂直居中、底端)、分布(水平、垂直)与层级(置顶、上移一层、下移一层、置底),每条一颗图标钮;拖动工具、缩放、网格与吸附开关;设计与预览、页面设置、打印;末组是面板,设置与帮助在菜单栏里
物料左栏palette按分组列出元素:每组是组名一行,下面是元素的方形小卡片,种类的图标在上、名称在下,按面板宽度自动排成几列;悬停或用键盘走到一张卡片时提示它的名称。点一下把元素加到视口中央,拖到画布上放在落点。「字段」一组按字段树一行一个地显示,见下文「字段物料」。扩展的物料来源另加的几组排在后面,见下文「扩展」
页面结构左栏,物料下面outline按页面模板列出页眉、正文与页脚里的元素,同一处最上层的排最前;点一项选中它,在同一处里调层级,见下文「页面结构」
画布中间—按纸张显示当前页面模板的页眉、正文与页脚,区的标签上切换页眉页脚的变体
标尺画布上边与左边—毫米标尺;从标尺拖出参考线,参考线存进页面模板,只用于对位、不打印。设置里可以关掉
翻页条画布右侧中间pages画布上的浮动件,只能显示或隐藏。上一页、页码、下一页,新增、复制、删除页面模板。页码钮上写「第几个 / 共几个」,点它列出全部页面模板,点一项切过去,下面是上移、下移页面
小地图画布左下角minimap画布上的浮动件,只能显示或隐藏。按页显示:当前页面模板的纸一块淡色,当前显示的页眉页脚变体与正文里的元素各一块,框出画布此刻看到的范围;在上面按下或拖动,视口中心移到那里。键盘上,画布没有选中元素时按方向键平移视口
选中工具条选中框上方—选中元素时浮出:层级四颗钮、创建副本、复制与删除;文字类元素多出字号(钮上写当前字号)、文字样式与左中右对齐,多选多出对齐六颗与分布两颗,见「画布操作」
尺寸徽标选中框下方—选中元素时常驻宽 × 高(mm),单选时可以直接改,见「画布操作」
属性右栏properties编辑选中的元素,多选时只显示共同的属性;没有选中时编辑页面设置;表格编辑态里编辑选中的表格部位
历史记录右栏,属性下面history缺省隐藏,从「面板」菜单打开。按先后列出撤销历史的每一步,当前位置那一行选中,选一行就跳到那一步;已撤销的一步字色减淡。面板头是「清空历史」,先确认再清空。只读与预览态时只能看
问题下方issues校验问题列表,点一下切到问题所在的页面模板并定位到元素;错误、警告数在面板头上,点一下只看或不看这一级
状态栏最下面—左边写选中的对象:没有选中时是当前页面模板与上面的元素个数,选中一个是种类与位置尺寸(mm),选中多个是个数;右边写打印客户端的连接状态(给了 printClient 时)与缩放,点缩放回到实际尺寸。设置里可以关掉

物料、页面结构、属性、历史记录与问题是外框里的卡片:面板头写图标与标题,属性面板的标题下面写正在编辑的对象,「?」里是这块面板的用法。

面板布局 ​

  • 面板头上的「×」隐藏这块面板,收起钮(或双击面板头)收成只剩标题条,「更多」菜单里换到别的栏、调先后、浮出成小窗或停靠回去。按住面板头拖:拖到外框边上换栏,拖到两块面板之间调先后,拖到画布上浮出成小窗;小窗不出外框。
  • 工具栏的「面板」菜单列出物料、属性、问题、翻页条、小地图、页面结构与历史记录,勾选的显示;末尾是「显示 / 隐藏所有面板」(Ctrl / ⌘ + \)、「还原面板显隐」(Ctrl / ⌘ + Shift + \,位置与大小不动)与「恢复默认布局」。
  • 布局一改,设计器就把整份布局写进设置的 layout.panels(DESIGNER_SETTING_KEYS.layout),派发 settings-change,宿主存起来、下次随 settings 赋回即可;给了 settingsStorageKey 时设计器自己存。拖分隔条只在松手时写一次。
  • 宿主用 defaultLayout 给缺省布局:用户没存过布局时用它,「恢复默认布局」也回到它。用 settingsPolicy 管控:layout.panels 锁定整份布局,layout.panels.<面板 id> 写 hidden 就没有这块面板、写 locked 就固定这块面板。面板 id 见包入口导出的 PRINT_DESIGNER_PANELS。
ts
export function wireLayout(designer: XhPrintDesigner): void {
  // 缺省收起问题列表;宿主的业务里用不到翻页条,也不许用户把它开出来
  designer.defaultLayout = { version: 1, hidden: [PRINT_DESIGNER_PANELS.issues] }
  designer.settingsPolicy = { [`${DESIGNER_SETTING_KEYS.layout}.${PRINT_DESIGNER_PANELS.pages}`]: 'hidden' }
}

窗口窄时左右两栏收起,从「面板」菜单打开时以抽屉盖在画布上;浮出的面板在窄屏时按停靠位置收进抽屉。

工具栏缺省只显示图标,设置里可以换成文字或图标加文字;翻页条与选中工具条只显示图标。没有图标的按钮写一段短文字,比如页码钮写「1 / 3」、字号钮写当前字号,按钮的名字里含着这段字。按钮的名字与快捷键报给读屏,悬停或键盘聚焦到按钮时提示命令的文字与快捷键;会删掉内容的命令按危险色显示。工具栏放不下时,末尾的按钮收进「更多」。

有几条命令点开的不是一个动作,而是锚在按钮上的弹层或菜单:

  • 「页面设置」是弹层,改当前页面模板的纸张、方向与页边距,与属性面板里「纸张」一组是同一批属性,改动同样记撤销。
  • 对齐、分布与层级每条一颗钮,一字排开,工具栏或选中工具条放不下时多的收进它们各自的「更多」。翻页条上的页码与选中工具条上的字号是菜单。

扩展登记的面板也放进外框,同样记在面板布局里,见下文「面板」。各区的属性见元素,属性面板的分组与中文名与它一致。

菜单栏 ​

菜单栏在外框最上面,菜单按命令表排:

菜单条目
文件保存;导入字段;打印;设置
编辑撤销、重做;剪切、复制、粘贴、删除、创建副本;全选;编辑 JSON(开发者模式开着时)
视图设计、预览;放大、缩小、适合窗口、实际大小;拖动;网格、吸附;面板(子菜单,就是工具栏上「面板」那一张)、外观(子菜单:显示工具栏与状态栏)
插入各种元素,按物料分组各成一组,扩展的元素排在它的分组里;页眉、页脚(子菜单,见下)
页面纸张、方向(子菜单,单选)、页面设置;上一页、下一页;新增、复制、删除页面;上移、下移页面
帮助开始引导、帮助、快捷键;关于
  • 条目写图标、名称与快捷键,开关式的命令(设计与预览、拖动、显示开关)带勾,此刻不可用的灰掉;没有条目的菜单不出现。预览态照样有菜单栏,从「视图」切回设计。
  • 插入菜单里选一种元素,与点物料一样放在视口中央并选中。页眉、页脚的子菜单与画布上区标签的变体菜单做同一件事:上半是已有的变体,单选,当前显示的打勾;下半是还没有的变体,写「新增变体 · 首页」这样,选了新增并切过去;显示的不是常规时,末尾是「删除变体」。
  • 页面菜单的纸张列出各种纸张预设,方向是纵向与横向,当前页面模板的打勾,选中写进当前页面模板、记一步撤销;自定义纸张与页边距在「页面设置」里改:它取消选择,在属性面板里编辑当前页面模板,属性面板藏着时把它显示出来。只读与预览态时插入菜单与纸张、方向不可用,只读时「页面设置」照样能看。
  • 「快捷键」「关于」打开设置对话框的那一页。「视图 → 外观」的两条显示开关改的是设置(toolbar.visible、general.statusBar),和在对话框里改一样派发 settings-change;settingsPolicy 藏起或锁定了那一条时它不可用。
  • 键盘:焦点在设计器里时按 F10 进菜单栏,焦点落到第一张菜单上;左右键换菜单,下、Enter、Space 展开,Esc 收起;再按 F10 或 Esc 回到进来前的地方。不用 Alt 与 Alt + 字母,它们留给浏览器。
  • 窗口窄时整条收成一颗「菜单」钮,点开后每张菜单是一个子菜单。
  • 菜单栏常开,设置里没有关掉它的开关。
  • 宿主的内容写成设计器元素的子元素,按 slot 放进外框,后来加的也一样:
    • 模板标题写 slot="menubar-center",按整条菜单栏居中,放不下截断、再窄就藏起,窄屏收成「菜单」钮时同样居中。
    • 保存状态写 slot="status-end",在状态栏右端、打印自己的状态项(打印客户端连接状态、缩放比例)前面;设置里关掉状态栏时跟着不显示。
    • 别的要常显的写 slot="menubar-end",在菜单栏右边一段。
    • 上面的示例把标题与保存状态放在这两处,见下文「保存与事件」。

宿主的命令写成一个扩展的命令,placement 写 menubar:<菜单 id> 落进那张菜单(file、edit、view、insert、page、help;打印的两张菜单的 id 是导出的 PRINT_MENUBAR_MENUS),不在缺省排列里的自成一组,排在那张菜单收尾的一组之前,只有一组的菜单接在后面。要定位置时用 commandLayout['menubar:<菜单 id>'] 写整份排列,给空数组就不要这张菜单:

ts
// 宿主自己的「新建」「返回列表」挂进文件菜单,新建同时上工具栏;再把文件菜单排成新建与保存、返回列表、设置三组
export function hostCommands(designer: XhPrintDesigner, actions: { readonly create: () => void, readonly back: () => void }): void {
  designer.extensions = [{
    id: '@acme/host',
    commands: [
      { id: '@acme/host.new', label: '新建', placement: ['menubar:file', 'toolbar'], run: actions.create },
      { id: '@acme/host.back', label: '返回列表', placement: ['menubar:file'], run: actions.back },
    ],
  }]
  designer.commandLayout = { 'menubar:file': [['@acme/host.new', 'save'], ['@acme/host.back'], ['settings']] }
}

页面结构 ​

页面结构按页面模板分组,每个页面模板下分页眉、正文与页脚;页眉页脚加了首页、末页、奇偶页这些变体时,再按变体分。页眉、页脚里一个元素都没有时不列。同一处的元素按层级排,最上层的排最前;元素有名称时写名称、副文本是种类,没有名称时写「种类 · id」,行首是种类的图标。

  • 点一项选中元素:它不在当前页面模板或当前显示的页眉页脚变体上时,先切过去再选中,并滚进可见区。悬停或用方向键走到一项时,画布上标出它。
  • 能多选,与画布上的选择是同一份:按住 Ctrl(macOS 为 ⌘)点一项或按 Ctrl / ⌘ + Space 加选、取消,按住 Shift 点一项从上一次点的那项选到这一项,分组行不算。新选上的第一个滚进可见区,只取消几项时画布不动。选择只留当前页面模板与当前页眉页脚变体里的元素:加选别处的元素时切过去,原来的选择随之换掉。
  • 拖动一项,或按 Alt 加上下键,在同一处里调层级,记一步撤销;要把元素从页眉挪到正文、或挪到别的页面模板,仍在画布上拖。锁定的元素不能挪、也不能从这里删。
  • Delete 删除焦点所在的一项,焦点在选中的项上时删全部选中的,记一步撤销。
  • 搜索框按名称与种类过滤。只读与预览态时只能选、不能删与挪。

字段物料 ​

给了 fields 时,物料面板最后多出「字段」一组,按字段树显示:带子字段的可以展开、收起,键盘与读屏按树的约定,搜索也能按字段名称与路径找。点一下把绑好字段的元素加到视口中央,拖到画布上放在落点:

字段生成
value,content 不写或为 text文本,内容绑定这个字段
value,content 为 image图片,地址取这个字段
value,content 为 barcode条码(缺省码制),内容绑定这个字段
value,content 为 qrcode二维码,内容绑定这个字段
list表格:dataField 指向它,各列取它的 value 子字段(进入对象、不进入嵌套的列表),列标题取字段名称,列宽均分表格宽度;content 为图片、条码、二维码的子字段对应同类单元格
object不生成元素,只作分组
  • 字段写了 label 时,新元素的名称就是它;条目上同时显示字段路径。
  • 列表的子字段相对数组的一行,只作浏览、不能拖出:要放行里的字段,先放表格,再在属性面板里改列。
  • content 是 XiHan.UI 字段树 DataField 的可选项,说明这个值代表什么;type 是数据类型,与生成哪种元素无关。

导入字段 ​

物料面板头的「导入字段」(也在文件菜单里)打开导入对话框,从一份 JSON 样例或一份 JSON Schema 生成字段树,不用宿主手写 fields:

  • 「JSON 样例」「JSON Schema」两页各有一个输入框,粘贴进来,或把 .json 文件拖到输入框下面、点它选一个,文件在本机读,不发请求。解析失败时写出第几行第几列。
  • 预览表一个字段一行,可以勾掉不要的、改显示名与类型;值字段的「内容」选文本、图片、条码或二维码,决定拖上画布生成哪种元素(见上表)。从样例推断时,图片地址(data:image/… 或以图片扩展名结尾的链接)预先标成图片。
  • 已有字段时可选「合并到现有字段」(缺省)或「替换现有字段」:合并时同名字段保留现有的显示名,种类不同的保留现有的并在表下列出;替换可能让模板里已有的绑定找不到字段,问题列表会报出来。
  • 来自样例时可以勾上「同时作为示例数据」(缺省勾上),画布马上按这份样例显示实际值。
  • 按「导入」后设计器换上新的 fields 并派发 fields-change { fields };带了示例数据的同样换上 sampleData 并派发 sample-data-change { sampleData }。字段与示例数据仍由宿主持有,不写进模板:宿主存起来,下次赋回。不记撤销。
  • 字段来自后端元数据、不许用户改时写 fields-readonly:面板头没有这颗钮,命令 import-fields 不可用。只读与预览态时同样没有。
  • openFieldImport(source?) 从别处打开对话框,source 是 sample、json-schema 或扩展字段来源的 id。
ts
export function wireFields(designer: XhPrintDesigner, store: FieldStore, fromBackend: boolean): void {
  designer.fields = store.fields
  designer.sampleData = store.sample
  // 字段来自后端元数据时不许在设计器里改
  designer.fieldsReadonly = fromBackend
  designer.addEventListener('fields-change', (event) => {
    store.save({ fields: (event as CustomEvent<{ fields: readonly DataField[] }>).detail.fields })
  })
  designer.addEventListener('sample-data-change', (event) => {
    store.save({ sample: (event as CustomEvent<{ sampleData: JsonValue }>).detail.sampleData })
  })
}

页码的格式是一行文本,行尾的「插入」菜单把当前页码 {{$page}} 或总页数 {{$pages}} 插到光标处,下面按第 1 页、共 3 页显示示例,数字写法跟着「数字写法」一栏。

边框分上、右、下、左四个页签,每边一个开关:打开时这一边照已有的边画线,还没有边时是 0.2 毫米黑色实线,下面可以改线宽、线型与颜色;四边都关掉就是没有边框。多选的元素边框不同时,从四边都不画开始改,改动对每个元素整体写入。

值格式先选种类:常用格式(数字、百分比、日期、人民币大写、数字字形)在下面填参数并显示示例;值映射是 XiHan.UI 设计器零件内建的选项表(options,不带禁用列),「数据值、显示文本」两列,可以新增、删除与上移下移,下面填没有命中时显示的文本;表达式以 value 引用待格式化的值,边输入边检查。换种类时整体换成这一种的缺省值,选「不设格式」即去掉格式。

画布操作 ​

  • 选择:点选元素;按住 Shift、Ctrl 或 ⌘ 点击切换某个元素的选中;在空白处拖动框选。
  • 移动与变换:拖动元素移动,拖控制柄缩放;文本、图片、矩形、椭圆、条码与二维码还能旋转。锁定的元素不能拖动与缩放。
  • 选中工具条:选中元素时,选中框上方浮着一条工具条:都有层级(置顶、上移一层、下移一层、置底)、创建副本、复制与删除;选中的全是文本、长文本、富文本或页码这类带文字样式的元素时,前面多出字号、粗体、斜体、下划线、删除线与左中右对齐,对选中的每个元素写同一个值,记一步撤销;选了几个元素、里面有别的种类时,多出对齐与分布。放不下的钮收进工具条自己的「更多」。贴顶放不下时翻到下方,拖动中与只读时收起。在画布上按 Alt+F10 把焦点交给它,在工具条里按 Esc 回到画布。排列怎么改见下文「命令」。
  • 尺寸徽标:选中元素时,选中框下方常驻宽 × 高(mm)。单选、元素能缩放时是两个数字框:点加减钮或按上下方向键改 1 mm,Shift 加上下方向键改 10 mm,连续改并成一步撤销;输入数字后按 Enter 或离开数字框才改,单独一步撤销,按 Esc 放弃。改的时候左上角不动,不小于这种元素的最小尺寸,二维码改一边另一边跟着变。锁定的元素、多选与只读时只显示尺寸。在选中工具条里按 Tab 进到徽标。
  • 换区:元素中心拖进页眉、正文或页脚,就放进那个区;表格、长文本与富文本只能留在正文区。
  • 页眉页脚:在区的标签上切换当前编辑的变体(缺省、首页、末页、奇数页、偶数页),可以新增或删除变体;拖动区的边界改高度。
  • 平移与缩放:设计器打开、换页面模板或重新装载模板时按实际大小显示(缩放 100%),纸的左上角落在画布左上角,上边与左边留出边距,左边让出页眉页脚标签;画布改尺寸时不重排。工具栏的「适合窗口」(zoom-to-fit,键 0)把整张纸缩放到画布里,此后画布改尺寸时跟着重新适配,直到平移、缩放或在画布上操作。滚轮平移,Ctrl / ⌘ + 滚轮以指针为中心缩放;按住空格或鼠标中键拖动平移。工具栏的「拖动」(手形,键 H)开着时,在画布上哪里按住拖动都是平移,按在元素、控制柄与表格把手上也一样:不选中、不挪元素,只点一下不取消选择,双击不进表格编辑;选中工具条、尺寸徽标与页眉页脚变体菜单照常能点。拖动途中按 Esc 只取消这次拖动,视口回到原处;不在拖动时按 Esc 关上拖动工具。
  • 吸附:拖动、新建与缩放元素时吸附到网格间距(设置「打印」一页,缺省 1 mm,写 0 不吸附网格);「吸附对齐」打开时还吸附其它元素、页边距、区边界与参考线,并显示对齐线。工具栏上的「网格」开关显示或收起网格线,「吸附」开关就是「吸附对齐」,两者改的都是设置。
  • 表格编辑态:双击表格或选中后按 Enter 进入。可以分别选中列、表头、表体、表尾行与表尾单元格,在属性面板里编辑;拖列边界改列宽;右键菜单里插入、删除、左右移动列,插入、删除表尾行,合并与拆分表尾单元格。按 Esc 退出。
  • 右键菜单:在空白处、元素上与表格编辑态里各有一套,带快捷键的条目在行尾显示快捷键。设计器先派发可取消的 blank-contextmenu、element-contextmenu 或 table-contextmenu(表格编辑态里,右键处的部位先被选中),宿主取消后不弹自带的菜单,可以换成自己的。

设置 ​

设置是用户偏好,每人每机一份,不随模板保存。openSettings(section?) 打开设置对话框,左边一个分区一页:

  • 「基础」(general):主题(跟随页面、跟随系统、亮色、暗色)、主题色(跟随页面、八种预设与自定义颜色)、状态栏开关、自动保存、开发者模式与分页、渲染两条调试日志。开发者模式缺省关,关着时编辑 JSON 这类调试查看的入口都不出现,两条调试日志也不能开,见下文「调试日志」。
  • 「语言」(language):设计器的界面语言,缺省跟随页面;也可以跟随系统,或选 XiHan.UI 的十种语言之一。只作用于这台设计器:设计器自己、外壳零件与里面的元素(含弹出的菜单、对话框这些浮层)一起换,页面别处不变;跟随系统按浏览器的语言偏好挑最接近的一种,挑不到用英文。语言包选了才按需加载。键是 language.locale(DESIGNER_SETTING_KEYS.language)。
  • 「布局」(layout):逐块面板一行,改显示、停在哪一栏、先后与收起,表下「恢复默认布局」;改的就是上文「面板布局」那一份。
  • 「工具栏」(toolbar):按钮的显示方式(图标、文字、图标和文字,缺省只显示图标)、显示工具栏,以及按钮表:藏起不用的按钮、换先后与分组,「恢复默认」回到宿主排好的样子。显示方式与显示工具栏只走设置,没有单独的属性;画布上的翻页条总是只显示图标。按钮表从宿主用 commandLayout 排好的工具栏起步,只管顶部工具栏,菜单栏、选中工具条与右键菜单不在里面。
  • 「打印」(print):打印方式、显示网格、网格间距、吸附对齐、显示边距线、标尺。打印方式只在给了 printClient 时才有,选本地客户端(缺省)还是浏览器:本地客户端直接交给打印机,连不上时退回浏览器的打印对话框;选浏览器时 print() 总是弹出浏览器的打印对话框。网格缺省不显示,间距缺省 1 mm,按当前缩放挤得太密时改按间距的整数倍画;边距线与标尺缺省显示。
  • 「本地客户端」(printClient):给了 printClient 才有这一页,写连接状态:已连接、未连接或未安装。「测试连接」调宿主给的 printClient 的 connect() 连一次;没找到客户端(CLIENT_UNAVAILABLE)时写未安装,这时也可能是装了没有运行。没连上时下面有「下载客户端」链接,缺省指向本站的本地打印客户端一页(PRINT_CLIENT_DOWNLOAD_URL),宿主用 clientDownloadUrl(attribute client-download-url)换成自己的地址。端口与来源授权仍由宿主建连接时与客户端管,设计器不自己建连接。
  • 扩展登记的分区,排在后面。分区 id 与 general、language、layout、toolbar、help、shortcuts、about、print、printClient 或别的扩展重名时,给 extensions 赋值就抛 DesignerExtensionError。
  • 「帮助」(help):几段操作说明,下面是「开始引导」与「帮助」两颗钮,按了先关上对话框再开始引导或打开本站的设计器一页。
  • 「快捷键」(shortcuts):按命令表列出全部快捷键,分通用、编辑、打印设计器与扩展几组,第二快捷键写在同一行;打印设计器那组末尾补上不是命令的 Ctrl / ⌘ + C、X、V 复制剪切粘贴,Backspace 删除与 Enter 编辑表格;保存是外壳的命令,列在「通用」里;「通用」里方向键一行写明没有选中时平移画布,Esc 一行写明取消拖动、退出表格编辑或取消选择,其余照 designer-shell 的通用行(含 F10 进菜单栏)。
  • 「关于」(about):打印设计器的版本(包入口导出的 PRINT_VERSION,与包版本一致)与许可证,后面接扩展给的几行。
ts
export function openCanvasSettings(designer: XhPrintDesigner): void {
  designer.openSettings(PRINT_SETTINGS_SECTION)
}

值是扁平的键值,键是「分区 id.条目 id」,如 general.theme、print.showGrid,没写的键取缺省值。宿主给缺省、锁定条目或读值时用常量,不手写键:「打印」分区的 id 与各条目的键是包入口导出的 PRINT_SETTINGS_SECTION 与 PRINT_SETTING_KEYS,「本地客户端」分区的 id 是 PRINT_CLIENT_SETTINGS_SECTION,「基础」「布局」「工具栏」三页的键是 @xihan-ui/designer-shell 的 DESIGNER_SETTING_KEYS(如工具栏的显示方式 toolbar.display、显示工具栏 toolbar.visible、按钮表 toolbar.layout)。settings 赋值即生效;用户在对话框里、用「视图 → 外观」的显示开关或工具栏的网格、吸附开关改了一条,设计器换成新的一份并派发 settings-change,宿主存起来、下次赋回。settingsPolicy 隐藏或锁定条目,键是条目键或分区 id:hidden 不显示,一个分区的条目全藏了就没有这一页;locked 只读,说明换成「由所在系统管理」。

ts
export function wireSettings(designer: XhPrintDesigner, saved: PrintSettings, store: (settings: PrintSettings) => void): void {
  // 宿主自己的缺省压在下面,用户存过的值盖在上面
  designer.settings = { [PRINT_SETTING_KEYS.gridSize]: 5, [PRINT_SETTING_KEYS.showGrid]: true, ...saved }
  designer.settingsPolicy = { [DESIGNER_SETTING_KEYS.theme]: 'hidden', [DESIGNER_SETTING_KEYS.brand]: 'hidden', [PRINT_SETTING_KEYS.snap]: 'locked' }
  designer.addEventListener('settings-change', (event) => {
    store((event as CustomEvent<{ settings: PrintSettings }>).detail.settings)
  })
}
  • 主题只作用于设计器:主题与主题色写在 <xh-print-designer> 自己的 data-theme、data-brand 上,页面其余部分不变;设置对话框、菜单这些浮层跟着设计器变。主题色内置:跟随页面(缺省,不写 data-brand)、XiHan.UI 的八种预设,或自定义一种颜色,宿主不用传预设;自定义的颜色注册成这台设计器自己的品牌,取不出色阶的颜色不生效。
  • 扩展读设置:命令、面板与扩展从上下文的 settings 读值,各分区没写的条目已经取了缺省值,如 context.settings['print.gridSize']。

独立使用、没有宿主替你存时,给 settingsStorageKey:连上文档时若没赋 settings 就从根所在窗口的 localStorage 读,用户改了设置就写回去;标了 secret 的文本条目(如密钥)不存。读写失败派发 settings-storage-error,设置留在内存里。不给就不读写存储。

ts
export function standalone(designer: XhPrintDesigner): void {
  designer.settingsStorageKey = 'print-designer:settings'
  designer.addEventListener('settings-storage-error', (event) => {
    console.warn('设置没能读写本机存储,这次改的只在内存里', (event as CustomEvent<{ error: unknown }>).detail.error)
  })
}

调试日志 ​

开发者模式开着时,「基础」里能打开分页调试日志(general.debugPagination)与渲染调试日志(general.debugRender),缺省都关。打开后设计器把记录写到浏览器的控制台,每条折叠成一组,标题以 [xh-print-designer] pagination 或 render 开头;只写控制台,不上报。关掉开发者模式,两条日志随即停。

  • 分页:切到预览或打印时,每个页面模板开始排、按假定的页数选的页眉页脚变体不对而重排、表格这类跨页元素在哪页放不下续排到哪页、元素在哪页放不下整块挪到哪页、排完共几页,组里是这一步的数据。
  • 渲染:设计器每次重画(改了哪些属性)与设计态排版的耗时;预览与打印的各环节——分页、生成输出页的 DOM、等图片与字体就绪、合成交给本地客户端的文档、送打印——各记一组耗时,组里写输出页数、资源个数或字节数;一次打印另记一组总耗时与结果。

分页步骤与打印环节来自运行时的 trace 回调,宿主自己用预览元素或 printDocument 时也能接,见运行时。

新手引导 ​

设计器第一次在设计态显示出来(画布有了尺寸,放在没打开的页签里时等它打开)后,自动开始新手引导:先是各款共用的几步(欢迎、菜单栏、工具栏、物料、画布、属性、问题与状态栏),再是打印自己的几步——「插入」菜单、页眉与页脚、「页面」菜单、页面模板、字段物料与打印,最后一步是设置。一步高亮一块面板、一个区、菜单栏上的一张菜单或工具栏上的一颗钮,目标看不见(面板隐藏、按钮不在工具栏上)的那步自动跳过。

  • 菜单栏一步高亮整条菜单栏,讲各张菜单里有什么、按 F10 进出菜单栏;「插入」「页面」两步高亮菜单栏上那张菜单的入口;设置一步高亮「文件」菜单的入口,说设置在里面、引导能从「帮助」重新开始。字段物料与打印两步的说明带一句它们也在「文件」菜单里。

  • 窗口窄、菜单栏收成一颗「菜单」钮时,菜单栏一步高亮这一条,「插入」「页面」与设置几步都落在「菜单」钮上。

  • 走完、跳过或关上之后,设计器把设置的 help.tourDone(DESIGNER_TOUR_DONE_KEY)写成 true 并派发 settings-change,宿主存起来,下次就不再自动开始。

  • 引导限在设计器的外框里:遮罩只盖外框,高亮框裁在外框里,页面别处照常可读可点。同一页同时只有一个引导:别的设计器的引导开着时不自动开始。

  • autoTour(attribute auto-tour)写 false 关掉自动开始。

  • startTour() 从头开始,同一页里别的引导开着时先收起它,预览态或没有能走的步骤时返回 false;命令 start-tour 与设置「帮助」页的「开始引导」做同一件事,缺省在帮助菜单里、不上工具栏,宿主可以经 commandLayout 放上去。

  • 扩展的 tourSteps 往后面加步骤,order 从 200 起;目标写 { panel }、{ region }、{ command }、{ selector },或 { menubar: '<菜单 id>' }(菜单栏上那张菜单的入口,窄屏时是「菜单」钮,菜单栏上没有这张菜单时跳过)。步骤 id 写扩展 id 加点,与别的扩展重名或用了 shell. 前缀时赋值即抛 DesignerExtensionError。

快捷键 ​

焦点在设计器里、且不在输入框与面板里时生效,只在设计态。

按键作用
Ctrl / ⌘ + Z撤销
Ctrl / ⌘ + Shift + Z,Ctrl + Y重做
Ctrl / ⌘ + C、X、V复制、剪切、粘贴,走系统剪贴板
Ctrl / ⌘ + D创建副本
Ctrl / ⌘ + A全选当前页面模板里看得见的元素
Ctrl / ⌘ + S保存:执行外壳的 save 命令,派发 save
Ctrl / ⌘ + Shift + J打开编辑 JSON 的对话框(开发者模式开着时;关着时交给浏览器)
Ctrl / ⌘ + ,打开设置对话框
Ctrl / ⌘ + ]、[上移一层、下移一层
Ctrl / ⌘ + Shift + ]、[置于顶层、置于底层
Delete、Backspace删除选中的元素
方向键选中的元素移动一格(网格间距,写 0 时 1 mm);没有选中时平移视口
Shift + 方向键选中的元素移动 10 mm
按住空格拖动平移画布;按住鼠标中键拖动同样
H打开或关上拖动工具
+、-放大、缩小
0适合窗口
Enter选中表格时进入表格编辑态
Esc取消正在进行的拖动;在表格编辑态里退出;否则取消选择
F10焦点交给菜单栏;再按 F10 或 Esc 回到原处
Alt + F10焦点交给选中框上方的选中工具条(工具条没有按钮时交给尺寸徽标);在里面按 Esc 回到画布

复制的元素以 JSON 放进系统剪贴板,可以在两个设计器之间粘贴;粘贴时先按当前的元素登记表校验,有错误的内容不粘贴。别的内容(图片、旧模板等)粘贴或拖到画布上时交给扩展的导入器,见下文「导入」。

保存与事件 ​

设计器不保存模板。每次修改派发 schema-change,detail.schema 是改完的模板;一次拖动、一次属性编辑合成一次。选「文件 → 保存」或在画布上按 Ctrl / ⌘ + S 时(都是外壳的 save 命令,预览态只能从菜单保存)派发 save,宿主保存成功后调用 markSaved():

ts
// 写成设计器的子元素:slot="menubar-center" 的标题在菜单栏正中,slot="status-end" 的保存状态在状态栏右端
const title = document.createElement('span')
title.slot = 'menubar-center'
title.textContent = '送货单'
const status = document.createElement('span')
status.slot = 'status-end'
status.textContent = '没有修改'
designer.append(title, status)
designer.addEventListener('dirty-change', (event) => {
  const { dirty } = (event as PrintDesignerEventMap['dirty-change']).detail
  status.textContent = dirty ? '有未保存的修改,选「文件 → 保存」或按 Ctrl / ⌘ + S' : '已保存'
})
designer.addEventListener('save', () => {
  // 把 designer.schema 交给后端保存,成功后标记为已保存
  designer.markSaved()
})
  • 给 schema 赋值即装载新模板:清空撤销历史与选择,按实际大小显示、纸的左上角落在画布左上角,并派发 kind 为 load 的 schema-change。读取 schema 得到当前模板。
  • dirty 表示有没有未保存的修改,变化时派发 dirty-change;撤销回到保存时的状态,dirty 也回到 false。
  • 撤销历史的每一步带一句说明,如「删除文本 title」「修改「字号」」「移动 3 个元素」,随界面语言变;工具栏上撤销、重做的名字带上下一步的说明(「撤销:删除文本 title」)。拖动、缩放、旋转按下到松开是一步;属性面板里连续输入同一项是一步;方向键微移连续按、选中不变、相隔不超过 1 秒的并成一步,换了选中就另起一步。
  • 「清空历史」(命令 clear-history)先弹确认框,按确定才清空撤销与重做,模板与 dirty 不变;它不在工具栏上,在历史记录面板的面板头上,宿主也可以经 commandLayout 放置。只读与预览态时撤销、重做与清空历史都不可用。
  • issues-change 在模板改动后带上全部校验问题,宿主可以据此拦住有错误的模板,不让保存。
  • 导入字段派发 fields-change 与 sample-data-change,见上文「导入字段」。
  • 其余事件:selection-change、page-change、mode-change、viewport-change、element-dblclick。

也可以用方法操作:execute(operations) 执行一组编辑操作并记为一步撤销(操作见 @xihan-print/core 的 PrintOperation),addElement、select、copy、paste、align、reorder、zoomToFit、scrollToElement 等与界面上的命令一致。print() 按当前模板与 data 打印,给了 printClient 时交给本地打印客户端。

自动保存本机草稿 ​

设置「基础」里打开自动保存(general.autoSave,缺省关)后,有未保存的修改时停手约一秒,设计器把整份模板存成这台浏览器里的草稿,连着改只存最后一次;保存了(宿主调 markSaved())草稿就删掉。下次宿主装进同一个草稿键的模板、和草稿不一样时,弹框问恢复还是丢弃:恢复把草稿整份换上,记一步撤销(草稿有错误级校验问题时不换,如扩展换了);丢弃即删;一样就悄悄删掉。只读时不问。

  • 草稿键:设计器的 draftKey(attribute draft-key)写模板 id,区分同一页、同一种设计器里的不同模板;不写时按页面地址(路径、查询与锚点)区分。
  • 草稿只存在本机,宿主不用存,也不用接新事件。要关掉这一项,用 settingsPolicy 把 general.autoSave 藏起或锁住,或在 settings 里预先写开或关。

编辑 JSON ​

设置「基础」里开着开发者模式(general.developer,DESIGNER_SETTING_KEYS.developer)时,按 Ctrl / ⌘ + Shift + J,或在「编辑」菜单、画布空白处的右键菜单里选「编辑 JSON」,打开编辑整份模板 JSON 的对话框。开发者模式关着时这几处都没有它,快捷键交给浏览器;openJsonEditor() 照样能调。宿主要让用户用它,就在 settings 里预先写 'general.developer': true,或用 settingsPolicy 锁住这一条。

  • 框里写出当前模板,缩进两格。边改边解析、按打印的校验报问题(与 validate() 同一套:结构、元素登记表与表达式),问题列在框下,点一条光标跳到那个路径在文字里的位置。
  • 解析失败或有错误级的问题时「应用」不可用;没有错误时按「应用」整份换上,记一步撤销「编辑 JSON」,派发 kind 为 execute 的 schema-change。撤销就回到改之前的那份。
  • 框下还有「格式化」与「复制」。只读时只能看,不能应用。
  • 整份换上走 @xihan-print/core 的编辑操作 { type: 'replaceDocument', document },逆操作换回原来那份;新模板由调用方先校验。

只读与预览 ​

  • readonly:可以选择、浏览、预览与打印,不能修改;物料不能拖出,属性面板只读。
    • 写成 attribute 时按三态读,与 XiHan.UI 元素的布尔 attribute 同一读法:不写或移除时不只读;写 readonly="false" 也不只读;其余写法(readonly、readonly=""、readonly="true")只读。
    • 这是行为变化:以前按原生布尔 attribute 读,只要写了就只读,readonly="false" 也算只读。
    • 经 property 赋值(designer.readonly = false)不受影响。
  • mode:design 设计态,preview 预览态。预览态按 data 分页,用 <xh-print-preview> 显示全部输出页。

语言包 ​

界面文字缺省是英文。把 XiHan.UI 与打印同一语言的语言包合并后全局设置,设计器、它内部的零件与预览都换成这种语言,见快速开始。用户也可以在设置的「语言」页里只给这台设计器换语言,见上文「设置」。

只改一个实例的几处文案时,给它的 translations 属性,压在全局语言包之上:

ts
import type { XhPrintDesigner } from '@xihan-print/designer'

export function renamePanels(designer: XhPrintDesigner): void {
  designer.translations = {
    palette: '控件',
    issues: '检查结果',
  }
}

全部文案键见设计器参考。属性面板里的属性名、选项名与单位也在里面,键是属性路径,如 style.fontSize。

扩展 ​

extensions 收一组扩展对象:元素种类、编辑方式、校验规则、属性编辑器、命令、面板、设置分区、导入器、字段来源、物料来源、菜单栏的菜单、引导步骤与「关于」页的几行都经它登记。内置元素、设计器自带的编辑器与命令也是扩展,自动排在最前。同一页面上的几个设计器各给各的,互不影响。

  • 扩展对象的 id 全局唯一,用包名加功能名,如 @acme/print-fonts。它也是模板里扩展数据的键:文档、页面与元素都可以带 extensions: { [扩展 id]: { ... } },校验只查它是对象,编辑与复制粘贴原样带着走。
  • 赋值时当场合并查重:扩展 id、元素类型、编辑方式、命令 id 与快捷键、字段来源 id、物料来源 id、菜单栏的菜单 id 与内置的或别的扩展重名时,抛 DesignerExtensionError(code 为 designer.extension-conflict),设计器保持赋值前的状态。
  • setup(context) 在设计器连上文档时调用,它返回的函数在断开、或这个扩展被去掉时调用。同一个扩展对象换了引用,当作卸下再装上,宿主要保持引用稳定。
  • rules 是校验规则,收到文档与 { elements }(合并后的元素登记表),返回问题。问题码以扩展 id 的末段加点开头,结构没有 error 时才跑。
  • controls 是新的编辑方式:属性描述里用到这个 kind 时,按它的 check 校验取值;没登记的编辑方式报 unknown-control。
  • settings 是扩展的设置分区,放进设置对话框,排在设计器自己的分区之后(order 从 200 起),值同样在 settings 里、键是「分区 id.条目 id」。要地址和密钥的收费附加包(如云打印)登记自己的分区:密钥写成 { kind: 'text', secret: true } 的条目,画成密码框,settingsStorageKey 存设置时也不存它。本地客户端没有地址与密钥,「本地客户端」一页只写连接状态。
  • tourSteps 是新手引导的步骤,见上文「新手引导」;about 是设置「关于」页里接在后面的几行 { label, value },收费附加包写自己的版本与授权。
  • fieldSources 是导入字段对话框里的来源页,排在「JSON 样例」「JSON Schema」之后,如从接口请求取一份样例;页拿到内容后交回对话框,推断、预览与确认走对话框自己的。形状见 XiHan.UI designer-kit 的「字段来源」。
  • paletteSources 是物料来源:往物料面板加几组条目,如收费附加包的「我的物料」、行业模板,排在内置分组与「字段」之后,几个来源之间按 order。物料面板每次刷新都按上下文现取它的分组;条目被点一下时交给来源的 insert(item, context),拖到画布上放下时多给一个落点 { x, y, region }:纸张坐标(mm),以及这一点落在页眉、正文还是页脚。插入什么、插在哪里由来源定;只读或不在设计态时不交给来源。分组或条目的 id 与物料面板里已有的重名时,刷新物料面板时抛 TypeError。形状见 XiHan.UI designer-shell 的「物料来源」。
  • menubarMenus 往菜单栏加菜单 { id, label, order? }:label 可以是收到上下文的函数,随语言变;order 定先后,内置的文件 0、编辑 10、视图 20、帮助 90,打印的插入 30、页面 40,扩展写在 30–80 之间,不写按 50。命令写 placement: ['menubar:<菜单 id>'] 落进来,没有命令的菜单不出现,见上文「菜单栏」。
  • 预览元素 <xh-print-preview> 与 paginate、validateDocument 这些函数也收 extensions,与设计器给同一份。

扩展元素 ​

扩展的 elements 登记新的元素类型,renderers 给它们的渲染。原型与内置元素的一样,给出缺省值、尺寸下限、物料分组与属性描述;不跨页的元素(flow 为 false)另外必须给 layout:排版时按元素与数据算出画在元素框里的数据(只能是 JSON),元素框的位置与尺寸由排版填好。渲染收到排版盒与输出页所在的文档,返回要放进元素框的节点:

ts
import type { ExtensionElementPrototype } from '@xihan-print/core'
import type { PrintDesignerExtension } from '@xihan-print/designer'
import { behaviorProperties, layoutProperties, property } from '@xihan-print/core'

const SVG = 'http://www.w3.org/2000/svg'

const seal: ExtensionElementPrototype = {
  type: 'seal',
  category: 'media',
  order: 9,
  flow: false,
  minSize: { width: 10, height: 10 },
  defaults: { type: 'seal', width: 40, height: 40, field: '', color: '#d40000' },
  properties: [
    ...layoutProperties({ rotation: false }),
    property(['field'], 'data', { kind: 'dataField', kinds: ['value'] }),
    property(['color'], 'appearance', { kind: 'color' }),
    ...behaviorProperties(),
  ],
  // 排版只给 JSON:从数据里取单位名,元素框由 core 填好
  layout: (element, context) => ({
    name: String(context.field(String(element.field)) ?? ''),
    color: String(element.color),
  }),
  // 报出引用的字段:collectDocumentFields 汇总模板字段时不会漏掉印章
  fields: (element, context) => context.field(String(element.field)),
}

export const sealExtension: PrintDesignerExtension = {
  id: '@acme/print-seal',
  elements: [seal],
  palette: { seal: { label: '印章' } },
  renderers: {
    seal: (box, { document }) => {
      const { name, color } = box.data as { name: string, color: string }
      const svg = document.createElementNS(SVG, 'svg')
      svg.setAttribute('viewBox', '0 0 100 100')
      svg.setAttribute('width', '100%')
      svg.setAttribute('height', '100%')
      const ring = document.createElementNS(SVG, 'circle')
      ring.setAttribute('cx', '50')
      ring.setAttribute('cy', '50')
      ring.setAttribute('r', '46')
      ring.setAttribute('fill', 'none')
      ring.setAttribute('stroke', color)
      ring.setAttribute('stroke-width', '4')
      const text = document.createElementNS(SVG, 'text')
      text.setAttribute('x', '50')
      text.setAttribute('y', '56')
      text.setAttribute('text-anchor', 'middle')
      text.setAttribute('fill', color)
      text.textContent = name
      svg.append(ring, text)
      return svg
    },
  },
}
  • 属性描述可以用 layoutProperties、behaviorProperties 与 property 拼,和内置元素用的是同一套。
  • 引用了数据字段的元素给 fields:返回它引用的字段,collectDocumentFields 汇总模板字段时才不会漏掉它。上下文带 field、template、expression 与 text 四个帮手,分别把字段路径、{{path}} 模板串、表达式与文本内容来源换成引用,与内置元素同一套解析;引用的是列表时给 rows,里面是相对列表一行的引用。字段的值在元素里当图片地址、条码或二维码的内容用时,在引用上写 content(image、barcode、qrcode),汇总时一并记进字段树。
  • 不跨页的元素没给 layout、跨页的元素没给 prepare(见下文)时,赋值时抛 TypeError。
  • palette 给自己元素的物料项:名称与图标(@xihan-ui/core 的 IconRecord)。名称也用在画布与属性面板的可访问名和问题列表里,不给时显示类型名。palette 里写了不是本扩展登记的元素类型时,赋值时抛 TypeError。
  • 原型的 category 可以是内置分组(text、media、data、shape、code),也可以是新的分组。新分组排在内置分组之后,按首次登记的顺序;名称写在扩展的 categories 里,没写时显示分组名。分组名与内置分组或别的扩展写的重名时,抛 DesignerExtensionError。
  • 名称随扩展给出,不在设计器的语言包里。扩展包要支持多种语言,就按语言给出不同的扩展对象,例如导出一个收文案的工厂函数。
  • 渲染只能用标准 HTML 与 SVG:浏览器打印与本地打印客户端用的是另一份文档,自定义元素在那里不会升级。
  • 扩展去掉后,文档里已有的这类元素照样保留:设计器把它画成写着类型的虚线占位,校验报 element.unknownType,再装上就恢复。

跨页的元素(flow 为 true)给 prepare 而不是 layout:按元素与数据给出按顺序的若干块,每块是高度(mm)与交给渲染的数据,另可给一块 repeat,排在每页那一段的开头(如表头)。prepare 收到的上下文比 layout 多一个 measureText(text, style, width),按文字样式与可用宽度折行量出各行与总高度:

ts
import type { ExtensionElementPrototype, TextStyle } from '@xihan-print/core'
import type { PrintDesignerExtension } from '@xihan-print/designer'
import { behaviorProperties, layoutProperties, property } from '@xihan-print/core'

const style: TextStyle = {
  fontFamily: 'sans-serif',
  fontSize: 10,
  bold: false,
  italic: false,
  underline: false,
  lineThrough: false,
  color: '#000000',
  align: 'left',
  verticalAlign: 'top',
  lineHeight: 1.4,
  letterSpacing: 0,
}

interface Approval {
  name: string
  comment: string
}

const approvals: ExtensionElementPrototype = {
  type: 'approvals',
  category: 'data',
  order: 9,
  flow: true,
  minSize: { width: 40, height: 10 },
  defaults: { type: 'approvals', width: 180, height: 20, field: 'approvals' },
  properties: [
    ...layoutProperties({ rotation: false }),
    property(['field'], 'data', { kind: 'dataField', kinds: ['list'] }),
    ...behaviorProperties(),
  ],
  // 每条记录一块:意见折行后的高度加上下各 2 mm;块不拆开,放不下就整块挪到下一页
  prepare: (element, context) => {
    const list = context.field(String(element.field))
    const items = (Array.isArray(list) ? list : []) as Approval[]
    return {
      repeat: { height: 8, data: ['审批人', '意见'] },
      blocks: items.map(item => ({
        height: context.measureText(item.comment, style, element.width - 40).height + 4,
        data: [item.name, item.comment],
      })),
    }
  },
}

export const approvalsExtension: PrintDesignerExtension = {
  id: '@acme/print-approvals',
  elements: [approvals],
  palette: { approvals: { label: '审批记录' } },
  renderers: {
    // 每页一段:data 是 { blocks, repeat },blocks 是这一页的那几块
    approvals: (box, { document }) => {
      const { blocks, repeat } = box.data as { blocks: string[][], repeat: string[] }
      const table = document.createElement('table')
      table.style.width = '100%'
      for (const cells of [repeat, ...blocks]) {
        const row = table.insertRow()
        for (const text of cells)
          row.insertCell().textContent = text
      }
      return table
    },
  },
}
  • 块依次排进正文区,放不下的整块挪到下一页,与长文本、表格一样把下方的元素往下推;只有一段时高度不小于设计高度。
  • 每页一段,渲染每段调一次:排版盒的 data 是 { blocks, repeat },blocks 是这一段的那几块的数据,repeat 是重复块的数据(没给时为 null);fragment 给出段序号与是不是最后一段。
  • 一块比整页正文区还高时照样放下,分页诊断报 element.overflow;块高度不是不小于 0 的有限数时,排版抛 TypeError。
  • 跨页的元素只能放在正文区,放进页眉页脚时校验报 element.flowInBand;设计态排成一段、按内容撑高。

属性编辑器 ​

扩展的 editors 给属性面板换编辑器,按扩展的顺序压在设计器自带的编辑器之上。查找顺序是对象种类加属性路径、属性路径、编辑方式,都没有时用面板内建的编辑器:

表键适用
byTarget对象种类 → 属性路径只对这种对象生效
byPath属性路径,如 style.fontFamily所有带这个属性的对象
byControl编辑方式,如 color、stroke所有这种编辑方式的属性

对象种类是 page(页面设置)、元素类型(如 text、table)与表格部位(table-column、table-header、table-body、table-footer-row、table-footer-cell);编辑方式见元素的「取值」一栏。给了编辑器的复合属性不再拆开,整个值交给编辑器。

编辑器是一个函数:收到当前值与 setValue、commit,返回一个元素;值或只读状态变化时设计器调用 update,移除前调用 destroy。元素标了 data-xh-part="control" 时放进字段里,标签自动接上。下面的编辑器让字体只能从几种里选:

ts
import type { DesignerPropertyEditor } from '@xihan-ui/designer-shell'
import { definePrintDesigner, PRINT_DESIGNER_PANELS } from '@xihan-print/designer'
import { schema } from '../templates/minimal'

const FONTS = ['宋体', '黑体', '楷体', '仿宋']

const fontEditor: DesignerPropertyEditor = (initial) => {
  let context = initial
  const select = document.createElement('select')
  select.dataset.xhPart = 'control'
  select.append(new Option('缺省字体', ''), ...FONTS.map(font => new Option(font, font)))
  const sync = (): void => {
    select.value = typeof context.value === 'string' ? context.value : ''
    select.disabled = context.readonly
  }
  select.addEventListener('change', () => {
    context.setValue(select.value === '' ? undefined : select.value)
    context.commit()
  })
  sync()
  return {
    element: select,
    update: (next) => {
      context = next
      sync()
    },
  }
}
ts
const designer = document.createElement('xh-print-designer')
designer.defaultLayout = { version: 1, hidden: [PRINT_DESIGNER_PANELS.palette, PRINT_DESIGNER_PANELS.issues, PRINT_DESIGNER_PANELS.pages] }
designer.extensions = [{ id: '@acme/print-fonts', editors: { byPath: { 'style.fontFamily': fontEditor } } }]
designer.schema = schema
  • setValue(undefined) 清除这个属性;连续的 setValue 合成一步撤销,commit() 之后的修改另起一步。
  • context.target 是当前全部被编辑的对象,多选时有多个。
  • context.issues 是落在这个属性里层的校验问题,路径相对这个属性(如值映射第 2 项的数据值是 ["entries", 1, "value"]),没有时是空数组。复合编辑器据此把问题标到对应的子项上:子项旁边写出问题文字,子项的控件标无效(aria-invalid="true"),并经 aria-describedby 接上这段文字;读屏名仍是子项自己的名字。编辑器返回的实例写 nestedIssues: true 时,属性行下面不再重复这些问题;不写时,行下显示第一条。设计器自带的值格式(值映射由内建选项表标到那一格,未命中文本、常用格式与表达式标到各自下面)与表格列、表尾行的条目都这样标;四边边框按页签分边,里层问题仍显示在属性行下面。

命令 ​

扩展的 commands 登记命令,工具栏按钮、右键菜单项与快捷键共用一条。命令 id 用扩展 id 加点加名字;label、enabled 与 run 收到 { designer, translations, commands, settings, frame }(commands 是四款设计器共用的命令文案,如 commands.undo;settings 是生效的设置;frame 是设计器的外框),label 也可以直接写成文字;给了 checked 的是开关式命令,工具栏按钮显示按下状态:

ts
import type { PrintDesignerExtension, XhPrintDesigner } from '@xihan-print/designer'
import { PRINT_TOOLBAR_LAYOUT } from '@xihan-print/designer'

export const lockExtension: PrintDesignerExtension = {
  id: '@acme/print-lock',
  commands: [{
    id: '@acme/print-lock.lock',
    label: '锁定',
    placement: ['toolbar', 'menu:element'],
    shortcut: ['Mod', 'Shift', 'L'],
    enabled: ({ designer }) => !designer.readonly && designer.getSelection().elements.length > 0,
    run: ({ designer }) => {
      designer.execute(designer.getSelection().elements.map(id => ({ type: 'updateElement', id, changes: [{ path: ['locked'], value: true }] })))
    },
  }],
}
  • placement 是命令缺省出现的位置:工具栏 toolbar(工具栏只显示图标时,没有图标的命令写它的 shortLabel),右键菜单 menu:blank(画布空白处)、menu:element(元素上)、menu:table(表格编辑态里),选中工具条 selection:text(选中的全是文字类元素)、selection:element(单个别的元素)、selection:multiple(几个元素里有别的种类),或菜单栏的一张菜单 menubar:<菜单 id>(见上文「菜单栏」)。扩展命令按登记顺序排在内置命令之后,自成一组。
  • shortLabel 是按钮上看得见的短文字,名字与提示仍取 label,所以短文字要出现在 label 里(不分大小写),比如 label 写「插入条码」、shortLabel 写「条码」;开发期不在时,控制台警告一次。
  • shortcut 写成键数组,Mod 在 macOS 上是 ⌘、其余平台是 Ctrl。焦点在画布上、命令此刻可用时生效。
  • 设计器的 commandLayout 改写排列:位置 → 若干组命令 id,内置命令与扩展命令可以混排;写了某个位置就只显示列出的命令,写了没登记的命令 id 时抛 TypeError。缺省排列是导出的 PRINT_TOOLBAR_LAYOUT、printMenuLayout(subject, tableSelected) 与 printSelectionLayout(subject),菜单栏的是 printMenubarLayout(commands, elements)(在 @xihan-ui/designer-shell 的 designerMenubarLayout(commands) 上加了打印的几处,elements 是元素登记表,插入菜单按它的物料分组排),可以在它们的基础上改:
ts
export function lockFirst(designer: XhPrintDesigner): void {
  designer.extensions = [lockExtension]
  designer.commandLayout = { toolbar: [['@acme/print-lock.lock'], ...PRINT_TOOLBAR_LAYOUT] }
}

内置命令的 id:

用途命令 id
编辑undo redo clear-history cut copy paste duplicate delete select-all
对齐与层级align-left align-center align-right align-top align-middle align-bottom distribute-horizontal distribute-vertical bring-to-front bring-forward send-backward send-to-back:工具栏上缺省每条一颗钮,对齐、分布与层级各成一组
视图pan-tool(designer-shell 内置,拖动工具,按下状态跟外框的 canvasTool) zoom-out zoom-in zoom-to-fit zoom-to-actual grid snap design preview page-setup print:grid snap 也在视图菜单,print 也在文件菜单
表格edit-table exit-table-edit insert-column-left insert-column-right remove-column move-column-left move-column-right insert-footer-row-above insert-footer-row-below remove-footer-row merge-cell-right split-cell
文字格式font-size text-bold text-italic text-underline text-line-through text-align-left text-align-center text-align-right:缺省排在 selection:text 的选中工具条上
保存、面板、设置与帮助save panels settings help:designer-shell 内置。「保存」(Ctrl / ⌘ + S)派发 save,在文件菜单;其余三条在菜单栏里(面板在视图菜单,设置在文件菜单,帮助在帮助菜单),「面板」也排在工具栏末组。「面板」点开菜单开关各块面板,「设置」打开设置对话框(Ctrl / ⌘ + ,),「帮助」在新窗口打开本站的设计器一页(PRINT_DESIGNER_HELP_URL)。panels-toggle-all panels-restore layout-reset 排在「面板」菜单末尾。start-tour 开始新手引导,shortcuts、about 打开设置的快捷键页与关于页,都在帮助菜单;edit-json 打开编辑 JSON 的对话框(Ctrl / ⌘ + Shift + J),在编辑菜单与画布空白处右键菜单的末组,只在开发者模式开着时存在;show-toolbar show-status-bar 是显示开关,收在视图菜单的「外观」(appearance)子菜单里
面板开关panel:<面板 id>:每块面板一条,含扩展登记的面板(panel:<区名>),文字是面板标题、勾选的是看得见的,宿主锁定的面板不可用;不排在任何位置,可以经 commandLayout 放上工具栏
页面模板previous-page page-menu next-page add-page duplicate-page remove-page:翻页条上的排列固定(PRINT_PAGE_STRIP_LAYOUT),不经 commandLayout;move-page-up move-page-down 在页码菜单里。除了页码,这几条也在页面菜单里
页面菜单page-paper page-orientation:纸张与方向的子菜单;page-properties:在属性面板里编辑当前页面模板
插入insert:<元素类型>:每种元素一条,扩展登记的元素也有(printInsertCommandId(type)),放在视口中央并选中;header-variants footer-variants:页眉、页脚变体的子菜单。都在插入菜单里,不上工具栏,宿主可以经 commandLayout 放上去;扩展的命令不能用 insert: 开头的这些 id

面板 ​

扩展的 panels 往外框里加区,放印章库、套打对位这类面板。区是 { name, position, size?, minSize?, label?, title?, icon?, anchor?, defaultHidden?, closable?, movable?, floatable?, minimizable? }:position 取 top、start、end、bottom,或 overlay(画布上的浮动件,anchor 定落在哪个角或右侧中间,只能显示或隐藏),同一位置的区按扩展顺序上下排;label 是分隔条可访问名里的区名,给了 title 就画成带面板头的卡片;defaultHidden 的缺省不显示,closable、movable、floatable、minimizable 写 false 就不能隐藏、换栏、浮出或收起。create(context) 返回放进区里的元素;设计器状态变化时调用 update(context),扩展去掉时先调用 destroy() 再移除:

ts
import type { PrintDesignerExtension } from '@xihan-print/designer'

export const selectionPanel: PrintDesignerExtension = {
  id: '@acme/print-selection',
  panels: [{
    region: { name: 'acme-selection', position: 'end', label: '选中' },
    create: ({ designer }) => {
      const element = designer.ownerDocument.createElement('section')
      const render = (ids: readonly string[]): void => {
        element.textContent = ids.length === 0 ? '没有选中' : ids.join('、')
      }
      render(designer.getSelection().elements)
      return { element, update: context => render(context.designer.getSelection().elements) }
    },
  }],
}
  • 区名与内置区(menubar、toolbar、pages、minimap、palette、outline、canvas、properties、history、issues、status)或别的扩展的重名时,赋值时抛 DesignerExtensionError,设计器保持赋值前的状态。
  • 扩展的区同样记在面板布局里,用户能隐藏、挪动与浮出,也各有一条开关命令 panel:<区名>;扩展自己登记了同名命令时同样抛 DesignerExtensionError。预览态只保留菜单栏、工具栏与画布。

导入 ​

扩展的 importers 把粘贴与拖到画布上的外部内容换成元素。来源是 { kind, mime?, name?, text?, file? }:kind 为 clipboard 或 drop,文件带 file,文本按链接(text/uri-list)、HTML、纯文本依次给出。设计器按文件、链接、HTML、纯文本的顺序逐个问各导入器的 accepts,扩展靠前的先问,第一个接下的导入:

ts
import type { PrintDesignerExtension } from '@xihan-print/designer'
import { createElementRegistry } from '@xihan-print/core'

const registry = createElementRegistry()

function readAsDataUrl(file: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(file)
  })
}

export const imageImporter: PrintDesignerExtension = {
  id: '@acme/print-images',
  importers: [{
    id: '@acme/print-images.file',
    accepts: source => source.file !== undefined && source.mime?.startsWith('image/') === true,
    import: async (source) => {
      const url = await readAsDataUrl(source.file!)
      const image = registry.createElement('image', { id: 'image', x: 0, y: 0, overrides: { source: { kind: 'static', url } } })
      return { elements: [image] }
    },
  }],
}
  • import 可以是异步的,返回 { elements, region? },返回 undefined 即什么都不放。元素整体挪到落点(粘贴时是视口中央),左上角吸附网格,再按粘贴的规则换新 id 放进当前页面模板并选中,记一步撤销。region 缺省按落点所在的区。
  • 放下前按当前的元素登记表校验;import 抛错或元素没有通过校验时,设计器派发 import-error,带导入器的 id、来源与错误,不放任何元素。
  • 设计器自己的剪贴板内容不经导入器。
  • 导入器只把外部内容换成元素,放进当前页面模板,例如把拖进来的图片传到宿主的存储、换成图片元素。纸张、页眉页脚这类整份模板的设置不经导入器;整份存量模板的迁移见与宿主集成。

Released under The MIT License