Compare commits
21 Commits
318569c757
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d3e5537c | ||
|
|
1b04e34ff0 | ||
|
|
3a50e7e33b | ||
|
|
43900a8b52 | ||
|
|
25fa186d02 | ||
|
|
455946c51c | ||
|
|
fcd587bd50 | ||
|
|
1d506e0c71 | ||
|
|
ac117425d7 | ||
|
|
8c9b53b1b6 | ||
|
|
b4c2cf07d0 | ||
|
|
2bd89c517c | ||
|
|
4955b1f2e2 | ||
|
|
55867ae12d | ||
|
|
846061ab48 | ||
|
|
ccb5b119de | ||
|
|
1e03b7750a | ||
|
|
833eda5621 | ||
|
|
fff6cb98ae | ||
|
|
6cdae2f69c | ||
|
|
38b17c7589 |
129
README.md
129
README.md
@@ -62,6 +62,8 @@ npm run preview # 预览构建结果
|
||||
|
||||
在元素的 `content` 中使用 `{变量名}` 引用数据。导出时由 Excel 列映射或默认值填充。
|
||||
|
||||
表格元素的变量写在各单元格的 `tableCells["行,列"].content` 中(见下文 **表格** 一节),同样参与变量汇总与数据映射。
|
||||
|
||||
| 占位符 | 说明 |
|
||||
|--------|------|
|
||||
| `{变量名}` | 普通变量,如 `{货位编号}`、`{物料名称}` |
|
||||
@@ -71,7 +73,7 @@ npm run preview # 预览构建结果
|
||||
|
||||
- 写在花括号内,不含 `}`
|
||||
- `#INDEX` reserved,不要作为自定义变量名
|
||||
- 可在 `variableList` 中预先声明;也会从 `content` 与显示条件中自动汇总
|
||||
- 可在 `variableList` 中预先声明;也会从 `content`、**表格单元格 content** 与显示条件中自动汇总
|
||||
|
||||
### 导出时的变量映射(非模板字段)
|
||||
|
||||
@@ -96,7 +98,7 @@ npm run preview # 预览构建结果
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 元素唯一 ID |
|
||||
| `type` | `'text' \| 'barcode' \| 'qrcode' \| 'image'` | 元素类型 |
|
||||
| `type` | `'text' \| 'barcode' \| 'qrcode' \| 'image' \| 'table'` | 元素类型 |
|
||||
| `name` | string | 图层名称(设计器显示用) |
|
||||
| `x`, `y` | number | 位置 (mm) |
|
||||
| `width`, `height` | number | 尺寸 (mm) |
|
||||
@@ -116,7 +118,7 @@ npm run preview # 预览构建结果
|
||||
| `borderRadiusCorners` | object | 指定哪些角圆角,如 `{ "tl": true, "tr": true }` |
|
||||
| `locked` | boolean | 锁定后设计器不可拖动 |
|
||||
|
||||
### 值提取(`text` / `barcode` / `qrcode` / `image` 均有效)
|
||||
### 值提取(`text` / `barcode` / `qrcode` / `image` / 表格单元格 均有效)
|
||||
|
||||
对 **变量替换完成后的完整 `content` 字符串** 做提取(先替换 `{变量}`,再提取)。
|
||||
|
||||
@@ -223,6 +225,118 @@ npm run preview # 预览构建结果
|
||||
|
||||
---
|
||||
|
||||
### 表格 `type: "table"`
|
||||
|
||||
表格由行数、列数及各行高、各列宽定义网格;单元格可单独设置内容与文本样式,支持合并。
|
||||
|
||||
元素级 `content` 对表格通常留空;**单元格内容默认为空**,在 `tableCells` 中按坐标单独设置。
|
||||
|
||||
#### 表格结构字段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `tableRows` | number | `3` | 行数 (1–50) |
|
||||
| `tableCols` | number | `3` | 列数 (1–50) |
|
||||
| `tableRowHeights` | number[] | — | 各行高度 (mm),长度须等于 `tableRows` |
|
||||
| `tableColWidths` | number[] | — | 各列宽度 (mm),长度须等于 `tableCols` |
|
||||
| `tableBorderWidth` | number | `0.2` | 单元格网格线宽 (mm);`0` 时不绘制网格线 |
|
||||
| `tableBorderColor` | string | `#374151` | 网格线颜色 |
|
||||
| `tableCells` | Record<string, TableCellData> | — | 单元格数据,键为 `"行,列"`(**从 0 开始**) |
|
||||
|
||||
**尺寸关系**
|
||||
|
||||
- 内容区(网格区域)= 元素宽高减去 `padding`
|
||||
- `sum(tableRowHeights)` = 内容区高度
|
||||
- `sum(tableColWidths)` = 内容区宽度
|
||||
- 增大内边距时,元素宽高会自动增大以保持行高/列宽不变
|
||||
|
||||
拖拽缩放表格四角时,各行高、各列宽会 **等比缩放**。
|
||||
|
||||
#### `TableCellData`(`tableCells` 的值)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `content` | string | 单元格文本或 `{变量}`;缺省为空 |
|
||||
| `mergeAnchor` | `{ row, col }` | 被合并单元格指向主单元格坐标 |
|
||||
| `rowSpan` | number | 主单元格合并行数,默认 `1` |
|
||||
| `colSpan` | number | 主单元格合并列数,默认 `1` |
|
||||
| `fontSize` | number | 字号 (pt),缺省继承元素级 |
|
||||
| `fontFamily` | `'sans' \| 'serif' \| 'mono'` | 字体 |
|
||||
| `textAlign` | `'left' \| 'center' \| 'right'` | 水平对齐 |
|
||||
| `verticalAlign` | `'top' \| 'middle' \| 'bottom'` | 垂直对齐 |
|
||||
| `fontWeight` | `'normal' \| 'bold'` | 粗体 |
|
||||
| `fontStyle` | `'normal' \| 'italic'` | 斜体 |
|
||||
| `textUnderline` | boolean | 下划线 |
|
||||
| `textLineThrough` | boolean | 删除线 |
|
||||
| `textScaleX`, `textScaleY` | number | 水平/垂直拉伸 |
|
||||
| `letterSpacing` | number | 字间距 (mm) |
|
||||
| `textWritingMode` | `'horizontal' \| 'vertical'` | 排列方向 |
|
||||
| `textColor` | string | 文字颜色 |
|
||||
| `backgroundColor` | string | 单元格背景色 |
|
||||
| `wordWrap` | boolean | 自动换行 |
|
||||
| `textFormat` | 同文本元素 | 显示格式 |
|
||||
| `decimalPlaces` | number | 小数位数 |
|
||||
| `numberPartDisplay` | 同文本元素 | 数值拆分显示 |
|
||||
| `textExtractMode` 等 | 同值提取字段 | 对该单元格 `content` 生效 |
|
||||
| `textDisplayPrefix` / `textDisplaySuffix` | string | 显示前后缀 |
|
||||
|
||||
未在 `tableCells` 中声明的坐标视为空白单元格,使用元素级默认字体/对齐等绘制属性。
|
||||
|
||||
#### 合并单元格
|
||||
|
||||
- 主单元格(左上角)存 `rowSpan` / `colSpan`
|
||||
- 被合并的从属单元格存 `mergeAnchor: { row, col }` 指向主单元格
|
||||
- 合并区域内仅主单元格参与内容与样式;渲染时内部网格线自动省略
|
||||
|
||||
#### 表格示例片段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "el_table",
|
||||
"type": "table",
|
||||
"name": "明细表",
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"width": 66,
|
||||
"height": 20,
|
||||
"content": "",
|
||||
"fontSize": 9,
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"fontWeight": "normal",
|
||||
"fontFamily": "sans",
|
||||
"barcodeFormat": "CODE128",
|
||||
"showText": false,
|
||||
"tableRows": 2,
|
||||
"tableCols": 3,
|
||||
"tableRowHeights": [6, 6],
|
||||
"tableColWidths": [22, 22, 22],
|
||||
"tableBorderWidth": 0.2,
|
||||
"tableBorderColor": "#374151",
|
||||
"tableCells": {
|
||||
"0,0": { "content": "物料", "fontWeight": "bold" },
|
||||
"0,1": { "content": "数量", "fontWeight": "bold" },
|
||||
"0,2": { "content": "货位", "fontWeight": "bold" },
|
||||
"1,0": { "content": "{物料名称}" },
|
||||
"1,1": { "content": "{数量}" },
|
||||
"1,2": { "content": "{货位编号}" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
合并 2×2 区域示例(主单元格 `"0,0"`,`rowSpan: 2`,`colSpan: 2`):
|
||||
|
||||
```json
|
||||
"tableCells": {
|
||||
"0,0": { "content": "标题", "rowSpan": 2, "colSpan": 2 },
|
||||
"0,1": { "mergeAnchor": { "row": 0, "col": 0 } },
|
||||
"1,0": { "mergeAnchor": { "row": 0, "col": 0 } },
|
||||
"1,1": { "mergeAnchor": { "row": 0, "col": 0 } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 纸张排版 `PaperConfig`
|
||||
|
||||
控制标签如何排列到 PDF 页面上(每模板独立保存)。
|
||||
@@ -410,11 +524,13 @@ PDF 中标签之间的裁切参考线(预览与导出一致)。
|
||||
|
||||
对单个元素,最终显示值按以下顺序计算:
|
||||
|
||||
1. 将 `content` 中的 `{变量}` 替换为数据行值(文本元素可对每个变量做数值格式化)
|
||||
1. 将 `content` 中的 `{变量}` 替换为数据行值(文本元素可对每个变量做数值格式化;**表格**对每格 `tableCells[*].content` 同样处理)
|
||||
2. 对完整字符串做 **值提取**(trim → 分隔符 / 前 N / 后 N)
|
||||
3. 文本元素追加 **显示前后缀**(`textDisplayPrefix` / `textDisplaySuffix`)
|
||||
3. 文本元素及 **表格单元格** 追加 **显示前后缀**(`textDisplayPrefix` / `textDisplaySuffix`)
|
||||
4. 根据 **显示控制** 决定是否绘制该元素
|
||||
|
||||
表格单元格 `content` 为空时不绘制该格文字;网格线仍按 `tableBorderWidth` 绘制(为 `0` 时不显示)。
|
||||
|
||||
---
|
||||
|
||||
## 导入 / 导出模板
|
||||
@@ -442,5 +558,6 @@ PDF 每页按 `paper` 网格排列标签,裁切线可选,标签图由 Canvas
|
||||
|
||||
## 类型定义源码
|
||||
|
||||
完整 TypeScript 类型见 [`src/types.ts`](src/types.ts)。
|
||||
完整 TypeScript 类型见 [`src/types.ts`](src/types.ts)(含 `TableCellData`)。
|
||||
表格工具函数见 [`src/tableUtils.ts`](src/tableUtils.ts)。
|
||||
模板规范化逻辑见 [`src/utils.ts`](src/utils.ts) 中的 `normalizeTemplate`。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#323232" />
|
||||
<title>标签设计生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"express": "^4.21.2",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -2082,6 +2083,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
@@ -2836,6 +2843,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2866,6 +2879,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2937,6 +2956,24 @@
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -2958,6 +2995,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3614,6 +3660,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
|
||||
@@ -3747,6 +3799,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -3933,6 +4006,12 @@
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -4051,6 +4130,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -4696,6 +4790,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"express": "^4.21.2",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
506
src/App.tsx
506
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types';
|
||||
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from './types';
|
||||
import {
|
||||
stripTemplateForStorage,
|
||||
normalizeTemplate,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
extractTemplateVariables,
|
||||
buildAutoVariableMapping,
|
||||
applyRowVariableMapping,
|
||||
filterLabelsForImageExport,
|
||||
type PrintableLabel,
|
||||
} from './utils';
|
||||
import { LabelDesigner } from './components/LabelDesigner';
|
||||
@@ -18,12 +19,24 @@ import { PropSidebar } from './components/PropSidebar';
|
||||
import { ExportSidebar } from './components/ExportSidebar';
|
||||
import { MobileExportView } from './components/MobileExportView';
|
||||
import { MobileDesignView } from './components/MobileDesignView';
|
||||
import type { TableCellCoord } from './tableUtils';
|
||||
import { LayoutPreview } from './components/PreviewGrid';
|
||||
import { LabelLayoutSizeCalculator } from './components/LabelLayoutSizeCalculator';
|
||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||
import { renderLabelToDataUrl } from './labelRenderer';
|
||||
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
|
||||
import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer';
|
||||
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
|
||||
import {
|
||||
exportLabelsImagesZipAsync,
|
||||
type LabelImagesExportProgress,
|
||||
} from './labelImagesExport';
|
||||
import {
|
||||
getAvailablePrinters,
|
||||
saveSelectedPrinterId,
|
||||
submitLabelsPrint,
|
||||
} from './labelPrint';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useUndoRedo } from './hooks/useUndoRedo';
|
||||
import { useElementClipboard } from './hooks/useElementClipboard';
|
||||
import { useAppDialog } from './components/AppDialog';
|
||||
import { AnchoredMenu } from './components/AnchoredMenu';
|
||||
import {
|
||||
@@ -67,7 +80,7 @@ function fitTemplatePreviewSize(widthMm: number, heightMm: number) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { showAlert, showConfirm } = useAppDialog();
|
||||
const { showAlert, showConfirm, showToast } = useAppDialog();
|
||||
|
||||
// --- 1. Templates Storage & Initial States ---
|
||||
const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
|
||||
@@ -135,6 +148,8 @@ export default function App() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [pdfGenerating, setPdfGenerating] = useState(false);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
const [imagesZipGenerating, setImagesZipGenerating] = useState(false);
|
||||
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
@@ -142,15 +157,22 @@ export default function App() {
|
||||
totalPages: 0,
|
||||
phase: 'render',
|
||||
});
|
||||
const [imagesZipProgress, setImagesZipProgress] = useState<LabelImagesExportProgress>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
phase: 'render',
|
||||
});
|
||||
|
||||
// --- 5. Selection and Active item states ---
|
||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||
const [selectedTableCells, setSelectedTableCells] = useState<TableCellCoord[]>([]);
|
||||
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
|
||||
// Modal / Inline input values for template creations
|
||||
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
||||
const [newTmplName, setNewTmplName] = useState<string>('');
|
||||
const [newTmplW, setNewTmplW] = useState<number>(75);
|
||||
const [newTmplH, setNewTmplH] = useState<number>(45);
|
||||
const [newTmplW, setNewTmplW] = useState('75');
|
||||
const [newTmplH, setNewTmplH] = useState('45');
|
||||
const [newTmplPaper, setNewTmplPaper] = useState<PaperConfig | null>(null);
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
const [editTmplName, setEditTmplName] = useState<string>('');
|
||||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
||||
@@ -178,25 +200,35 @@ export default function App() {
|
||||
};
|
||||
|
||||
// Modify active template elements
|
||||
const handleTemplateChange = (updated: LabelTemplate) => {
|
||||
const updatedList = templates.map(t => t.id === updated.id ? updated : t);
|
||||
saveTemplatesToStorage(updatedList);
|
||||
};
|
||||
const activeTemplateRef = useRef(activeTemplate);
|
||||
activeTemplateRef.current = activeTemplate;
|
||||
|
||||
const handleTemplateChange = useCallback((updated: LabelTemplate) => {
|
||||
setTemplates((prev) => {
|
||||
const updatedList = prev.map((t) => (t.id === updated.id ? updated : t));
|
||||
try {
|
||||
const stripped = updatedList.map(stripTemplateForStorage);
|
||||
localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped));
|
||||
} catch (e) {}
|
||||
return updatedList;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDesignTemplateChange = useCallback(
|
||||
(updated: LabelTemplate) => {
|
||||
const current = activeTemplateRef.current;
|
||||
if (
|
||||
!skipDesignHistoryRef.current &&
|
||||
workflowStep === 'template-design' &&
|
||||
activeTemplate &&
|
||||
updated.id === activeTemplate.id
|
||||
current &&
|
||||
updated.id === current.id
|
||||
) {
|
||||
designHistory.record(activeTemplate, updated);
|
||||
designHistory.record(current, updated);
|
||||
}
|
||||
skipDesignHistoryRef.current = false;
|
||||
handleTemplateChange(updated);
|
||||
},
|
||||
[workflowStep, activeTemplate, designHistory, templates]
|
||||
[workflowStep, designHistory, handleTemplateChange]
|
||||
);
|
||||
|
||||
const handleDesignUndo = useCallback(() => {
|
||||
@@ -215,6 +247,45 @@ export default function App() {
|
||||
handleTemplateChange(next);
|
||||
}, [activeTemplate, designHistory, templates]);
|
||||
|
||||
const handleSelectElements = useCallback(
|
||||
(ids: string[]) => {
|
||||
setSelectedElementIds(ids);
|
||||
if (ids.length !== 1) {
|
||||
setSelectedTableCells([]);
|
||||
return;
|
||||
}
|
||||
const el = activeTemplate?.elements.find((e) => e.id === ids[0]);
|
||||
if (!el || el.type !== 'table') {
|
||||
setSelectedTableCells([]);
|
||||
}
|
||||
},
|
||||
[activeTemplate]
|
||||
);
|
||||
|
||||
const handleCopyElementsSuccess = useCallback(
|
||||
(count: number) => {
|
||||
showToast(`已复制 ${count} 个元素`, { variant: 'success' });
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const handleDuplicateElementsSuccess = useCallback(
|
||||
(count: number) => {
|
||||
showToast(`已创建 ${count} 个副本`, { variant: 'success' });
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const elementClipboard = useElementClipboard({
|
||||
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
|
||||
onChange: handleDesignTemplateChange,
|
||||
selectedElementIds,
|
||||
onSelectElements: handleSelectElements,
|
||||
onSelectTableCells: setSelectedTableCells,
|
||||
onCopySuccess: handleCopyElementsSuccess,
|
||||
onDuplicateSuccess: handleDuplicateElementsSuccess,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
|
||||
const needReset =
|
||||
@@ -336,31 +407,24 @@ export default function App() {
|
||||
await showAlert('请先输入合法的模板名', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
const width = Number(newTmplW);
|
||||
const height = Number(newTmplH);
|
||||
if (!newTmplW.trim() || Number.isNaN(width) || width < 10) {
|
||||
await showAlert('请输入合法的标签宽度(>10 mm)', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!newTmplH.trim() || Number.isNaN(height) || height < 10) {
|
||||
await showAlert('请输入合法的标签高度(>10 mm)', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
const newId = `custom_tmpl_${Date.now()}`;
|
||||
const newTmpl: LabelTemplate = {
|
||||
id: newId,
|
||||
name: `${newTmplName}`,
|
||||
width: Math.max(10, Number(newTmplW) || 70),
|
||||
height: Math.max(10, Number(newTmplH) || 40),
|
||||
elements: [
|
||||
{
|
||||
id: `desc_welcome`,
|
||||
type: 'text',
|
||||
name: '主文字内容',
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: Math.max(10, newTmplW - 10),
|
||||
height: 10,
|
||||
content: '{变量}',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans'
|
||||
}
|
||||
],
|
||||
paper: DEFAULT_PAPER_CONFIG,
|
||||
width,
|
||||
height,
|
||||
elements: [],
|
||||
paper: newTmplPaper ?? DEFAULT_PAPER_CONFIG,
|
||||
cutLine: DEFAULT_CUT_LINE_CONFIG,
|
||||
};
|
||||
|
||||
@@ -368,6 +432,7 @@ export default function App() {
|
||||
saveTemplatesToStorage(list);
|
||||
handleSelectTemplate(newId);
|
||||
setNewTmplName('');
|
||||
setNewTmplPaper(null);
|
||||
setShowAddTmplForm(false);
|
||||
setWorkflowStep('template-design');
|
||||
};
|
||||
@@ -375,8 +440,9 @@ export default function App() {
|
||||
const closeAddTemplateDialog = () => {
|
||||
setShowAddTmplForm(false);
|
||||
setNewTmplName('');
|
||||
setNewTmplW(75);
|
||||
setNewTmplH(45);
|
||||
setNewTmplW('75');
|
||||
setNewTmplH('45');
|
||||
setNewTmplPaper(null);
|
||||
};
|
||||
|
||||
const openEditTemplateDialog = (tmpl: LabelTemplate) => {
|
||||
@@ -510,7 +576,14 @@ export default function App() {
|
||||
);
|
||||
|
||||
const layoutResult = useMemo(
|
||||
() => buildPrintablePages(mappedRows, paper, workingTemplate, draftExportRange),
|
||||
() =>
|
||||
buildPrintablePages(
|
||||
mappedRows,
|
||||
paper,
|
||||
workingTemplate,
|
||||
draftExportRange,
|
||||
workingTemplate.variableDefaults ?? null
|
||||
),
|
||||
[mappedRows, paper, workingTemplate, draftExportRange]
|
||||
);
|
||||
|
||||
@@ -544,7 +617,14 @@ export default function App() {
|
||||
const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE;
|
||||
|
||||
const appliedLayoutResult = useMemo(
|
||||
() => buildPrintablePages(appliedMappedRows, paper, workingTemplate, appliedExportRange),
|
||||
() =>
|
||||
buildPrintablePages(
|
||||
appliedMappedRows,
|
||||
paper,
|
||||
workingTemplate,
|
||||
appliedExportRange,
|
||||
workingTemplate.variableDefaults ?? null
|
||||
),
|
||||
[appliedMappedRows, paper, workingTemplate, appliedExportRange]
|
||||
);
|
||||
|
||||
@@ -655,7 +735,7 @@ export default function App() {
|
||||
if (previewSnapshot !== null && appliedExportData !== null) {
|
||||
setPreviewLayoutStale(true);
|
||||
}
|
||||
}, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]);
|
||||
}, [workingTemplate.elements, workingTemplate.width, workingTemplate.height, workingTemplate.dataFilterRules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workflowStep !== 'print-view') return;
|
||||
@@ -665,7 +745,8 @@ export default function App() {
|
||||
appliedMappedRows,
|
||||
paper,
|
||||
workingTemplate,
|
||||
appliedExportData.exportRange
|
||||
appliedExportData.exportRange,
|
||||
workingTemplate.variableDefaults ?? null
|
||||
);
|
||||
|
||||
setPreviewSnapshot({
|
||||
@@ -685,16 +766,70 @@ export default function App() {
|
||||
appliedMappedRows,
|
||||
workingTemplate.width,
|
||||
workingTemplate.height,
|
||||
workingTemplate.dataFilterRules,
|
||||
]);
|
||||
|
||||
const pdfExportImage = useMemo(() => {
|
||||
const hasCode = workingTemplate.elements.some(
|
||||
(e) => e.type === 'barcode' || e.type === 'qrcode'
|
||||
);
|
||||
return hasCode
|
||||
? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 }
|
||||
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 };
|
||||
}, [workingTemplate.elements]);
|
||||
const printMode = workingTemplate.paper?.printColorMode ?? 'cmyk';
|
||||
return {
|
||||
imageFormat: 'png' as const,
|
||||
jsPdfFormat: 'PNG' as const,
|
||||
scale: printMode === 'cmyk' ? 14 : 12,
|
||||
jpegQuality: 0.95,
|
||||
};
|
||||
}, [workingTemplate.paper?.printColorMode]);
|
||||
|
||||
const handleExportImageFileNamePatternChange = (pattern: string) => {
|
||||
if (!activeTemplate) return;
|
||||
handleTemplateChange({
|
||||
...activeTemplate,
|
||||
exportImageFileNamePattern: pattern.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportImageFilterModeChange = (mode: ElementVisibilityMode) => {
|
||||
if (!activeTemplate) return;
|
||||
handleTemplateChange({
|
||||
...activeTemplate,
|
||||
exportImageFilterMode: mode === 'always' ? undefined : 'conditional',
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportImageFilterRulesChange = (rules: VisibilityRule[]) => {
|
||||
if (!activeTemplate) return;
|
||||
handleTemplateChange({
|
||||
...activeTemplate,
|
||||
exportImageFilterMode: 'conditional',
|
||||
exportImageFilterRules: rules,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDataFilterRulesChange = (rules: VisibilityRule[]) => {
|
||||
if (!activeTemplate) return;
|
||||
handleTemplateChange({
|
||||
...activeTemplate,
|
||||
dataFilterRules: rules.length > 0 ? rules : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAppliedLabelToDataUrl = useCallback(
|
||||
(label: PrintableLabel) =>
|
||||
renderLabelToDataUrl({
|
||||
widthMm: activeTemplate!.width,
|
||||
heightMm: activeTemplate!.height,
|
||||
elements: activeTemplate!.elements,
|
||||
rowData: label.row,
|
||||
rowIndex: label.sourceIndex,
|
||||
showBorder: false,
|
||||
mode: 'output',
|
||||
scale: pdfExportImage.scale,
|
||||
imageFormat: pdfExportImage.imageFormat,
|
||||
jpegQuality: pdfExportImage.jpegQuality,
|
||||
printColorMode: activeTemplate!.paper?.printColorMode ?? 'cmyk',
|
||||
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
||||
}),
|
||||
[activeTemplate, pdfExportImage]
|
||||
);
|
||||
|
||||
const exportPdf = async () => {
|
||||
if (!activeTemplate) return;
|
||||
@@ -731,19 +866,7 @@ export default function App() {
|
||||
cutLine,
|
||||
filename: `${safeName}_${exportTotalPages}页.pdf`,
|
||||
imageFormat: pdfExportImage.jsPdfFormat,
|
||||
renderLabel: (label) =>
|
||||
renderLabelToDataUrl({
|
||||
widthMm: activeTemplate.width,
|
||||
heightMm: activeTemplate.height,
|
||||
elements: activeTemplate.elements,
|
||||
rowData: label.row,
|
||||
rowIndex: label.sourceIndex,
|
||||
showBorder: false,
|
||||
mode: 'output',
|
||||
scale: pdfExportImage.scale,
|
||||
imageFormat: pdfExportImage.imageFormat,
|
||||
jpegQuality: 0.9,
|
||||
}),
|
||||
renderLabel: renderAppliedLabelToDataUrl,
|
||||
onProgress: (progress) => {
|
||||
const now = Date.now();
|
||||
const isDone = progress.done >= progress.total || progress.phase === 'save';
|
||||
@@ -761,14 +884,126 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const exportLabelsImagesZip = async () => {
|
||||
if (!activeTemplate) return;
|
||||
if (!appliedExportData) {
|
||||
await showAlert('请先应用数据后再导出图片。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (appliedLayoutResult.selectedCount === 0) {
|
||||
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const exportableLabels = filterLabelsForImageExport(
|
||||
appliedLayoutResult.pages,
|
||||
activeTemplate,
|
||||
activeTemplate.variableDefaults ?? null
|
||||
);
|
||||
if (exportableLabels.length === 0) {
|
||||
await showAlert('没有符合导出过滤条件的标签。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const labelTotal = exportableLabels.length;
|
||||
const safeName =
|
||||
activeTemplate.name.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40) || 'labels';
|
||||
const progressLastUpdate = { current: 0 };
|
||||
|
||||
setImagesZipGenerating(true);
|
||||
setImagesZipProgress({
|
||||
done: 0,
|
||||
total: labelTotal,
|
||||
phase: 'render',
|
||||
});
|
||||
|
||||
try {
|
||||
await exportLabelsImagesZipAsync({
|
||||
printablePages: appliedLayoutResult.pages,
|
||||
filename: `${safeName}_${labelTotal}枚_${Date.now()}.zip`,
|
||||
imageExtension: pdfExportImage.imageFormat === 'jpeg' ? 'jpg' : 'png',
|
||||
fileNamePattern: activeTemplate.exportImageFileNamePattern,
|
||||
variableDefaults: activeTemplate.variableDefaults ?? null,
|
||||
exportFilter: {
|
||||
exportImageFilterMode: activeTemplate.exportImageFilterMode,
|
||||
exportImageFilterRules: activeTemplate.exportImageFilterRules,
|
||||
},
|
||||
renderLabel: renderAppliedLabelToDataUrl,
|
||||
onProgress: (progress) => {
|
||||
const now = Date.now();
|
||||
const isDone =
|
||||
progress.phase === 'save' ||
|
||||
(progress.phase === 'render' && progress.done >= progress.total);
|
||||
if (isDone || now - progressLastUpdate.current >= 150) {
|
||||
progressLastUpdate.current = now;
|
||||
setImagesZipProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Images zip export failed', e);
|
||||
await showAlert('图片导出失败,请重试。', { variant: 'error' });
|
||||
} finally {
|
||||
setImagesZipGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const printLabels = async (printerId: string) => {
|
||||
if (!activeTemplate) return;
|
||||
if (!appliedExportData) {
|
||||
await showAlert('请先应用数据后再打印。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (appliedLayoutResult.selectedCount === 0) {
|
||||
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
saveSelectedPrinterId(printerId);
|
||||
setPrinting(true);
|
||||
|
||||
try {
|
||||
const printers = await getAvailablePrinters();
|
||||
const blob = await exportLabelsPdfBlobAsync({
|
||||
paper,
|
||||
template: activeTemplate,
|
||||
printablePages: appliedLayoutResult.pages,
|
||||
gridCols: appliedLayoutResult.gridCols,
|
||||
cutLine,
|
||||
imageFormat: pdfExportImage.jsPdfFormat,
|
||||
renderLabel: renderAppliedLabelToDataUrl,
|
||||
});
|
||||
|
||||
const mode = await submitLabelsPrint(blob, printerId, printers);
|
||||
if (mode === 'direct') {
|
||||
await showAlert('已提交到所选打印机。', { variant: 'success' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Print failed', e);
|
||||
await showAlert('打印失败,请重试。', { variant: 'error' });
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDarkWorkspace =
|
||||
workflowStep === 'template-design' || workflowStep === 'print-view';
|
||||
const isMobileDarkShell = isMobile && isDarkWorkspace;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('app-dark-shell', isMobileDarkShell);
|
||||
return () => {
|
||||
document.documentElement.classList.remove('app-dark-shell');
|
||||
};
|
||||
}, [isMobileDarkShell]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||||
? 'bg-[#323232] text-[#e8e8e8]'
|
||||
: workflowStep === 'print-view' && isMobile
|
||||
className={`flex flex-col font-sans h-dvh min-h-dvh max-h-dvh overflow-hidden ${
|
||||
isDarkWorkspace
|
||||
? 'bg-[#323232] text-[#e8e8e8]'
|
||||
: 'bg-slate-50 text-slate-800'
|
||||
}`}
|
||||
} ${isMobileDarkShell ? 'pb-[env(safe-area-inset-bottom)]' : ''}`}
|
||||
>
|
||||
{/*
|
||||
=========================================
|
||||
@@ -1002,6 +1237,7 @@ export default function App() {
|
||||
rowIndex={0}
|
||||
showBorder={true}
|
||||
variableDefaults={tmpl.variableDefaults ?? null}
|
||||
{...resolveReferenceBackgroundForPreview(tmpl)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1131,7 +1367,9 @@ export default function App() {
|
||||
template={activeTemplate}
|
||||
paper={paper}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElements={setSelectedElementIds}
|
||||
onSelectElements={handleSelectElements}
|
||||
selectedTableCells={selectedTableCells}
|
||||
onSelectTableCells={setSelectedTableCells}
|
||||
onChangeTemplate={handleDesignTemplateChange}
|
||||
canUndo={designHistory.canUndo}
|
||||
canRedo={designHistory.canRedo}
|
||||
@@ -1139,6 +1377,7 @@ export default function App() {
|
||||
onRedo={handleDesignRedo}
|
||||
onBack={() => setWorkflowStep('template-select')}
|
||||
onExport={() => openPrintView()}
|
||||
elementClipboard={elementClipboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1181,7 +1420,7 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<span className="hidden md:inline text-[10px] text-[#666]">
|
||||
Shift/Ctrl+点击多选
|
||||
Shift/Ctrl+点击多选 · Ctrl+C/V 复制粘贴
|
||||
</span>
|
||||
<button
|
||||
onClick={() => openPrintView()}
|
||||
@@ -1198,16 +1437,22 @@ export default function App() {
|
||||
template={activeTemplate}
|
||||
onChange={handleDesignTemplateChange}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElements={setSelectedElementIds}
|
||||
onSelectElements={handleSelectElements}
|
||||
selectedTableCells={selectedTableCells}
|
||||
onSelectTableCells={setSelectedTableCells}
|
||||
elementClipboard={elementClipboard}
|
||||
/>
|
||||
<PropSidebar
|
||||
template={activeTemplate}
|
||||
onChangeTemplate={handleDesignTemplateChange}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElements={setSelectedElementIds}
|
||||
onSelectElements={handleSelectElements}
|
||||
selectedTableCells={selectedTableCells}
|
||||
onSelectTableCells={setSelectedTableCells}
|
||||
activeTab="element"
|
||||
onChangeTab={() => { }}
|
||||
paper={paper}
|
||||
elementClipboard={elementClipboard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1219,6 +1464,7 @@ export default function App() {
|
||||
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
|
||||
<MobileExportView
|
||||
template={activeTemplate}
|
||||
onChangeTemplate={handleTemplateChange}
|
||||
templateName={activeTemplate.name ?? '标签'}
|
||||
templateVariables={templateVariables}
|
||||
importData={importData}
|
||||
@@ -1232,6 +1478,7 @@ export default function App() {
|
||||
rows={mappedRows}
|
||||
draftExportRange={draftExportRange}
|
||||
onChangeDraftExportRange={setDraftExportRange}
|
||||
onChangeDataFilterRules={handleDataFilterRulesChange}
|
||||
cutLine={cutLine}
|
||||
onChangeCutLine={handleCutLineChange}
|
||||
printablePages={previewSnapshot?.pages ?? []}
|
||||
@@ -1247,8 +1494,24 @@ export default function App() {
|
||||
activeRowIndex={activeRowIndex}
|
||||
onSelectRowIndex={setActiveRowIndex}
|
||||
pdfGenerating={pdfGenerating}
|
||||
imagesZipGenerating={imagesZipGenerating}
|
||||
printing={printing}
|
||||
onBack={() => setWorkflowStep('template-select')}
|
||||
onExportPdf={exportPdf}
|
||||
onPrint={printLabels}
|
||||
printDisabled={
|
||||
!appliedExportData ||
|
||||
appliedLayoutResult.selectedCount === 0 ||
|
||||
previewLayoutStale ||
|
||||
printing ||
|
||||
pdfGenerating ||
|
||||
imagesZipGenerating
|
||||
}
|
||||
onExportImages={exportLabelsImagesZip}
|
||||
imagesExporting={imagesZipGenerating}
|
||||
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
|
||||
onChangeExportImageFilterMode={handleExportImageFilterModeChange}
|
||||
onChangeExportImageFilterRules={handleExportImageFilterRulesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1284,9 +1547,12 @@ export default function App() {
|
||||
type="button"
|
||||
onClick={exportPdf}
|
||||
disabled={
|
||||
!appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating
|
||||
!appliedExportData ||
|
||||
appliedLayoutResult.selectedCount === 0 ||
|
||||
pdfGenerating ||
|
||||
imagesZipGenerating
|
||||
}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating && !imagesZipGenerating
|
||||
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
|
||||
: 'bg-[#444] text-[#666] cursor-not-allowed'
|
||||
}`}
|
||||
@@ -1330,9 +1596,11 @@ export default function App() {
|
||||
paper={paper}
|
||||
onChangePaper={handlePaperChange}
|
||||
template={activeTemplate}
|
||||
onChangeTemplate={handleTemplateChange}
|
||||
rows={mappedRows}
|
||||
draftExportRange={draftExportRange}
|
||||
onChangeDraftExportRange={setDraftExportRange}
|
||||
onChangeDataFilterRules={handleDataFilterRulesChange}
|
||||
cutLine={cutLine}
|
||||
onChangeCutLine={handleCutLineChange}
|
||||
previewReady={previewSnapshot !== null}
|
||||
@@ -1342,6 +1610,24 @@ export default function App() {
|
||||
onApplyData={handleApplyData}
|
||||
onRedrawPreview={handleRedrawPreview}
|
||||
draftSelectedCount={selectedCount}
|
||||
printing={printing}
|
||||
onPrint={printLabels}
|
||||
onExportImages={exportLabelsImagesZip}
|
||||
imagesExporting={imagesZipGenerating}
|
||||
exportImageFileNamePattern={activeTemplate.exportImageFileNamePattern ?? ''}
|
||||
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
|
||||
exportImageFilterMode={activeTemplate.exportImageFilterMode ?? 'always'}
|
||||
exportImageFilterRules={activeTemplate.exportImageFilterRules ?? []}
|
||||
onChangeExportImageFilterMode={handleExportImageFilterModeChange}
|
||||
onChangeExportImageFilterRules={handleExportImageFilterRulesChange}
|
||||
printDisabled={
|
||||
!appliedExportData ||
|
||||
appliedLayoutResult.selectedCount === 0 ||
|
||||
previewLayoutStale ||
|
||||
printing ||
|
||||
pdfGenerating ||
|
||||
imagesZipGenerating
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1351,7 +1637,6 @@ export default function App() {
|
||||
{editingTemplateId && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||||
onClick={closeEditTemplateDialog}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
@@ -1399,7 +1684,6 @@ export default function App() {
|
||||
{showAddTmplForm && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||||
onClick={closeAddTemplateDialog}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
@@ -1440,18 +1724,25 @@ export default function App() {
|
||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<LabelLayoutSizeCalculator
|
||||
onCalculated={({ width, height, paper }) => {
|
||||
setNewTmplW(String(width));
|
||||
setNewTmplH(String(height));
|
||||
setNewTmplPaper(paper);
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||||
标签宽度 (mm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
required
|
||||
min="10"
|
||||
max="150"
|
||||
placeholder="75"
|
||||
value={newTmplW}
|
||||
onChange={(e) => setNewTmplW(Number(e.target.value))}
|
||||
onChange={(e) => setNewTmplW(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1460,12 +1751,12 @@ export default function App() {
|
||||
标签高度 (mm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
required
|
||||
min="10"
|
||||
max="150"
|
||||
placeholder="45"
|
||||
value={newTmplH}
|
||||
onChange={(e) => setNewTmplH(Number(e.target.value))}
|
||||
onChange={(e) => setNewTmplH(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1490,27 +1781,56 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfGenerating && (
|
||||
{(pdfGenerating || imagesZipGenerating) && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/70 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl px-8 py-6 flex flex-col items-center gap-4 min-w-[280px]">
|
||||
<Loader2 className="w-10 h-10 text-indigo-600 animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{pdfProgress.phase === 'save'
|
||||
? '即将完成,请稍候'
|
||||
: `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
|
||||
</p>
|
||||
{pdfGenerating ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{pdfProgress.phase === 'save'
|
||||
? '即将完成,请稍候'
|
||||
: `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{imagesZipProgress.phase === 'save'
|
||||
? '正在下载 ZIP 文件…'
|
||||
: imagesZipProgress.phase === 'zip'
|
||||
? '正在打包压缩…'
|
||||
: '正在生成标签图片'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{imagesZipProgress.phase === 'save'
|
||||
? '即将完成,请稍候'
|
||||
: imagesZipProgress.phase === 'zip'
|
||||
? `压缩进度 ${Math.round(imagesZipProgress.zipPercent ?? 0)}%`
|
||||
: `${imagesZipProgress.done} / ${imagesZipProgress.total} 张`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-600 transition-all duration-200"
|
||||
style={{
|
||||
width: pdfProgress.total
|
||||
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
|
||||
: '0%',
|
||||
width: pdfGenerating
|
||||
? pdfProgress.total
|
||||
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
|
||||
: '0%'
|
||||
: imagesZipProgress.phase === 'zip'
|
||||
? `${imagesZipProgress.zipPercent ?? 0}%`
|
||||
: imagesZipProgress.phase === 'save'
|
||||
? '100%'
|
||||
: imagesZipProgress.total
|
||||
? `${(imagesZipProgress.done / imagesZipProgress.total) * 100}%`
|
||||
: '0%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
258
src/colorUtils.ts
Normal file
258
src/colorUtils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { PrintColorMode, RecordRow, TemplateElement } from './types';
|
||||
import { resolveContent, type ContentResolveMode } from './utils';
|
||||
|
||||
export interface RenderColorContext {
|
||||
printColorMode?: PrintColorMode;
|
||||
row: RecordRow | null;
|
||||
variableDefaults?: Record<string, string> | null;
|
||||
mode?: ContentResolveMode;
|
||||
rowIndex?: number;
|
||||
}
|
||||
|
||||
export function colorValueUsesVariable(value?: string): boolean {
|
||||
return !!value?.includes('{');
|
||||
}
|
||||
|
||||
export function isTransparentColorValue(value?: string): boolean {
|
||||
if (!value) return true;
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed === '' || trimmed === 'transparent';
|
||||
}
|
||||
|
||||
function clampByte(n: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(n)));
|
||||
}
|
||||
|
||||
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed.startsWith('#')) return null;
|
||||
let hex = trimmed.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map((c) => c + c).join('');
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
if (hex.length === 8) {
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
a: parseInt(hex.slice(6, 8), 16) / 255,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRgbColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||
const rgba = color.match(
|
||||
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i
|
||||
);
|
||||
if (!rgba) return null;
|
||||
return {
|
||||
r: Math.round(Number(rgba[1])),
|
||||
g: Math.round(Number(rgba[2])),
|
||||
b: Math.round(Number(rgba[3])),
|
||||
a: rgba[4] !== undefined ? Math.min(1, Math.max(0, Number(rgba[4]))) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCssColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === 'transparent') return null;
|
||||
return parseHexColor(trimmed) ?? parseRgbColor(trimmed);
|
||||
}
|
||||
|
||||
function formatRgba(r: number, g: number, b: number, a = 1): string {
|
||||
if (a >= 1) return `rgb(${r},${g},${b})`;
|
||||
return `rgba(${r},${g},${b},${Math.round(a * 1000) / 1000})`;
|
||||
}
|
||||
|
||||
function rgbToCmyk(r: number, g: number, b: number) {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const k = 1 - Math.max(rn, gn, bn);
|
||||
if (k >= 1) return { c: 0, m: 0, y: 0, k: 1 };
|
||||
const c = (1 - rn - k) / (1 - k);
|
||||
const m = (1 - gn - k) / (1 - k);
|
||||
const y = (1 - bn - k) / (1 - k);
|
||||
return { c, m, y, k };
|
||||
}
|
||||
|
||||
function cmykToRgb(c: number, m: number, y: number, k: number) {
|
||||
return {
|
||||
r: 255 * (1 - c) * (1 - k),
|
||||
g: 255 * (1 - m) * (1 - k),
|
||||
b: 255 * (1 - y) * (1 - k),
|
||||
};
|
||||
}
|
||||
|
||||
/** 灰成分替换(GCR),中性色更多走 K 通道,印刷更稳定 */
|
||||
function applyGrayComponentReplacement(c: number, m: number, y: number, k: number) {
|
||||
const gray = Math.min(c, m, y);
|
||||
if (gray <= 0.01) return { c, m, y, k };
|
||||
const gcr = gray * 0.85;
|
||||
return {
|
||||
c: c - gcr,
|
||||
m: m - gcr,
|
||||
y: y - gcr,
|
||||
k: Math.min(1, k + gcr),
|
||||
};
|
||||
}
|
||||
|
||||
/** 总墨量限制(TAC),典型 280–300% */
|
||||
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.8) {
|
||||
const total = c + m + y + k;
|
||||
if (total <= maxInk || total <= 0) return { c, m, y, k };
|
||||
const scale = maxInk / total;
|
||||
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
|
||||
}
|
||||
|
||||
/** 网点扩大 / 纸张吸墨:印刷比屏幕略深 */
|
||||
function applyDotGain(r: number, g: number, b: number, amount: number): [number, number, number] {
|
||||
const factor = 1 - amount;
|
||||
return [clampByte(r * factor), clampByte(g * factor), clampByte(b * factor)];
|
||||
}
|
||||
|
||||
/** 轻度去饱和,逼近油墨叠色效果 */
|
||||
function applyDesaturation(r: number, g: number, b: number, saturation: number): [number, number, number] {
|
||||
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
return [
|
||||
clampByte(lum + (r - lum) * saturation),
|
||||
clampByte(lum + (g - lum) * saturation),
|
||||
clampByte(lum + (b - lum) * saturation),
|
||||
];
|
||||
}
|
||||
|
||||
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
|
||||
if (mode === 'grayscale') {
|
||||
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return [lum, lum, lum];
|
||||
}
|
||||
|
||||
let nr = r;
|
||||
let ng = g;
|
||||
let nb = b;
|
||||
|
||||
if (mode === 'cmyk') {
|
||||
let { c, m, y, k } = rgbToCmyk(nr, ng, nb);
|
||||
({ c, m, y, k } = applyGrayComponentReplacement(c, m, y, k));
|
||||
({ c, m, y, k } = limitTotalInk(c, m, y, k));
|
||||
const rgb = cmykToRgb(c, m, y, k);
|
||||
nr = rgb.r;
|
||||
ng = rgb.g;
|
||||
nb = rgb.b;
|
||||
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.07);
|
||||
return [nr, ng, nb];
|
||||
}
|
||||
|
||||
// rgb:轻度印刷补偿(设计预览与导出一致,缩小与实机色差)
|
||||
[nr, ng, nb] = applyDesaturation(nr, ng, nb, 0.9);
|
||||
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.04);
|
||||
return [nr, ng, nb];
|
||||
}
|
||||
|
||||
export function applyPrintColorModeToImageData(
|
||||
imageData: ImageData,
|
||||
mode: PrintColorMode = 'rgb'
|
||||
): void {
|
||||
const { data } = imageData;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha === 0) continue;
|
||||
const [nr, ng, nb] = transformRgbPixel(data[i], data[i + 1], data[i + 2], mode);
|
||||
data[i] = nr;
|
||||
data[i + 1] = ng;
|
||||
data[i + 2] = nb;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPrintColorModeToCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
mode: PrintColorMode = 'rgb'
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
applyPrintColorModeToImageData(imageData, mode);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
|
||||
if (isTransparentColorValue(color)) return color;
|
||||
const parsed = parseCssColor(color);
|
||||
if (!parsed) return color;
|
||||
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
|
||||
return formatRgba(r, g, b, parsed.a);
|
||||
}
|
||||
|
||||
/** 解析颜色字面量或 {变量} */
|
||||
export function resolveRenderableColor(
|
||||
raw: string | undefined,
|
||||
ctx: RenderColorContext,
|
||||
fallback?: string
|
||||
): string | undefined {
|
||||
const source = (raw ?? fallback ?? '').trim();
|
||||
if (!source) return undefined;
|
||||
if (isTransparentColorValue(source)) return 'transparent';
|
||||
|
||||
let resolved = source;
|
||||
if (colorValueUsesVariable(source)) {
|
||||
resolved = resolveContent(
|
||||
source,
|
||||
ctx.row,
|
||||
ctx.rowIndex ?? 0,
|
||||
ctx.variableDefaults,
|
||||
{ mode: ctx.mode ?? 'design' }
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 为渲染解析元素上的颜色字段(含变量) */
|
||||
export function resolveElementColorsForRender(
|
||||
elem: TemplateElement,
|
||||
ctx: RenderColorContext
|
||||
): TemplateElement {
|
||||
const textColor = resolveRenderableColor(elem.textColor, ctx, '#111827');
|
||||
const backgroundColor = resolveRenderableColor(elem.backgroundColor, ctx);
|
||||
const borderColor = resolveRenderableColor(elem.borderColor, ctx, '#111827');
|
||||
const tableBorderColor = resolveRenderableColor(elem.tableBorderColor, ctx, '#374151');
|
||||
|
||||
const patchBorderSide = (value?: string) => {
|
||||
if (!value?.trim()) return value;
|
||||
return resolveRenderableColor(value, ctx, borderColor ?? '#111827');
|
||||
};
|
||||
|
||||
return {
|
||||
...elem,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
tableBorderColor,
|
||||
borderColorTop: patchBorderSide(elem.borderColorTop),
|
||||
borderColorRight: patchBorderSide(elem.borderColorRight),
|
||||
borderColorBottom: patchBorderSide(elem.borderColorBottom),
|
||||
borderColorLeft: patchBorderSide(elem.borderColorLeft),
|
||||
};
|
||||
}
|
||||
|
||||
/** 颜色选择器可用的预览色(变量色时回退默认色) */
|
||||
export function colorPickerPreviewValue(value: string | undefined, fallback: string): string {
|
||||
if (!value || isTransparentColorValue(value) || colorValueUsesVariable(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = parseCssColor(value);
|
||||
if (!parsed) return fallback;
|
||||
if (parsed.a < 1) return fallback;
|
||||
const hex = (n: number) => n.toString(16).padStart(2, '0');
|
||||
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}`;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ interface DialogRequest {
|
||||
interface AppDialogContextValue {
|
||||
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
|
||||
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
|
||||
showToast: (message: string, options?: { variant?: AppDialogVariant; duration?: number }) => void;
|
||||
}
|
||||
|
||||
const AppDialogContext = createContext<AppDialogContextValue | null>(null);
|
||||
@@ -147,8 +148,40 @@ function AppDialogModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AppToast({
|
||||
message,
|
||||
variant = 'success',
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string;
|
||||
variant: AppDialogVariant;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const meta = VARIANT_META[variant];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(onDismiss, 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-1/2 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-[10001] -translate-x-1/2 pointer-events-none"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl shadow-lg border border-black/10 bg-[#2a2a2a] text-[#f0f0f0] text-[12px] font-medium max-w-[min(90vw,20rem)]">
|
||||
<span className="shrink-0">{meta.icon}</span>
|
||||
<span className="truncate">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogRequest | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; variant: AppDialogVariant } | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
|
||||
const showAlert = useCallback((message: string, options?: AlertOptions) => {
|
||||
const variant = options?.variant ?? 'info';
|
||||
@@ -180,6 +213,28 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback(() => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = null;
|
||||
}
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, options?: { variant?: AppDialogVariant; duration?: number }) => {
|
||||
dismissToast();
|
||||
const variant = options?.variant ?? 'success';
|
||||
setToast({ message, variant });
|
||||
const duration = options?.duration ?? 2000;
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
toastTimerRef.current = null;
|
||||
setToast(null);
|
||||
}, duration);
|
||||
},
|
||||
[dismissToast]
|
||||
);
|
||||
|
||||
const closeDialog = useCallback((confirmed: boolean) => {
|
||||
setDialog((current) => {
|
||||
current?.resolve(confirmed);
|
||||
@@ -188,9 +243,12 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppDialogContext.Provider value={{ showAlert, showConfirm }}>
|
||||
<AppDialogContext.Provider value={{ showAlert, showConfirm, showToast }}>
|
||||
{children}
|
||||
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
|
||||
{toast && (
|
||||
<AppToast message={toast.message} variant={toast.variant} onDismiss={dismissToast} />
|
||||
)}
|
||||
</AppDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import type { PrintColorMode } from '../types';
|
||||
import { ContentResolveMode } from '../utils';
|
||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||
|
||||
@@ -14,8 +15,12 @@ interface CanvasLabelImageProps {
|
||||
variableDefaults?: Record<string, string> | null;
|
||||
/** design=设计预览(空变量显示占位);output=排版/打印(空值隐藏图形元素) */
|
||||
mode?: ContentResolveMode;
|
||||
referenceBackground?: string;
|
||||
referenceBackgroundOpacity?: number;
|
||||
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
|
||||
renderScale?: number;
|
||||
printColorMode?: PrintColorMode;
|
||||
}
|
||||
|
||||
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
@@ -28,7 +33,11 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
transparentBackground = false,
|
||||
variableDefaults = null,
|
||||
mode = 'design' as ContentResolveMode,
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
renderScale = 16,
|
||||
printColorMode = 'cmyk',
|
||||
}) => {
|
||||
const [dataUrl, setDataUrl] = useState<string>('');
|
||||
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
||||
@@ -54,6 +63,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
variableDefaults,
|
||||
mode,
|
||||
scale: renderScale,
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
printColorMode,
|
||||
})
|
||||
.then((url) => {
|
||||
if (!active) return;
|
||||
@@ -88,6 +101,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||
transparentBackground,
|
||||
mode,
|
||||
renderScale,
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity,
|
||||
referenceBackgroundFit,
|
||||
printColorMode,
|
||||
]);
|
||||
|
||||
if (initialLoading && !dataUrl) {
|
||||
|
||||
74
src/components/ColorValueField.tsx
Normal file
74
src/components/ColorValueField.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { colorPickerPreviewValue, colorValueUsesVariable } from '../colorUtils';
|
||||
import { CommitInput } from './CommitInput';
|
||||
|
||||
interface ColorValueFieldProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
variableOptions?: string[];
|
||||
defaultColor?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const ColorValueField: React.FC<ColorValueFieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
variableOptions = [],
|
||||
defaultColor = '#111827',
|
||||
placeholder = '#111827',
|
||||
className = '',
|
||||
hint,
|
||||
}) => {
|
||||
const usesVariable = colorValueUsesVariable(value);
|
||||
const pickerValue = colorPickerPreviewValue(value, defaultColor);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="ps-field-label">{label}</label>}
|
||||
<div className="flex gap-1 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={pickerValue}
|
||||
disabled={usesVariable}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="color-input shrink-0 disabled:opacity-40"
|
||||
title={usesVariable ? '当前为变量颜色,请使用文本框编辑' : undefined}
|
||||
/>
|
||||
<CommitInput
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onCommit={onChange}
|
||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
{variableOptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{variableOptions.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const token = `{${v}}`;
|
||||
onChange(value.includes(token) ? value : value ? `${value} ${token}` : token);
|
||||
}}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{`{${v}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hint && <p className="text-[9px] text-[#666] mt-1 leading-relaxed">{hint}</p>}
|
||||
{!hint && variableOptions.length > 0 && (
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
支持 hex/rgb/transparent 或 {`{变量名}`};排版印刷色彩模式下按行数据解析
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -86,3 +86,75 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CommitTextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> {
|
||||
value: string;
|
||||
onCommit: (val: string) => void;
|
||||
}
|
||||
|
||||
/** 失焦提交型多行输入框 */
|
||||
export const CommitTextarea: React.FC<CommitTextareaProps> = ({
|
||||
value,
|
||||
onCommit,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
...props
|
||||
}) => {
|
||||
const [localVal, setLocalVal] = useState<string>(value);
|
||||
const focusedRef = useRef(false);
|
||||
const localValRef = useRef(localVal);
|
||||
const valueRef = useRef(value);
|
||||
const onCommitRef = useRef(onCommit);
|
||||
localValRef.current = localVal;
|
||||
valueRef.current = value;
|
||||
onCommitRef.current = onCommit;
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) {
|
||||
setLocalVal(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (focusedRef.current && localValRef.current !== valueRef.current) {
|
||||
onCommitRef.current(localValRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitIfChanged = () => {
|
||||
if (localValRef.current !== valueRef.current) {
|
||||
onCommitRef.current(localValRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
focusedRef.current = false;
|
||||
onBlur?.(e);
|
||||
commitIfChanged();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
(e.target as HTMLTextAreaElement).blur();
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
focusedRef.current = true;
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,71 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
|
||||
import { buildPrintablePages } from '../utils';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow, VisibilityRule } from '../types';
|
||||
import {
|
||||
buildPrintablePages,
|
||||
extractTemplateVariables,
|
||||
getDataFilteredIndices,
|
||||
} from '../utils';
|
||||
import { PageInput } from './PreviewGrid';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
|
||||
|
||||
interface DataRangeFieldsProps {
|
||||
variant?: 'ps' | 'mobile';
|
||||
exportRange: ExportRangeConfig;
|
||||
onChangeExportRange: (range: ExportRangeConfig) => void;
|
||||
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
|
||||
rows: RecordRow[];
|
||||
paper: PaperConfig;
|
||||
template: LabelTemplate;
|
||||
disabled?: boolean;
|
||||
/** 排版过滤在无数据时不可用;数据过滤始终可编辑 */
|
||||
layoutDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
||||
variant = 'ps',
|
||||
exportRange,
|
||||
onChangeExportRange,
|
||||
onChangeDataFilterRules,
|
||||
rows,
|
||||
paper,
|
||||
template,
|
||||
disabled = false,
|
||||
layoutDisabled = false,
|
||||
}) => {
|
||||
const isMobile = variant === 'mobile';
|
||||
const rangeStats = buildPrintablePages(rows, paper, template, exportRange);
|
||||
const variableDefaults = template.variableDefaults ?? null;
|
||||
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
|
||||
const dataFilteredCount = useMemo(
|
||||
() => getDataFilteredIndices(rows, template, variableDefaults).length,
|
||||
[rows, template, variableDefaults]
|
||||
);
|
||||
const rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults);
|
||||
|
||||
const patchRange = (patch: Partial<ExportRangeConfig>) => {
|
||||
onChangeExportRange({ ...exportRange, ...patch });
|
||||
};
|
||||
|
||||
const sectionTitleClass = isMobile ? 'mobile-field-label mb-1 block' : 'ps-section-title';
|
||||
const hintClass = isMobile ? 'text-[10px] text-slate-500' : 'text-[10px] text-[#888]';
|
||||
|
||||
return (
|
||||
<div className={isMobile ? 'space-y-4' : 'space-y-2'}>
|
||||
{!isMobile && <div className="ps-section-title">数据范围</div>}
|
||||
<div>
|
||||
{isMobile && <label className="mobile-field-label">范围</label>}
|
||||
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
|
||||
<FieldSelect
|
||||
value={exportRange.mode}
|
||||
disabled={disabled}
|
||||
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
|
||||
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
|
||||
>
|
||||
<option value="all">全部数据</option>
|
||||
<option value="odd">仅限奇数序号的行</option>
|
||||
<option value="even">仅限偶数序号的行</option>
|
||||
<option value="custom">自定义行序号</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{exportRange.mode === 'custom' && (
|
||||
<div>
|
||||
{isMobile ? (
|
||||
<label className="mobile-field-label">自定义序号</label>
|
||||
) : (
|
||||
<label className="ps-field-label">自定义序号</label>
|
||||
)}
|
||||
<PageInput
|
||||
type="text"
|
||||
value={exportRange.custom ?? ''}
|
||||
disabled={disabled}
|
||||
onCommit={(val) => patchRange({ custom: val })}
|
||||
placeholder="如 1,3,5-10"
|
||||
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'}
|
||||
<div className={isMobile ? 'space-y-4' : 'space-y-3'}>
|
||||
<div className="space-y-2">
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className={sectionTitleClass}>排版过滤</div>
|
||||
<p className={hintClass}>仅影响当前导出会话</p>
|
||||
<div>
|
||||
{isMobile && <label className="mobile-field-label">范围</label>}
|
||||
<FieldSelect
|
||||
value={exportRange.mode}
|
||||
disabled={layoutDisabled}
|
||||
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
|
||||
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
|
||||
>
|
||||
<option value="all">全部数据</option>
|
||||
<option value="odd">仅限奇数序号的行</option>
|
||||
<option value="even">仅限偶数序号的行</option>
|
||||
<option value="custom">自定义行序号</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{exportRange.mode === 'custom' && (
|
||||
<div>
|
||||
{isMobile ? (
|
||||
<label className="mobile-field-label">自定义序号</label>
|
||||
) : (
|
||||
<label className="ps-field-label">自定义序号</label>
|
||||
)}
|
||||
<PageInput
|
||||
type="text"
|
||||
value={exportRange.custom ?? ''}
|
||||
disabled={layoutDisabled}
|
||||
onCommit={(val) => patchRange({ custom: val })}
|
||||
placeholder="如 1,3,5-10"
|
||||
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className={hintClass}>
|
||||
最终范围:{rangeStats.selectedCount} / {rows.length} 行
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className={sectionTitleClass}>数据过滤</div>
|
||||
<p className={hintClass}>条件写入模板;全部满足的数据行才参与排版与导出。</p>
|
||||
<VariableFilterRulesEditor
|
||||
rules={template.dataFilterRules ?? []}
|
||||
templateVariables={templateVariables}
|
||||
emptyHint="尚未添加过滤条件,将使用全部数据行"
|
||||
description="按数据行与变量默认值判断;上传数据后可预览过滤结果。"
|
||||
addTitle="添加数据过滤条件"
|
||||
editTitle="编辑数据过滤条件"
|
||||
onChangeRules={onChangeDataFilterRules}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<p className={hintClass}>
|
||||
数据过滤后:{dataFilteredCount} / {rows.length} 行
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TemplateElement } from '../types';
|
||||
import { mmToPx } from '../utils';
|
||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||
|
||||
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
|
||||
export function getFloatPreviewPaddingMm(element: TemplateElement): number {
|
||||
if (element.type !== 'table') return 0;
|
||||
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
|
||||
}
|
||||
|
||||
interface ElementFloatPreviewProps {
|
||||
element: TemplateElement;
|
||||
renderScale: number;
|
||||
@@ -17,10 +23,11 @@ export async function renderElementFloatPreview(
|
||||
renderScale: number,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): Promise<string> {
|
||||
const padMm = getFloatPreviewPaddingMm(element);
|
||||
return renderLabelToDataUrl({
|
||||
widthMm: element.width,
|
||||
heightMm: element.height,
|
||||
elements: [{ ...element, x: 0, y: 0 }],
|
||||
widthMm: element.width + padMm * 2,
|
||||
heightMm: element.height + padMm * 2,
|
||||
elements: [{ ...element, x: padMm, y: padMm }],
|
||||
rowData: null,
|
||||
rowIndex: 0,
|
||||
showBorder: false,
|
||||
@@ -67,10 +74,10 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
||||
};
|
||||
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
||||
|
||||
const left = mmToPx(element.x, displayZoom);
|
||||
const top = mmToPx(element.y, displayZoom);
|
||||
const width = mmToPx(element.width, displayZoom);
|
||||
const height = mmToPx(element.height, displayZoom);
|
||||
const padMm = getFloatPreviewPaddingMm(element);
|
||||
const padPx = mmToPx(padMm, displayZoom);
|
||||
const boxW = mmToPx(element.width, displayZoom);
|
||||
const boxH = mmToPx(element.height, displayZoom);
|
||||
|
||||
if (!dataUrl) return null;
|
||||
|
||||
@@ -80,18 +87,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
||||
alt=""
|
||||
className="absolute pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${-padPx}px`,
|
||||
top: `${-padPx}px`,
|
||||
width: `${boxW + padPx * 2}px`,
|
||||
height: `${boxH + padPx * 2}px`,
|
||||
imageRendering: 'auto',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
|
||||
if (placeholder?.classList.contains('resize-float-placeholder')) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
339
src/components/ElementMaskDialog.tsx
Normal file
339
src/components/ElementMaskDialog.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import type { ElementMaskConfig, TemplateElement } from '../types';
|
||||
import { createDefaultElementMask } from '../elementMask';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { CommitInput } from './CommitInput';
|
||||
|
||||
interface ElementMaskDialogProps {
|
||||
open: boolean;
|
||||
element: TemplateElement | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (mask: ElementMaskConfig | undefined) => void;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
||||
open,
|
||||
element,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [draft, setDraft] = useState<ElementMaskConfig>({ enabled: true, type: 'rect' });
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !element) return;
|
||||
const initial = element.mask?.enabled
|
||||
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
|
||||
: createDefaultElementMask(element);
|
||||
draftRef.current = initial;
|
||||
setDraft(initial);
|
||||
}, [open, element]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !element || typeof document === 'undefined') return null;
|
||||
|
||||
const regionW = draft.width ?? element.width;
|
||||
const regionH = draft.height ?? element.height;
|
||||
const maskType = draft.type ?? 'rect';
|
||||
const canConfirm = maskType !== 'image' || !!draft.image?.trim();
|
||||
|
||||
const patchDraft = (patch: Partial<ElementMaskConfig>) => {
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
draftRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const patchDraftCorner = (key: keyof ElementMaskConfig, val: string) => {
|
||||
if (val.trim() === '') {
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
draftRef.current = next;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
|
||||
};
|
||||
|
||||
const normalizeDraft = (source: ElementMaskConfig): ElementMaskConfig => ({
|
||||
...source,
|
||||
enabled: true,
|
||||
x: round1(Number(source.x) || 0),
|
||||
y: round1(Number(source.y) || 0),
|
||||
width: Math.max(0.5, round1(Number(source.width) || element.width)),
|
||||
height: Math.max(0.5, round1(Number(source.height) || element.height)),
|
||||
borderRadius: Math.max(0, round1(Number(source.borderRadius) || 0)),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget as HTMLFormElement;
|
||||
const active = document.activeElement;
|
||||
|
||||
const submit = () => {
|
||||
if (!canConfirm) return;
|
||||
onConfirm(normalizeDraft(draftRef.current));
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (active instanceof HTMLElement && form.contains(active)) {
|
||||
active.blur();
|
||||
setTimeout(submit, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
submit();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onConfirm(undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="ps-modal-overlay" role="presentation">
|
||||
<div
|
||||
className="ps-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="element-mask-dialog-title"
|
||||
>
|
||||
<div className="ps-modal-header">
|
||||
<h3 id="element-mask-dialog-title" className="ps-modal-title">
|
||||
蒙版设置 — {element.name}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} className="ps-modal-close" aria-label="关闭">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="ps-modal-body space-y-3">
|
||||
<p className="text-[11px] text-[#888] leading-relaxed">
|
||||
蒙版作用于当前图层(背景、内容与边框);先在离屏画布合成,再按蒙版裁剪后绘制到画布。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label">蒙版方向</label>
|
||||
<FieldSelect
|
||||
value={draft.mode ?? 'include'}
|
||||
onChange={(e) =>
|
||||
patchDraft({
|
||||
mode: e.target.value as ElementMaskConfig['mode'],
|
||||
})
|
||||
}
|
||||
className="ps-field text-[11px]"
|
||||
>
|
||||
<option value="include">正向(保留区域内)</option>
|
||||
<option value="exclude">反向(保留区域外)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label">蒙版类型</label>
|
||||
<FieldSelect
|
||||
value={maskType}
|
||||
onChange={(e) =>
|
||||
patchDraft({
|
||||
type: e.target.value as ElementMaskConfig['type'],
|
||||
})
|
||||
}
|
||||
className="ps-field text-[11px]"
|
||||
>
|
||||
<option value="rect">矩形</option>
|
||||
<option value="image">图片</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ps-field-label">X 偏移 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={draft.x ?? 0}
|
||||
onCommit={(val) => patchDraft({ x: round1(Number(val) || 0) })}
|
||||
className="ps-field font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">Y 偏移 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={draft.y ?? 0}
|
||||
onCommit={(val) => patchDraft({ y: round1(Number(val) || 0) })}
|
||||
className="ps-field font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">宽度 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={regionW}
|
||||
onCommit={(val) =>
|
||||
patchDraft({
|
||||
width: Math.max(0.5, round1(Number(val) || element.width)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">高度 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={regionH}
|
||||
onCommit={(val) =>
|
||||
patchDraft({
|
||||
height: Math.max(0.5, round1(Number(val) || element.height)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{maskType === 'rect' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="ps-field-label">圆角 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.5"
|
||||
value={draft.borderRadius ?? 0}
|
||||
onCommit={(val) =>
|
||||
patchDraft({ borderRadius: Math.max(0, round1(Number(val) || 0)) })
|
||||
}
|
||||
className="ps-field font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
['borderRadiusTL', '左上'],
|
||||
['borderRadiusTR', '右上'],
|
||||
['borderRadiusBL', '左下'],
|
||||
['borderRadiusBR', '右下'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.5"
|
||||
value={draft[key] ?? ''}
|
||||
onCommit={(val) => patchDraftCorner(key, val)}
|
||||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{maskType === 'image' && (
|
||||
<div className="space-y-2">
|
||||
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
上传蒙版图片
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => patchDraft({ image: reader.result as string });
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||
按图片亮度控制蒙版:白色区域保留内容,黑色区域裁剪;反向模式下黑白互换。
|
||||
</p>
|
||||
{draft.image && (
|
||||
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
|
||||
<img
|
||||
src={draft.image}
|
||||
alt="蒙版预览"
|
||||
className="block max-h-28 mx-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="ps-field-label">图片适应方式</label>
|
||||
<FieldSelect
|
||||
value={draft.imageFit ?? 'fill'}
|
||||
onChange={(e) =>
|
||||
patchDraft({
|
||||
imageFit: e.target.value as ElementMaskConfig['imageFit'],
|
||||
})
|
||||
}
|
||||
className="ps-field text-[11px]"
|
||||
>
|
||||
<option value="fill">拉伸 (fill)</option>
|
||||
<option value="cover">覆盖 (cover)</option>
|
||||
<option value="contain">包含 (contain)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ps-modal-footer">
|
||||
{element.mask && element.mask.enabled !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="ps-btn text-[11px] text-red-400 hover:text-red-300 mr-auto"
|
||||
>
|
||||
移除蒙版
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="ps-btn text-[11px]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canConfirm}
|
||||
className="ps-btn ps-btn-primary text-[11px] disabled:opacity-50"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
66
src/components/ExportImageFilterFields.tsx
Normal file
66
src/components/ExportImageFilterFields.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import type { ElementVisibilityMode, VisibilityRule } from '../types';
|
||||
import { isExportImageFilterConditional } from '../utils';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
|
||||
|
||||
interface ExportImageFilterFieldsProps {
|
||||
filterMode?: ElementVisibilityMode;
|
||||
filterRules?: VisibilityRule[];
|
||||
templateVariables: string[];
|
||||
disabled?: boolean;
|
||||
onChangeFilterMode: (mode: ElementVisibilityMode) => void;
|
||||
onChangeFilterRules: (rules: VisibilityRule[]) => void;
|
||||
}
|
||||
|
||||
export const ExportImageFilterFields: React.FC<ExportImageFilterFieldsProps> = ({
|
||||
filterMode = 'always' as ElementVisibilityMode,
|
||||
filterRules = [],
|
||||
templateVariables,
|
||||
disabled = false,
|
||||
onChangeFilterMode,
|
||||
onChangeFilterRules,
|
||||
}) => {
|
||||
const conditional = isExportImageFilterConditional({ exportImageFilterMode: filterMode });
|
||||
|
||||
const setRules = (next: VisibilityRule[]) => {
|
||||
onChangeFilterMode('conditional');
|
||||
onChangeFilterRules(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="ps-field-label mb-0">导出过滤</label>
|
||||
<FieldSelect
|
||||
value={conditional ? 'conditional' : 'always'}
|
||||
onChange={(e) => {
|
||||
const nextConditional = e.target.value === 'conditional';
|
||||
onChangeFilterMode(nextConditional ? 'conditional' : 'always');
|
||||
if (nextConditional && (filterRules?.length ?? 0) === 0) {
|
||||
onChangeFilterRules([]);
|
||||
}
|
||||
}}
|
||||
className="ps-field text-[11px]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="always">导出全部标签</option>
|
||||
<option value="conditional">按条件过滤</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
{conditional && (
|
||||
<VariableFilterRulesEditor
|
||||
rules={filterRules}
|
||||
templateVariables={templateVariables}
|
||||
disabled={disabled}
|
||||
emptyHint="尚未添加过滤条件,将导出全部标签"
|
||||
description="全部条件满足时才导出该枚标签;按当前数据行与变量默认值判断。"
|
||||
addTitle="添加导出过滤条件"
|
||||
editTitle="编辑导出过滤条件"
|
||||
onChangeRules={setRules}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, Layout, Settings } from 'lucide-react';
|
||||
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
||||
import { Database, Layout, Printer, Settings } from 'lucide-react';
|
||||
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from '../types';
|
||||
import { DataImporter } from './DataImporter';
|
||||
import { VariableMappingPanel } from './VariableMappingPanel';
|
||||
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||
import { DataRangeFields } from './DataRangeFields';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { PrintModule } from './PrintModule';
|
||||
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
|
||||
|
||||
interface ExportSidebarProps {
|
||||
importData: ImportData | null;
|
||||
@@ -20,9 +22,11 @@ interface ExportSidebarProps {
|
||||
paper: PaperConfig;
|
||||
onChangePaper: (updated: PaperConfig) => void;
|
||||
template: LabelTemplate;
|
||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||
rows: RecordRow[];
|
||||
draftExportRange: ExportRangeConfig;
|
||||
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
||||
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
|
||||
cutLine: CutLineConfig;
|
||||
onChangeCutLine: (cutLine: CutLineConfig) => void;
|
||||
previewReady: boolean;
|
||||
@@ -32,6 +36,17 @@ interface ExportSidebarProps {
|
||||
onApplyData: () => void;
|
||||
onRedrawPreview: () => void;
|
||||
draftSelectedCount: number;
|
||||
printing: boolean;
|
||||
onPrint: (printerId: string) => void;
|
||||
imagesExporting?: boolean;
|
||||
onExportImages?: () => void;
|
||||
exportImageFileNamePattern?: string;
|
||||
onChangeExportImageFileNamePattern?: (pattern: string) => void;
|
||||
exportImageFilterMode?: ElementVisibilityMode;
|
||||
exportImageFilterRules?: VisibilityRule[];
|
||||
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
||||
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
||||
printDisabled: boolean;
|
||||
/** 移动端精简:仅数据、映射、数据范围 */
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -48,9 +63,11 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
paper,
|
||||
onChangePaper,
|
||||
template,
|
||||
onChangeTemplate,
|
||||
rows,
|
||||
draftExportRange,
|
||||
onChangeDraftExportRange,
|
||||
onChangeDataFilterRules,
|
||||
cutLine,
|
||||
onChangeCutLine,
|
||||
previewReady,
|
||||
@@ -60,12 +77,24 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
onApplyData,
|
||||
onRedrawPreview,
|
||||
draftSelectedCount,
|
||||
printing,
|
||||
onPrint,
|
||||
imagesExporting = false,
|
||||
onExportImages,
|
||||
exportImageFileNamePattern = '',
|
||||
onChangeExportImageFileNamePattern,
|
||||
exportImageFilterMode = 'always',
|
||||
exportImageFilterRules = [],
|
||||
onChangeExportImageFilterMode,
|
||||
onChangeExportImageFilterRules,
|
||||
printDisabled,
|
||||
compact = false,
|
||||
}) => {
|
||||
type ExportPanelKey = 'data' | 'layout';
|
||||
type ExportPanelKey = 'data' | 'layout' | 'print';
|
||||
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
||||
const dataCollapsed = activePanel !== 'data';
|
||||
const layoutCollapsed = activePanel !== 'layout';
|
||||
const printCollapsed = activePanel !== 'print';
|
||||
const toggleExportPanel = (key: ExportPanelKey) => {
|
||||
setActivePanel((prev) => (prev === key ? null : key));
|
||||
};
|
||||
@@ -76,10 +105,11 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
variant={compact ? 'ps' : 'ps'}
|
||||
exportRange={draftExportRange}
|
||||
onChangeExportRange={onChangeDraftExportRange}
|
||||
onChangeDataFilterRules={onChangeDataFilterRules}
|
||||
rows={rows}
|
||||
paper={paper}
|
||||
template={template}
|
||||
disabled={rows.length === 0}
|
||||
layoutDisabled={rows.length === 0}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -129,6 +159,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
</div>
|
||||
{dataRangeSection}
|
||||
{hasData && (
|
||||
<>
|
||||
<div>
|
||||
@@ -141,7 +172,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
variableDefaults={template.variableDefaults}
|
||||
/>
|
||||
</div>
|
||||
{dataRangeSection}
|
||||
{applyDataSection}
|
||||
</>
|
||||
)}
|
||||
@@ -168,6 +198,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
templateName={templateName}
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
{dataRangeSection}
|
||||
{hasData && (
|
||||
<>
|
||||
<VariableMappingPanel
|
||||
@@ -178,7 +209,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
onChangeMapping={onChangeMapping}
|
||||
variableDefaults={template.variableDefaults}
|
||||
/>
|
||||
{dataRangeSection}
|
||||
{applyDataSection}
|
||||
</>
|
||||
)}
|
||||
@@ -201,6 +231,9 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
exportRange={draftExportRange}
|
||||
totalRowCount={rows.length}
|
||||
/>
|
||||
<div className="ps-section-divider">
|
||||
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
|
||||
</div>
|
||||
<div className="ps-section-divider space-y-2">
|
||||
<div className="ps-section-title">
|
||||
<Settings className="w-3 h-3" />
|
||||
@@ -283,6 +316,30 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
</fieldset>
|
||||
</div>
|
||||
</CollapsiblePanelSection>
|
||||
|
||||
<CollapsiblePanelSection
|
||||
sectionClassName="export-print-panel"
|
||||
collapsed={printCollapsed}
|
||||
onToggle={() => toggleExportPanel('print')}
|
||||
icon={<Printer className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||
title="打印"
|
||||
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||
>
|
||||
<PrintModule
|
||||
disabled={printDisabled}
|
||||
printing={printing}
|
||||
onPrint={onPrint}
|
||||
imagesExporting={imagesExporting}
|
||||
onExportImages={onExportImages}
|
||||
exportImageFileNamePattern={exportImageFileNamePattern}
|
||||
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
|
||||
exportImageFilterMode={exportImageFilterMode}
|
||||
exportImageFilterRules={exportImageFilterRules}
|
||||
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
|
||||
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
</CollapsiblePanelSection>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { LabelTemplate, TemplateElement } from '../types';
|
||||
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
|
||||
import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils';
|
||||
import { getSelectionBounds } from '../elementAlign';
|
||||
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||||
import {
|
||||
createDefaultTableElement,
|
||||
getTableColWidths,
|
||||
getTableRowHeights,
|
||||
iterTableCells,
|
||||
toggleTableCellSelection,
|
||||
scaleTableTracks,
|
||||
getCellAnchor,
|
||||
getCellRectPx,
|
||||
type TableCellCoord,
|
||||
} from '../tableUtils';
|
||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
||||
import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
|
||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -16,6 +30,7 @@ import {
|
||||
Image as ImageIcon,
|
||||
Move,
|
||||
Trash2,
|
||||
Table2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LabelDesignerProps {
|
||||
@@ -23,12 +38,18 @@ interface LabelDesignerProps {
|
||||
onChange: (updated: LabelTemplate) => void;
|
||||
selectedElementIds: string[];
|
||||
onSelectElements: (ids: string[]) => void;
|
||||
selectedTableCells?: TableCellCoord[];
|
||||
onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void;
|
||||
variant?: 'desktop' | 'mobile';
|
||||
/** 微调等场景下隐藏选区框与缩放手柄 */
|
||||
suppressSelectionChrome?: boolean;
|
||||
elementClipboard?: Pick<
|
||||
ElementClipboardActions,
|
||||
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
|
||||
>;
|
||||
}
|
||||
|
||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
|
||||
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
const MIN_ELEM_W = 2;
|
||||
@@ -194,8 +215,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
onChange,
|
||||
selectedElementIds,
|
||||
onSelectElements,
|
||||
selectedTableCells = [],
|
||||
onSelectTableCells,
|
||||
variant = 'desktop',
|
||||
suppressSelectionChrome = false,
|
||||
elementClipboard,
|
||||
}) => {
|
||||
const { showConfirm } = useAppDialog();
|
||||
const selectedElementId = selectedElementIds[0] || null;
|
||||
@@ -212,6 +236,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
() => template.variableDefaults ?? null,
|
||||
[template.variableDefaults]
|
||||
);
|
||||
const printColorMode = template.paper?.printColorMode ?? 'cmyk';
|
||||
|
||||
const showReferencePreview = useMemo(
|
||||
() =>
|
||||
!!template.previewReferenceBackground &&
|
||||
template.enablePreviewReferenceBackground !== false,
|
||||
[template.previewReferenceBackground, template.enablePreviewReferenceBackground]
|
||||
);
|
||||
|
||||
const localElementsRef = useRef<TemplateElement[] | null>(null);
|
||||
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
|
||||
@@ -256,6 +288,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
startElemY: number;
|
||||
startElemW: number;
|
||||
startElemH: number;
|
||||
startTableRowHeights?: number[];
|
||||
startTableColWidths?: number[];
|
||||
groupStartPositions?: Record<string, { x: number; y: number }>;
|
||||
} | null>(null);
|
||||
|
||||
@@ -269,6 +303,15 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
|
||||
const ignoreCanvasClickRef = useRef(false);
|
||||
const dragMovedRef = useRef(false);
|
||||
const tableCellPointerRef = useRef<{
|
||||
row: number;
|
||||
col: number;
|
||||
elementId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
pointerId: number;
|
||||
dragStarted: boolean;
|
||||
} | null>(null);
|
||||
const multiTouchActiveRef = useRef(false);
|
||||
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const marqueeRef = useRef(marqueeState);
|
||||
@@ -277,17 +320,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
selectedIdsRef.current = selectedElementIds;
|
||||
const templateElementsRef = useRef(template.elements);
|
||||
templateElementsRef.current = template.elements;
|
||||
const templateRef = useRef(template);
|
||||
templateRef.current = template;
|
||||
|
||||
const addImageElement = useCallback(
|
||||
(src: string = '') => {
|
||||
const current = templateRef.current;
|
||||
const id = `image_${Date.now()}`;
|
||||
const n = template.elements.length + 1;
|
||||
const n = current.elements.length + 1;
|
||||
const newElement: TemplateElement = {
|
||||
id,
|
||||
type: 'image',
|
||||
name: `图片 ${n}`,
|
||||
x: 5,
|
||||
y: 5,
|
||||
...centerElementOnLabel(current.width, current.height, 30, 30),
|
||||
width: 30,
|
||||
height: 30,
|
||||
content: src,
|
||||
@@ -297,26 +342,30 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundOpacity: 0,
|
||||
imageFit: 'contain',
|
||||
};
|
||||
onChange({
|
||||
...template,
|
||||
elements: [...template.elements, newElement],
|
||||
...current,
|
||||
elements: [...current.elements, newElement],
|
||||
});
|
||||
onSelectElements([id]);
|
||||
setActiveTool('move');
|
||||
},
|
||||
[template, onChange, onSelectElements]
|
||||
[onChange, onSelectElements]
|
||||
);
|
||||
|
||||
const addElement = useCallback(
|
||||
(type: 'text' | 'barcode' | 'qrcode') => {
|
||||
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
||||
const current = templateRef.current;
|
||||
let newElement: TemplateElement;
|
||||
const id = `${type}_${Date.now()}`;
|
||||
const n = template.elements.length + 1;
|
||||
const n = current.elements.length + 1;
|
||||
|
||||
if (type === 'text') {
|
||||
if (type === 'table') {
|
||||
newElement = createDefaultTableElement(id, n);
|
||||
} else if (type === 'text') {
|
||||
newElement = {
|
||||
id,
|
||||
type: 'text',
|
||||
@@ -334,7 +383,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundOpacity: 0,
|
||||
};
|
||||
} else if (type === 'barcode') {
|
||||
newElement = {
|
||||
@@ -352,7 +402,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: true,
|
||||
fontFamily: 'mono',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundOpacity: 0,
|
||||
};
|
||||
} else {
|
||||
newElement = {
|
||||
@@ -370,21 +421,31 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundOpacity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const centered = centerElementOnLabel(
|
||||
current.width,
|
||||
current.height,
|
||||
newElement.width,
|
||||
newElement.height
|
||||
);
|
||||
newElement = { ...newElement, ...centered };
|
||||
|
||||
onChange({
|
||||
...template,
|
||||
elements: [...template.elements, newElement],
|
||||
...current,
|
||||
elements: [...current.elements, newElement],
|
||||
});
|
||||
onSelectElements([id]);
|
||||
setActiveTool('move');
|
||||
},
|
||||
[template, onChange, onSelectElements]
|
||||
[onChange, onSelectElements]
|
||||
);
|
||||
|
||||
const deleteSelectedElements = useCallback(async () => {
|
||||
const current = templateRef.current;
|
||||
const ids =
|
||||
selectedElementIds.length > 0
|
||||
? selectedElementIds
|
||||
@@ -392,14 +453,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
? [selectedElementId]
|
||||
: [];
|
||||
const deletable = ids.filter((id) => {
|
||||
const el = template.elements.find((item) => item.id === id);
|
||||
const el = current.elements.find((item) => item.id === id);
|
||||
return el && !el.locked;
|
||||
});
|
||||
if (deletable.length === 0) return;
|
||||
|
||||
const message =
|
||||
deletable.length === 1
|
||||
? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
||||
? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
||||
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
|
||||
|
||||
const confirmed = await showConfirm(message, {
|
||||
@@ -410,10 +471,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if (!confirmed) return;
|
||||
|
||||
const idSet = new Set(deletable);
|
||||
const remaining = template.elements.filter((el) => !idSet.has(el.id));
|
||||
onChange({ ...template, elements: remaining });
|
||||
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||||
onChange({ ...templateRef.current, elements: remaining });
|
||||
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||||
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]);
|
||||
}, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]);
|
||||
|
||||
const handleToolClick = (tool: ActiveTool) => {
|
||||
if (tool === 'move') {
|
||||
@@ -451,7 +512,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault();
|
||||
setActiveTool('move');
|
||||
onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id));
|
||||
onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id));
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
elementClipboard?.copySelectedElements();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
elementClipboard?.pasteClipboardElements();
|
||||
setActiveTool('move');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
@@ -476,9 +548,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
|
||||
const idSet = new Set(moveIds);
|
||||
e.preventDefault();
|
||||
const current = templateRef.current;
|
||||
onChange({
|
||||
...template,
|
||||
elements: template.elements.map((el) => {
|
||||
...current,
|
||||
elements: current.elements.map((el) => {
|
||||
if (!idSet.has(el.id) || el.locked) return el;
|
||||
return {
|
||||
...el,
|
||||
@@ -499,6 +572,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
addElement,
|
||||
onSelectElements,
|
||||
deleteSelectedElements,
|
||||
elementClipboard,
|
||||
]);
|
||||
|
||||
const clearInteractionCanvasState = useCallback(() => {
|
||||
@@ -883,11 +957,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
corner
|
||||
);
|
||||
|
||||
const updated = template.elements.map((el) =>
|
||||
el.id === dragState.elementId
|
||||
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
|
||||
: el
|
||||
);
|
||||
const updated = template.elements.map((el) => {
|
||||
if (el.id !== dragState.elementId) return el;
|
||||
if (
|
||||
el.type === 'table' &&
|
||||
dragState.startTableRowHeights &&
|
||||
dragState.startTableColWidths
|
||||
) {
|
||||
return scaleTableTracks(
|
||||
{ ...el, x: rect.x, y: rect.y },
|
||||
rect.w,
|
||||
rect.h,
|
||||
dragState.startElemW,
|
||||
dragState.startElemH,
|
||||
dragState.startTableRowHeights,
|
||||
dragState.startTableColWidths
|
||||
);
|
||||
}
|
||||
return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h };
|
||||
});
|
||||
pendingDragElementsRef.current = updated;
|
||||
|
||||
const paintInteractionFloat = () => {
|
||||
@@ -956,7 +1044,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
const shouldCommit =
|
||||
dragState.type === 'resize' || dragMovedRef.current;
|
||||
if (shouldCommit) {
|
||||
onChange({ ...template, elements: localElementsRef.current });
|
||||
onChange({ ...templateRef.current, elements: localElementsRef.current });
|
||||
}
|
||||
}
|
||||
localElementsRef.current = null;
|
||||
@@ -1109,6 +1197,71 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleTableCellPointerDown = (
|
||||
e: React.PointerEvent,
|
||||
element: TemplateElement,
|
||||
row: number,
|
||||
col: number
|
||||
) => {
|
||||
if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
tableCellPointerRef.current = {
|
||||
row,
|
||||
col,
|
||||
elementId: element.id,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
pointerId: e.pointerId,
|
||||
dragStarted: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTableCellPointerMove = (
|
||||
e: React.PointerEvent,
|
||||
element: TemplateElement
|
||||
) => {
|
||||
const pending = tableCellPointerRef.current;
|
||||
if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) {
|
||||
return;
|
||||
}
|
||||
if (pending.dragStarted || element.locked) return;
|
||||
|
||||
const dx = e.clientX - pending.startX;
|
||||
const dy = e.clientY - pending.startY;
|
||||
if (
|
||||
Math.abs(dx) > DRAG_START_THRESHOLD_PX ||
|
||||
Math.abs(dy) > DRAG_START_THRESHOLD_PX
|
||||
) {
|
||||
pending.dragStarted = true;
|
||||
dragMovedRef.current = true;
|
||||
tableCellPointerRef.current = null;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
handleDragStart(e, element, 'drag');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableCellPointerUp = (e: React.PointerEvent) => {
|
||||
const pending = tableCellPointerRef.current;
|
||||
if (!pending || pending.pointerId !== e.pointerId) return;
|
||||
|
||||
if (!pending.dragStarted && onSelectTableCells) {
|
||||
onSelectTableCells((prev) =>
|
||||
toggleTableCellSelection(prev, { row: pending.row, col: pending.col })
|
||||
);
|
||||
}
|
||||
tableCellPointerRef.current = null;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.PointerEvent,
|
||||
element: TemplateElement,
|
||||
@@ -1135,6 +1288,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
startElemY: element.y,
|
||||
startElemW: element.width,
|
||||
startElemH: element.height,
|
||||
startTableRowHeights:
|
||||
element.type === 'table' ? getTableRowHeights(element) : undefined,
|
||||
startTableColWidths:
|
||||
element.type === 'table' ? getTableColWidths(element) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1292,6 +1449,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
|
||||
const showUnselectedDimLayer = activeTool === 'move';
|
||||
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
|
||||
|
||||
const multiSelectionBounds = useMemo(() => {
|
||||
if (selectedElementIds.length < 2 || hideSelectionChrome) return null;
|
||||
const selected = template.elements.filter(
|
||||
(el) => selectedElementIds.includes(el.id) && !el.locked
|
||||
);
|
||||
if (selected.length < 2) return null;
|
||||
return getSelectionBounds(selected);
|
||||
}, [selectedElementIds, template.elements, hideSelectionChrome]);
|
||||
|
||||
const dragMultiSelectionBounds = useMemo(() => {
|
||||
if (
|
||||
selectedElementIds.length < 2 ||
|
||||
dragState?.type !== 'drag' ||
|
||||
!dragInteractionVisualActive ||
|
||||
dragFloatElements.length < 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return getSelectionBounds(dragFloatElements);
|
||||
}, [
|
||||
selectedElementIds.length,
|
||||
dragState?.type,
|
||||
dragInteractionVisualActive,
|
||||
dragFloatElements,
|
||||
]);
|
||||
|
||||
const resizingPreviewElement =
|
||||
dragState?.type === 'resize'
|
||||
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
|
||||
@@ -1300,12 +1484,35 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
const overlayElements = filterVisibleElements(template.elements);
|
||||
|
||||
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
|
||||
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
|
||||
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
|
||||
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动', shortcut: 'V' },
|
||||
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本', shortcut: 'T' },
|
||||
{ id: 'table', icon: <Table2 className="w-[18px] h-[18px]" />, label: '表格' },
|
||||
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
|
||||
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
||||
];
|
||||
|
||||
const mobileToolItems = useMemo<MobileToolButtonItem[]>(
|
||||
() => [
|
||||
...tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
icon: tool.icon,
|
||||
label: tool.label,
|
||||
shortcut: tool.shortcut,
|
||||
active: activeTool === tool.id,
|
||||
onClick: () => handleToolClick(tool.id),
|
||||
})),
|
||||
{
|
||||
id: 'image',
|
||||
icon: <ImageIcon className="w-[18px] h-[18px]" />,
|
||||
label: '图片',
|
||||
active: false,
|
||||
onClick: handleImageToolClick,
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
|
||||
[activeTool, handleToolClick, handleImageToolClick]
|
||||
);
|
||||
|
||||
const mainToolButtons = (
|
||||
<>
|
||||
{tools.map((tool) => (
|
||||
@@ -1410,20 +1617,27 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{dragFloatElements.map((el) => (
|
||||
{dragFloatElements.map((el) => {
|
||||
const padMm = getFloatPreviewPaddingMm(el);
|
||||
const padPx = mmToPx(padMm, displayZoom);
|
||||
const boxW = mmToPx(el.width, displayZoom);
|
||||
const boxH = mmToPx(el.height, displayZoom);
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
||||
width: `${boxW}px`,
|
||||
height: `${boxH}px`,
|
||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-drag-float-placeholder={el.id}
|
||||
className="absolute inset-0 bg-white border border-[#31a8ff]/40 shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
|
||||
className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
|
||||
/>
|
||||
<img
|
||||
ref={(node) => {
|
||||
@@ -1443,7 +1657,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
}
|
||||
}}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full pointer-events-none select-none opacity-0"
|
||||
className="absolute pointer-events-none select-none opacity-0"
|
||||
style={{
|
||||
left: `${-padPx}px`,
|
||||
top: `${-padPx}px`,
|
||||
width: `${boxW + padPx * 2}px`,
|
||||
height: `${boxH + padPx * 2}px`,
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.opacity = '1';
|
||||
@@ -1451,8 +1671,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{selectedElementIds.length === 1 && (
|
||||
<div
|
||||
className="absolute inset-0 ps-resize-outline pointer-events-none z-[3]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{dragMultiSelectionBounds && (
|
||||
<div
|
||||
className="absolute ps-resize-outline pointer-events-none z-[5]"
|
||||
style={{
|
||||
left: `${mmToPx(dragMultiSelectionBounds.minX, displayZoom)}px`,
|
||||
top: `${mmToPx(dragMultiSelectionBounds.minY, displayZoom)}px`,
|
||||
width: `${mmToPx(
|
||||
dragMultiSelectionBounds.maxX - dragMultiSelectionBounds.minX,
|
||||
displayZoom
|
||||
)}px`,
|
||||
height: `${mmToPx(
|
||||
dragMultiSelectionBounds.maxY - dragMultiSelectionBounds.minY,
|
||||
displayZoom
|
||||
)}px`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
||||
@@ -1460,27 +1705,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{resizeFloatElements.map((el) => (
|
||||
<React.Fragment key={el.id}>
|
||||
{resizeFloatElements.map((el) => {
|
||||
const left = mmToPx(el.x, displayZoom);
|
||||
const top = mmToPx(el.y, displayZoom);
|
||||
const width = mmToPx(el.width, displayZoom);
|
||||
const height = mmToPx(el.height, displayZoom);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
|
||||
key={el.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
<ElementFloatPreview
|
||||
element={el}
|
||||
renderScale={canvasRenderScale}
|
||||
displayZoom={displayZoom}
|
||||
variableDefaults={previewDefaults}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
>
|
||||
<ElementFloatPreview
|
||||
element={el}
|
||||
renderScale={canvasRenderScale}
|
||||
displayZoom={displayZoom}
|
||||
variableDefaults={previewDefaults}
|
||||
/>
|
||||
<div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
@@ -1512,7 +1764,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<div
|
||||
ref={canvasAreaRef}
|
||||
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
|
||||
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto'
|
||||
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto scrollbar-hide overscroll-contain'
|
||||
}`}
|
||||
onPointerDown={handleWorkspacePointerDown}
|
||||
onClick={(e) => {
|
||||
@@ -1536,20 +1788,27 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden"
|
||||
className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
|
||||
showReferencePreview ? '' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
||||
{template.width} mm
|
||||
</div>
|
||||
<div
|
||||
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
{template.height} mm
|
||||
</div> */}
|
||||
{showReferencePreview && (
|
||||
<img
|
||||
src={template.previewReferenceBackground}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full pointer-events-none select-none z-0"
|
||||
style={{
|
||||
objectFit: template.previewReferenceBackgroundFit ?? 'cover',
|
||||
opacity: Math.min(
|
||||
1,
|
||||
Math.max(0, (template.previewReferenceBackgroundOpacity ?? 100) / 100)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-[1]">
|
||||
<CanvasLabelImage
|
||||
widthMm={template.width}
|
||||
heightMm={template.height}
|
||||
@@ -1557,7 +1816,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
rowData={null}
|
||||
rowIndex={0}
|
||||
showBorder={false}
|
||||
transparentBackground={showReferencePreview}
|
||||
variableDefaults={previewDefaults}
|
||||
printColorMode={printColorMode}
|
||||
renderScale={canvasRenderScale}
|
||||
/>
|
||||
</div>
|
||||
@@ -1568,32 +1829,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
|
||||
}`}
|
||||
aria-hidden={!showUnselectedDim}
|
||||
>
|
||||
{overlayElements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) return null;
|
||||
if (dragState?.type === 'drag' && dragMovingIdSet?.has(element.id)) return null;
|
||||
const elemX = mmToPx(element.x, displayZoom);
|
||||
const elemY = mmToPx(element.y, displayZoom);
|
||||
const elemW = mmToPx(element.width, displayZoom);
|
||||
const elemH = mmToPx(element.height, displayZoom);
|
||||
return (
|
||||
<div
|
||||
key={`dim-${element.id}`}
|
||||
className="ps-unselected-dim absolute"
|
||||
style={{
|
||||
left: `${elemX}px`,
|
||||
top: `${elemY}px`,
|
||||
width: `${elemW}px`,
|
||||
height: `${elemH}px`,
|
||||
transform: element.rotation
|
||||
? `rotate(${element.rotation}deg)`
|
||||
: undefined,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedLiftElements && (
|
||||
@@ -1607,6 +1843,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
showBorder={false}
|
||||
transparentBackground
|
||||
variableDefaults={previewDefaults}
|
||||
printColorMode={printColorMode}
|
||||
renderScale={canvasRenderScale}
|
||||
/>
|
||||
</div>
|
||||
@@ -1645,6 +1882,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if (isBeingDragged) {
|
||||
return null;
|
||||
}
|
||||
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayElement =
|
||||
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
||||
@@ -1704,6 +1944,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBeingResized && (
|
||||
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
!hideSelectionChrome &&
|
||||
!isLocked &&
|
||||
selectedElementIds.length === 1 &&
|
||||
!isBeingResized && (
|
||||
<div
|
||||
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResizeHandles && (
|
||||
<>
|
||||
<div
|
||||
@@ -1734,9 +1989,74 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
{element.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
!hideSelectionChrome &&
|
||||
!isLocked &&
|
||||
selectedElementIds.length === 1 &&
|
||||
overlayElement.type === 'table' &&
|
||||
onSelectTableCells && (
|
||||
<div className="absolute inset-0 z-10 pointer-events-none">
|
||||
{iterTableCells(overlayElement).map(({ row, col }) => {
|
||||
const { x: cellX, y: cellY, width: cellW, height: cellH } =
|
||||
getCellRectPx(overlayElement, row, col, canvasRenderScale);
|
||||
const isCellSelected = selectedTableCells.some((c) => {
|
||||
const anchor = getCellAnchor(overlayElement, c.row, c.col);
|
||||
return anchor.row === row && anchor.col === col;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={`cell-${row}-${col}`}
|
||||
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing touch-none ${
|
||||
isCellSelected
|
||||
? 'bg-[#31a8ff]/25'
|
||||
: 'hover:bg-[#31a8ff]/10'
|
||||
}`}
|
||||
style={{
|
||||
left: `${cellX}px`,
|
||||
top: `${cellY}px`,
|
||||
width: `${cellW}px`,
|
||||
height: `${cellH}px`,
|
||||
boxShadow: isCellSelected
|
||||
? 'inset 0 0 0 1px #31a8ff'
|
||||
: undefined,
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
handleTableCellPointerDown(e, overlayElement, row, col)
|
||||
}
|
||||
onPointerMove={(e) =>
|
||||
handleTableCellPointerMove(e, overlayElement)
|
||||
}
|
||||
onPointerUp={(e) => handleTableCellPointerUp(e)}
|
||||
onPointerCancel={(e) => handleTableCellPointerUp(e)}
|
||||
title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{multiSelectionBounds && (
|
||||
<div
|
||||
className="absolute ps-resize-outline pointer-events-none z-[1005]"
|
||||
style={{
|
||||
left: `${mmToPx(multiSelectionBounds.minX, displayZoom)}px`,
|
||||
top: `${mmToPx(multiSelectionBounds.minY, displayZoom)}px`,
|
||||
width: `${mmToPx(
|
||||
multiSelectionBounds.maxX - multiSelectionBounds.minX,
|
||||
displayZoom
|
||||
)}px`,
|
||||
height: `${mmToPx(
|
||||
multiSelectionBounds.maxY - multiSelectionBounds.minY,
|
||||
displayZoom
|
||||
)}px`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1744,9 +2064,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
|
||||
{isMobileLayout ? (
|
||||
<div className="mobile-design-toolstrip">
|
||||
<div className="mobile-design-toolstrip-tools">
|
||||
{mainToolButtons}
|
||||
</div>
|
||||
<MobileMainToolButtons items={mobileToolItems} />
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageFile}
|
||||
/>
|
||||
{zoomControls}
|
||||
</div>
|
||||
) : (
|
||||
@@ -1763,18 +2088,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<span>
|
||||
文档: {template.width} × {template.height} mm
|
||||
</span>
|
||||
{selectedElemObj && (
|
||||
{selectedElemObj && selectedElementIds.length === 1 && (
|
||||
<span className="text-[#31a8ff]">
|
||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||
</span>
|
||||
)}
|
||||
{selectedElementIds.length > 1 && (
|
||||
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
||||
{multiSelectionBounds && (
|
||||
<span className="text-emerald-400">
|
||||
| 已选 {selectedElementIds.length} 个 — X:{multiSelectionBounds.minX} Y:
|
||||
{multiSelectionBounds.minY} mm
|
||||
</span>
|
||||
)}
|
||||
{activeTool === 'move' && (
|
||||
<span className="text-[#888] hidden sm:inline">
|
||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
240
src/components/LabelLayoutSizeCalculator.tsx
Normal file
240
src/components/LabelLayoutSizeCalculator.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calculator, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { PaperConfig, PaperType } from '../types';
|
||||
import { computeLabelSizeFromLayout, DEFAULT_PAPER_CONFIG } from '../utils';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
|
||||
const fieldClass =
|
||||
'w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none';
|
||||
const labelClass = 'block text-[11px] font-semibold text-gray-500 mb-1.5';
|
||||
|
||||
interface LabelLayoutSizeCalculatorProps {
|
||||
onCalculated: (result: { width: number; height: number; paper: PaperConfig }) => void;
|
||||
}
|
||||
|
||||
function handlePaperTypeChange(paper: PaperConfig, type: PaperType): PaperConfig {
|
||||
let width = 210;
|
||||
let height = 297;
|
||||
if (type === 'A5') {
|
||||
width = 148;
|
||||
height = 210;
|
||||
} else if (type === 'A6') {
|
||||
width = 105;
|
||||
height = 148;
|
||||
} else if (type === 'custom') {
|
||||
width = paper.width;
|
||||
height = paper.height;
|
||||
}
|
||||
return { ...paper, type, width, height };
|
||||
}
|
||||
|
||||
export const LabelLayoutSizeCalculator: React.FC<LabelLayoutSizeCalculatorProps> = ({
|
||||
onCalculated,
|
||||
}) => {
|
||||
const { showAlert } = useAppDialog();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [paper, setPaper] = useState<PaperConfig>(DEFAULT_PAPER_CONFIG);
|
||||
const [cols, setCols] = useState('3');
|
||||
const [rows, setRows] = useState('8');
|
||||
|
||||
const patchPaper = (patch: Partial<PaperConfig>) => {
|
||||
setPaper((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const handleCalculate = async () => {
|
||||
const colCount = Math.max(1, Math.min(99, Number(cols) || 0));
|
||||
const rowCount = Math.max(1, Math.min(99, Number(rows) || 0));
|
||||
if (!cols.trim() || !rows.trim() || colCount < 1 || rowCount < 1) {
|
||||
await showAlert('请输入有效的排版列数与行数。', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const size = computeLabelSizeFromLayout(paper, colCount, rowCount);
|
||||
if (!size) {
|
||||
await showAlert(
|
||||
'无法根据当前纸张、边距与间距计算出有效标签尺寸(单边至少 10 mm)。请调整参数后重试。',
|
||||
{ variant: 'warning' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onCalculated({ width: size.width, height: size.height, paper });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-gray-50 hover:bg-gray-100 text-left transition cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-[12px] font-medium text-gray-700">
|
||||
<Calculator className="w-4 h-4 text-indigo-500" />
|
||||
根据页面排版计算标签尺寸
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-3 py-3 space-y-3 border-t border-gray-200 bg-white">
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
设置纸张、页边距与 n×m 排版后,将自动反算单个标签的宽高并填入上方表单。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>纸张类型</label>
|
||||
<FieldSelect
|
||||
value={paper.type}
|
||||
onChange={(e) => patchPaper(handlePaperTypeChange(paper, e.target.value as PaperType))}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="A4">A4 (210×297 mm)</option>
|
||||
<option value="A5">A5 (148×210 mm)</option>
|
||||
<option value="A6">A6 (105×148 mm)</option>
|
||||
<option value="custom">自定义尺寸</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
{paper.type === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>纸张宽 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="10"
|
||||
value={paper.width}
|
||||
onCommit={(val) => patchPaper({ width: Math.max(10, Number(val) || 10) })}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>纸张高 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="10"
|
||||
value={paper.height}
|
||||
onCommit={(val) => patchPaper({ height: Math.max(10, Number(val) || 10) })}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>进纸方向</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchPaper({ orientation: 'portrait' })}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
|
||||
paper.orientation === 'portrait'
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchPaper({ orientation: 'landscape' })}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
|
||||
paper.orientation === 'landscape'
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>排版列数 n</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={cols}
|
||||
onCommit={(val) => setCols(val)}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>排版行数 m</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={rows}
|
||||
onCommit={(val) => setRows(val)}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(
|
||||
[
|
||||
['上边距', 'marginTop', paper.marginTop],
|
||||
['下边距', 'marginBottom', paper.marginBottom],
|
||||
['左边距', 'marginLeft', paper.marginLeft],
|
||||
['右边距', 'marginRight', paper.marginRight],
|
||||
] as const
|
||||
).map(([label, key, val]) => (
|
||||
<div key={key}>
|
||||
<label className={labelClass}>{label} (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={val}
|
||||
onCommit={(v) => patchPaper({ [key]: Math.max(0, Number(v) || 0) })}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>列间距 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={paper.columnGap}
|
||||
onCommit={(val) => patchPaper({ columnGap: Math.max(0, Number(val) || 0) })}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>行间距 (mm)</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={paper.rowGap}
|
||||
onCommit={(val) => patchPaper({ rowGap: Math.max(0, Number(val) || 0) })}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCalculate()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-semibold cursor-pointer"
|
||||
>
|
||||
确认计算并填入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
146
src/components/LayoutPageBackgroundFields.tsx
Normal file
146
src/components/LayoutPageBackgroundFields.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, Upload } from 'lucide-react';
|
||||
import type { LabelTemplate } from '../types';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface LayoutPageBackgroundFieldsProps {
|
||||
template: LabelTemplate;
|
||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||
}
|
||||
|
||||
export const LayoutPageBackgroundFields: React.FC<LayoutPageBackgroundFieldsProps> = ({
|
||||
template,
|
||||
onChangeTemplate,
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
<div className="ps-section-title">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
页面背景
|
||||
</div>
|
||||
<p className="text-[9px] text-[#777] leading-relaxed">
|
||||
上传底图作为排版后整页背景;可分别控制排版预览与 PDF / 打印输出是否显示。
|
||||
</p>
|
||||
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
上传背景图
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
layoutPageBackground: reader.result as string,
|
||||
layoutPageBackgroundOpacity: template.layoutPageBackgroundOpacity ?? 100,
|
||||
layoutPageBackgroundFit: template.layoutPageBackgroundFit ?? 'cover',
|
||||
enableLayoutPageBackground: true,
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{template.layoutPageBackground && (
|
||||
<>
|
||||
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
|
||||
<img
|
||||
src={template.layoutPageBackground}
|
||||
alt="页面背景预览"
|
||||
className="block max-h-24 mx-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">适应方式</label>
|
||||
<FieldSelect
|
||||
value={template.layoutPageBackgroundFit ?? 'cover'}
|
||||
onChange={(e) =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
layoutPageBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
|
||||
})
|
||||
}
|
||||
className="ps-field text-[11px]"
|
||||
>
|
||||
<option value="cover">覆盖 (cover)</option>
|
||||
<option value="contain">包含 (contain)</option>
|
||||
<option value="fill">拉伸 (fill)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">
|
||||
不透明度 ({template.layoutPageBackgroundOpacity ?? 100}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={template.layoutPageBackgroundOpacity ?? 100}
|
||||
onChange={(e) =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
layoutPageBackgroundOpacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full accent-[#31a8ff]"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={template.enableLayoutPageBackground !== false}
|
||||
onChange={(e) =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
enableLayoutPageBackground: e.target.checked ? true : false,
|
||||
})
|
||||
}
|
||||
className="ps-checkbox mt-0.5"
|
||||
/>
|
||||
<span className="text-xs text-[#ccc] leading-normal">
|
||||
排版预览中显示页面背景
|
||||
<span className="block text-[9px] text-[#777] mt-0.5">
|
||||
关闭后仍保留背景图,可随时重新开启
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={template.includeLayoutPageBackgroundInOutput ?? false}
|
||||
onChange={(e) =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
includeLayoutPageBackgroundInOutput: e.target.checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
className="ps-checkbox mt-0.5"
|
||||
/>
|
||||
<span className="text-xs text-[#ccc] leading-normal">
|
||||
PDF 导出与打印时包含页面背景
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChangeTemplate({
|
||||
...template,
|
||||
layoutPageBackground: undefined,
|
||||
layoutPageBackgroundOpacity: undefined,
|
||||
layoutPageBackgroundFit: undefined,
|
||||
enableLayoutPageBackground: undefined,
|
||||
includeLayoutPageBackgroundInOutput: undefined,
|
||||
})
|
||||
}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||||
>
|
||||
清除页面背景
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
|
||||
import { LabelTemplate, PaperConfig } from '../types';
|
||||
import type { TableCellCoord } from '../tableUtils';
|
||||
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||||
import { LabelDesigner } from './LabelDesigner';
|
||||
import { PropSidebar, MobilePropModule } from './PropSidebar';
|
||||
|
||||
@@ -9,6 +11,8 @@ interface MobileDesignViewProps {
|
||||
paper: PaperConfig;
|
||||
selectedElementIds: string[];
|
||||
onSelectElements: (ids: string[]) => void;
|
||||
selectedTableCells: TableCellCoord[];
|
||||
onSelectTableCells: (cells: TableCellCoord[]) => void;
|
||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@@ -16,6 +20,7 @@ interface MobileDesignViewProps {
|
||||
onRedo: () => void;
|
||||
onBack: () => void;
|
||||
onExport: () => void;
|
||||
elementClipboard: ElementClipboardActions;
|
||||
}
|
||||
|
||||
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||
@@ -23,6 +28,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||
paper,
|
||||
selectedElementIds,
|
||||
onSelectElements,
|
||||
selectedTableCells,
|
||||
onSelectTableCells,
|
||||
onChangeTemplate,
|
||||
canUndo,
|
||||
canRedo,
|
||||
@@ -30,6 +37,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||
onRedo,
|
||||
onBack,
|
||||
onExport,
|
||||
elementClipboard,
|
||||
}) => {
|
||||
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
|
||||
|
||||
@@ -82,7 +90,10 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||
onChange={onChangeTemplate}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElements={onSelectElements}
|
||||
selectedTableCells={selectedTableCells}
|
||||
onSelectTableCells={onSelectTableCells}
|
||||
suppressSelectionChrome={mobileModule === 'nudge'}
|
||||
elementClipboard={elementClipboard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,11 +104,14 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSelectElements={onSelectElements}
|
||||
selectedTableCells={selectedTableCells}
|
||||
onSelectTableCells={onSelectTableCells}
|
||||
activeTab="element"
|
||||
onChangeTab={() => {}}
|
||||
paper={paper}
|
||||
mobileModule={mobileModule}
|
||||
onMobileModuleChange={setMobileModule}
|
||||
elementClipboard={elementClipboard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from '../types';
|
||||
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
|
||||
|
||||
interface MobileExportLayoutPanelProps {
|
||||
template: LabelTemplate;
|
||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||
paper: PaperConfig;
|
||||
onChangePaper: (paper: PaperConfig) => void;
|
||||
rows: RecordRow[];
|
||||
@@ -22,6 +24,7 @@ interface MobileExportLayoutPanelProps {
|
||||
|
||||
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
|
||||
template,
|
||||
onChangeTemplate,
|
||||
paper,
|
||||
onChangePaper,
|
||||
rows,
|
||||
@@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
|
||||
exportRange={exportRange}
|
||||
totalRowCount={rows.length}
|
||||
/>
|
||||
<div className="ps-section-divider">
|
||||
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
|
||||
</div>
|
||||
<div className="ps-section-divider space-y-2">
|
||||
<div className="ps-section-title">
|
||||
<Settings className="w-3 h-3" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Database,
|
||||
Layout,
|
||||
Download,
|
||||
Printer,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ImportData,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
ExportRangeConfig,
|
||||
PaperConfig,
|
||||
CutLineConfig,
|
||||
ElementVisibilityMode,
|
||||
VisibilityRule,
|
||||
} from '../types';
|
||||
import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
|
||||
import { DataImporter, downloadDataTemplate } from './DataImporter';
|
||||
@@ -21,8 +24,9 @@ import { VariableMappingPanel } from './VariableMappingPanel';
|
||||
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
||||
import { DataRangeFields } from './DataRangeFields';
|
||||
import { LayoutPreview } from './PreviewGrid';
|
||||
import { PrintModule } from './PrintModule';
|
||||
|
||||
type MobileExportModule = 'data' | 'layout';
|
||||
type MobileExportModule = 'data' | 'layout' | 'print';
|
||||
|
||||
const MOBILE_EXPORT_MODULES: {
|
||||
id: MobileExportModule;
|
||||
@@ -31,10 +35,12 @@ const MOBILE_EXPORT_MODULES: {
|
||||
}[] = [
|
||||
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
||||
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
|
||||
{ id: 'print', label: '打印', icon: <Printer className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
interface MobileExportViewProps {
|
||||
template: LabelTemplate;
|
||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||
templateName: string;
|
||||
templateVariables: string[];
|
||||
importData: ImportData | null;
|
||||
@@ -48,6 +54,7 @@ interface MobileExportViewProps {
|
||||
rows: RecordRow[];
|
||||
draftExportRange: ExportRangeConfig;
|
||||
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
||||
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
|
||||
cutLine: CutLineConfig;
|
||||
onChangeCutLine: (cutLine: CutLineConfig) => void;
|
||||
printablePages: (PrintableLabel | null)[][];
|
||||
@@ -63,12 +70,22 @@ interface MobileExportViewProps {
|
||||
activeRowIndex: number;
|
||||
onSelectRowIndex: (idx: number) => void;
|
||||
pdfGenerating: boolean;
|
||||
imagesZipGenerating?: boolean;
|
||||
printing: boolean;
|
||||
onBack: () => void;
|
||||
onExportPdf: () => void;
|
||||
onPrint: (printerId: string) => void;
|
||||
onExportImages?: () => void;
|
||||
onChangeExportImageFileNamePattern?: (pattern: string) => void;
|
||||
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
||||
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
||||
printDisabled: boolean;
|
||||
imagesExporting?: boolean;
|
||||
}
|
||||
|
||||
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
template,
|
||||
onChangeTemplate,
|
||||
templateName,
|
||||
templateVariables,
|
||||
importData,
|
||||
@@ -82,6 +99,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
rows,
|
||||
draftExportRange,
|
||||
onChangeDraftExportRange,
|
||||
onChangeDataFilterRules,
|
||||
cutLine,
|
||||
onChangeCutLine,
|
||||
printablePages,
|
||||
@@ -97,8 +115,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
activeRowIndex,
|
||||
onSelectRowIndex,
|
||||
pdfGenerating,
|
||||
imagesZipGenerating = false,
|
||||
printing,
|
||||
onBack,
|
||||
onExportPdf,
|
||||
onPrint,
|
||||
printDisabled,
|
||||
onExportImages,
|
||||
imagesExporting = false,
|
||||
onChangeExportImageFileNamePattern,
|
||||
onChangeExportImageFilterMode,
|
||||
onChangeExportImageFilterRules,
|
||||
}) => {
|
||||
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
||||
|
||||
@@ -117,7 +144,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
? '当前数据范围内无标签'
|
||||
: null;
|
||||
|
||||
const exportDisabled = !!exportBlockedReason || pdfGenerating;
|
||||
const exportDisabled = !!exportBlockedReason || pdfGenerating || imagesZipGenerating;
|
||||
|
||||
return (
|
||||
<div className="mobile-export-view mobile-design-view">
|
||||
@@ -222,28 +249,27 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
{hasData && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2">关联变量</p>
|
||||
<VariableMappingPanel
|
||||
variant="ps"
|
||||
variables={templateVariables}
|
||||
columns={dataColumns}
|
||||
mapping={variableColumnMapping}
|
||||
onChangeMapping={onChangeMapping}
|
||||
variableDefaults={template.variableDefaults}
|
||||
/>
|
||||
</div>
|
||||
<DataRangeFields
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2">关联变量</p>
|
||||
<VariableMappingPanel
|
||||
variant="ps"
|
||||
exportRange={draftExportRange}
|
||||
onChangeExportRange={onChangeDraftExportRange}
|
||||
rows={rows}
|
||||
paper={paper}
|
||||
template={template}
|
||||
variables={templateVariables}
|
||||
columns={dataColumns}
|
||||
mapping={variableColumnMapping}
|
||||
onChangeMapping={onChangeMapping}
|
||||
variableDefaults={template.variableDefaults}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<DataRangeFields
|
||||
variant="ps"
|
||||
exportRange={draftExportRange}
|
||||
onChangeExportRange={onChangeDraftExportRange}
|
||||
onChangeDataFilterRules={onChangeDataFilterRules}
|
||||
rows={rows}
|
||||
paper={paper}
|
||||
template={template}
|
||||
/>
|
||||
{/* {exportBlockedReason && !pdfGenerating && (
|
||||
<p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p>
|
||||
)} */}
|
||||
@@ -253,6 +279,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
<div className="ps-panel-body p-3 min-h-0">
|
||||
<MobileExportLayoutPanel
|
||||
template={template}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
paper={paper}
|
||||
onChangePaper={onChangePaper}
|
||||
rows={rows}
|
||||
@@ -262,6 +289,25 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileModule === 'print' && (
|
||||
<div className="ps-panel-body p-3 min-h-0">
|
||||
<PrintModule
|
||||
variant="mobile"
|
||||
disabled={printDisabled}
|
||||
printing={printing}
|
||||
onPrint={onPrint}
|
||||
imagesExporting={imagesExporting}
|
||||
onExportImages={onExportImages}
|
||||
exportImageFileNamePattern={template.exportImageFileNamePattern ?? ''}
|
||||
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
|
||||
exportImageFilterMode={template.exportImageFilterMode ?? 'always'}
|
||||
exportImageFilterRules={template.exportImageFilterRules ?? []}
|
||||
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
|
||||
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
123
src/components/MobileMainToolButtons.tsx
Normal file
123
src/components/MobileMainToolButtons.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { AnchoredMenu } from './AnchoredMenu';
|
||||
|
||||
const TOOL_BTN_SIZE_PX = 40;
|
||||
const TOOL_BTN_GAP_PX = 4;
|
||||
const TOOL_BTN_SLOT_PX = TOOL_BTN_SIZE_PX + TOOL_BTN_GAP_PX;
|
||||
|
||||
export interface MobileToolButtonItem {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface MobileMainToolButtonsProps {
|
||||
items: MobileToolButtonItem[];
|
||||
}
|
||||
|
||||
function computeVisibleCount(containerWidth: number, total: number): number {
|
||||
if (total <= 0 || containerWidth <= 0) return total;
|
||||
if (containerWidth >= total * TOOL_BTN_SLOT_PX) return total;
|
||||
const withMore = Math.floor((containerWidth - TOOL_BTN_SLOT_PX) / TOOL_BTN_SLOT_PX);
|
||||
return Math.max(1, Math.min(withMore, total - 1));
|
||||
}
|
||||
|
||||
function reorderItemsForActive(items: MobileToolButtonItem[], visibleCount: number): MobileToolButtonItem[] {
|
||||
if (visibleCount >= items.length) return items;
|
||||
const activeIndex = items.findIndex((item) => item.active);
|
||||
if (activeIndex < 0 || activeIndex < visibleCount) return items;
|
||||
const next = [...items];
|
||||
const [activeItem] = next.splice(activeIndex, 1);
|
||||
next.splice(visibleCount - 1, 0, activeItem);
|
||||
return next;
|
||||
}
|
||||
|
||||
export const MobileMainToolButtons: React.FC<MobileMainToolButtonsProps> = ({ items }) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(items.length);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const measure = () => {
|
||||
setVisibleCount(computeVisibleCount(container.clientWidth, items.length));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [items.length]);
|
||||
|
||||
const orderedItems = useMemo(
|
||||
() => reorderItemsForActive(items, visibleCount),
|
||||
[items, visibleCount]
|
||||
);
|
||||
|
||||
const visibleItems = orderedItems.slice(0, visibleCount);
|
||||
const overflowItems = orderedItems.slice(visibleCount);
|
||||
const overflowHasActive = overflowItems.some((item) => item.active);
|
||||
|
||||
const renderToolButton = (item: MobileToolButtonItem) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`ps-tool-btn mobile-design-tool-btn ${item.active ? 'active' : ''}`}
|
||||
onClick={item.onClick}
|
||||
title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mobile-design-toolstrip-tools">
|
||||
{visibleItems.map((item) => renderToolButton(item))}
|
||||
{overflowItems.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={moreButtonRef}
|
||||
type="button"
|
||||
className={`ps-tool-btn mobile-design-tool-btn`}
|
||||
onClick={() => setMoreOpen((open) => !open)}
|
||||
title="更多工具"
|
||||
aria-label="更多工具"
|
||||
aria-expanded={moreOpen}
|
||||
>
|
||||
<MoreHorizontal className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<AnchoredMenu
|
||||
open={moreOpen}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
anchorRef={moreButtonRef}
|
||||
className="mobile-toolstrip-overflow-menu"
|
||||
>
|
||||
{overflowItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`mobile-toolstrip-overflow-menu-item${item.active ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="mobile-toolstrip-overflow-menu-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</AnchoredMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types';
|
||||
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig, PrintColorMode, PRINT_COLOR_MODE_LABELS } from '../types';
|
||||
import {
|
||||
computeAutoLayoutPaper,
|
||||
DEFAULT_EXPORT_RANGE,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type PrintableLabel,
|
||||
} from '../utils';
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import { resolveReferenceBackgroundForOutput, resolveLayoutPageBackgroundForPreview } from '../labelRenderer';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
@@ -65,7 +66,8 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) =>
|
||||
a.marginLeft === b.marginLeft &&
|
||||
a.columnGap === b.columnGap &&
|
||||
a.rowGap === b.rowGap &&
|
||||
a.flow === b.flow;
|
||||
a.flow === b.flow &&
|
||||
(a.printColorMode ?? 'cmyk') === (b.printColorMode ?? 'cmyk');
|
||||
|
||||
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
paper,
|
||||
@@ -130,7 +132,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
|
||||
const rangeForStats = exportRange ?? DEFAULT_EXPORT_RANGE;
|
||||
const draftLayoutStats = useMemo(
|
||||
() => buildPrintablePages(rows, draftPaper, template, rangeForStats),
|
||||
() =>
|
||||
buildPrintablePages(
|
||||
rows,
|
||||
draftPaper,
|
||||
template,
|
||||
rangeForStats,
|
||||
template.variableDefaults ?? null
|
||||
),
|
||||
[rows, draftPaper, template, rangeForStats]
|
||||
);
|
||||
|
||||
@@ -227,293 +236,316 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
const autoLayoutDialog =
|
||||
autoLayoutOpen && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
className="ps-modal-overlay"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="ps-modal-overlay"
|
||||
role="presentation"
|
||||
className="ps-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auto-layout-dialog-title"
|
||||
>
|
||||
<div
|
||||
className="ps-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auto-layout-dialog-title"
|
||||
<div className="ps-modal-header">
|
||||
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
|
||||
自动排版
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeAutoLayoutDialog}
|
||||
className="ps-modal-close"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmAutoLayout();
|
||||
}}
|
||||
>
|
||||
<div className="ps-modal-header">
|
||||
<h3 id="auto-layout-dialog-title" className="ps-modal-title">
|
||||
自动排版
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeAutoLayoutDialog}
|
||||
className="ps-modal-close"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<div className="ps-modal-body space-y-3">
|
||||
<p className="text-[11px] text-[#888] leading-relaxed">
|
||||
将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
|
||||
最小页边距 (mm)
|
||||
</label>
|
||||
<input
|
||||
id="auto-layout-min-margin"
|
||||
ref={autoLayoutInputRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={autoLayoutMinMargin}
|
||||
onChange={(e) => setAutoLayoutMinMargin(e.target.value)}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ps-modal-footer">
|
||||
<button type="button" onClick={closeAutoLayoutDialog} className="ps-btn text-[11px]">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="ps-btn ps-btn-primary text-[11px]">
|
||||
确定排版
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmAutoLayout();
|
||||
}}
|
||||
>
|
||||
<div className="ps-modal-body space-y-3">
|
||||
<p className="text-[11px] text-[#888] leading-relaxed">
|
||||
将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="auto-layout-min-margin" className="ps-field-label">
|
||||
最小页边距 (mm)
|
||||
</label>
|
||||
<input
|
||||
id="auto-layout-min-margin"
|
||||
ref={autoLayoutInputRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={autoLayoutMinMargin}
|
||||
onChange={(e) => setAutoLayoutMinMargin(e.target.value)}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ps-modal-footer">
|
||||
<button type="button" onClick={closeAutoLayoutDialog} className="ps-btn text-[11px]">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="ps-btn ps-btn-primary text-[11px]">
|
||||
确定排版
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={panelRef}
|
||||
onBlur={isMobile ? undefined : handlePanelBlur}
|
||||
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{!isPs && !isMobile && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-bold text-gray-900 leading-none">排版</h3>
|
||||
</div>
|
||||
)}
|
||||
{isPs && (
|
||||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||
排版参数修改后将自动更新预览。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>纸张</h4>
|
||||
<div>
|
||||
<label className={labelClass}>纸张类型</label>
|
||||
<FieldSelect
|
||||
value={draftPaper.type}
|
||||
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="A4">A4 (210×297 mm)</option>
|
||||
<option value="A5">A5 (148×210 mm)</option>
|
||||
<option value="A6">A6 (105×148 mm)</option>
|
||||
<option value="custom">自定义尺寸</option>
|
||||
</FieldSelect>
|
||||
<div
|
||||
ref={panelRef}
|
||||
onBlur={isMobile ? undefined : handlePanelBlur}
|
||||
className={`space-y-4 text-xs ${isPs || isMobile ? '' : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{!isPs && !isMobile && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-bold text-gray-900 leading-none">排版</h3>
|
||||
</div>
|
||||
)}
|
||||
{isPs && (
|
||||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||
排版参数修改后将自动更新预览。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{draftPaper.type === 'custom' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>宽度 (mm)</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
value={draftPaper.width}
|
||||
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>高度 (mm)</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
value={draftPaper.height}
|
||||
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div className={isPs || isMobile ? 'space-y-4' : 'grid grid-cols-1 md:grid-cols-4 gap-6'}>
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>纸张</h4>
|
||||
<div>
|
||||
<label className={labelClass}>纸张类型</label>
|
||||
<FieldSelect
|
||||
value={draftPaper.type}
|
||||
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="A4">A4 (210×297 mm)</option>
|
||||
<option value="A5">A5 (148×210 mm)</option>
|
||||
<option value="A6">A6 (105×148 mm)</option>
|
||||
<option value="custom">自定义尺寸</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>进纸方向</label>
|
||||
{isPs ? (
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'portrait'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'landscape'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex bg-gray-100 p-0.5 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'portrait' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'landscape' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
{draftPaper.type === 'custom' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>宽度 (mm)</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
value={draftPaper.width}
|
||||
onCommit={(val) => patchDraftPaper({ width: Math.max(10, Number(val) || 10) })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>高度 (mm)</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
value={draftPaper.height}
|
||||
onCommit={(val) => patchDraftPaper({ height: Math.max(10, Number(val) || 10) })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>页边距 (mm)</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
['上边距', 'marginTop', draftPaper.marginTop],
|
||||
['下边距', 'marginBottom', draftPaper.marginBottom],
|
||||
['左边距', 'marginLeft', draftPaper.marginLeft],
|
||||
['右边距', 'marginRight', draftPaper.marginRight],
|
||||
] as const
|
||||
).map(([label, key, val]) => (
|
||||
<div key={key}>
|
||||
<label className={labelClass}>{label}</label>
|
||||
<div>
|
||||
<label className={labelClass}>进纸方向</label>
|
||||
{isPs ? (
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'portrait' ? 'active' : ''}`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`ps-toggle-btn flex-1 text-[10px] ${draftPaper.orientation === 'landscape' ? 'active' : ''}`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'portrait'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`flex-1 py-2.5 text-sm font-semibold rounded-lg transition ${draftPaper.orientation === 'landscape'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex bg-gray-100 p-0.5 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'portrait' })}
|
||||
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'portrait' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
纵向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchDraftPaper({ orientation: 'landscape' })}
|
||||
className={`flex-1 py-1.5 text-[11px] font-semibold rounded ${draftPaper.orientation === 'landscape' ? 'bg-white text-gray-800 shadow-xs' : 'text-gray-500'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
横向
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>页边距 (mm)</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
['上边距', 'marginTop', draftPaper.marginTop],
|
||||
['下边距', 'marginBottom', draftPaper.marginBottom],
|
||||
['左边距', 'marginLeft', draftPaper.marginLeft],
|
||||
['右边距', 'marginRight', draftPaper.marginRight],
|
||||
] as const
|
||||
).map(([label, key, val]) => (
|
||||
<div key={key}>
|
||||
<label className={labelClass}>{label}</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={val}
|
||||
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAutoLayoutDialog}
|
||||
title="设置最小页边距并自动居中排版"
|
||||
className={
|
||||
isPs
|
||||
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
|
||||
: isMobile
|
||||
? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
|
||||
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
|
||||
}
|
||||
>
|
||||
自动排版
|
||||
</button>
|
||||
<span
|
||||
className={
|
||||
isPs
|
||||
? 'text-[9px] text-[#666] leading-tight'
|
||||
: isMobile
|
||||
? 'text-[10px] text-slate-400 leading-tight'
|
||||
: 'text-[9px] text-gray-400 leading-tight'
|
||||
}
|
||||
>
|
||||
上次最小页边距 {lastAutoLayoutMinMargin} mm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>标签间距 (mm)</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>横向</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="100"
|
||||
value={val}
|
||||
onCommit={(v) => patchDraftPaper({ [key]: Number(v) || 0 })}
|
||||
max="50"
|
||||
value={draftPaper.columnGap}
|
||||
onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>纵向</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="50"
|
||||
value={draftPaper.rowGap}
|
||||
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 flex flex-col items-end gap-0.5 -mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAutoLayoutDialog}
|
||||
title="设置最小页边距并自动居中排版"
|
||||
className={
|
||||
isPs
|
||||
? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
|
||||
: isMobile
|
||||
? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
|
||||
: 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
|
||||
}
|
||||
>
|
||||
自动排版
|
||||
</button>
|
||||
<span
|
||||
className={
|
||||
isPs
|
||||
? 'text-[9px] text-[#666] leading-tight'
|
||||
: isMobile
|
||||
? 'text-[10px] text-slate-400 leading-tight'
|
||||
: 'text-[9px] text-gray-400 leading-tight'
|
||||
}
|
||||
>
|
||||
上次最小页边距 {lastAutoLayoutMinMargin} mm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>标签间距 (mm)</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>横向</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="50"
|
||||
value={draftPaper.columnGap}
|
||||
onCommit={(val) => patchDraftPaper({ columnGap: Number(val) || 0 })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>纵向</label>
|
||||
<PageInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="50"
|
||||
value={draftPaper.rowGap}
|
||||
onCommit={(val) => patchDraftPaper({ rowGap: Number(val) || 0 })}
|
||||
inputClassName={fieldClass}
|
||||
/>
|
||||
<label className={labelClass}>填充顺序</label>
|
||||
<FieldSelect
|
||||
value={draftPaper.flow}
|
||||
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="left-right-top-bottom">左右 → 上下(行优先)</option>
|
||||
<option value="top-bottom-left-right">上下 → 左右(列优先)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>填充顺序</label>
|
||||
<FieldSelect
|
||||
value={draftPaper.flow}
|
||||
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="left-right-top-bottom">左右 → 上下(行优先)</option>
|
||||
<option value="top-bottom-left-right">上下 → 左右(列优先)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className={sectionTitleClass}>印刷色彩</h4>
|
||||
<div>
|
||||
<label className={labelClass}>色彩模式</label>
|
||||
<FieldSelect
|
||||
value={draftPaper.printColorMode ?? 'cmyk'}
|
||||
onChange={(e) =>
|
||||
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
{(Object.keys(PRINT_COLOR_MODE_LABELS) as PrintColorMode[]).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{PRINT_COLOR_MODE_LABELS[mode]}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
|
||||
设计预览、排版预览与导出/打印使用同一套色彩处理;推荐 CMYK 模式。实机仍受打印机驱动影响,请以打样为准
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{autoLayoutDialog}
|
||||
{autoLayoutDialog}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -576,6 +608,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
|
||||
const currentPaperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||
const pageCardWidthPx = Math.ceil(currentPaperW * previewScale);
|
||||
const previewPageGapPx = isMobileVariant ? 16 : 32;
|
||||
const colGapPx = Math.max(0, paper.columnGap * previewScale);
|
||||
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
||||
const labelWPx = template.width * previewScale;
|
||||
@@ -586,6 +620,24 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
const cutChannelXPx = cutChannel.x * previewScale;
|
||||
const cutChannelYPx = cutChannel.y * previewScale;
|
||||
const cutDash = formatCutLineSvgDashArray(cutLine, previewScale);
|
||||
const referenceBackgroundRender = useMemo(
|
||||
() => resolveReferenceBackgroundForOutput(template),
|
||||
[
|
||||
template.previewReferenceBackground,
|
||||
template.previewReferenceBackgroundOpacity,
|
||||
template.previewReferenceBackgroundFit,
|
||||
template.includeReferenceBackgroundInOutput,
|
||||
]
|
||||
);
|
||||
const layoutPageBackground = useMemo(
|
||||
() => resolveLayoutPageBackgroundForPreview(template),
|
||||
[
|
||||
template.layoutPageBackground,
|
||||
template.layoutPageBackgroundOpacity,
|
||||
template.layoutPageBackgroundFit,
|
||||
template.enableLayoutPageBackground,
|
||||
]
|
||||
);
|
||||
const pageCutLines = useMemo(
|
||||
() =>
|
||||
collectGridCutLinePositions(
|
||||
@@ -665,13 +717,6 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewScale(2.0)}
|
||||
className={`ps-preview-preset-btn ${previewScale === 2.0 ? 'active' : ''}`}
|
||||
>
|
||||
适中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewScale(3.3)}
|
||||
@@ -684,7 +729,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPages((v) => !v)}
|
||||
className="ps-btn text-[10px]"
|
||||
className={`ps-preview-preset-btn`}
|
||||
>
|
||||
{showAllPages ? '收起预览' : `展开全部 ${totalPages} 页`}
|
||||
</button>
|
||||
@@ -693,9 +738,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
|
||||
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||
}`}
|
||||
className={`flex-1 overflow-auto scrollbar-hide overscroll-contain ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||
}`}
|
||||
>
|
||||
{!previewReady ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
|
||||
@@ -708,35 +752,54 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex flex-col items-center ${
|
||||
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
|
||||
}`}
|
||||
className={
|
||||
isMobileVariant
|
||||
? 'flex flex-col items-center justify-center min-h-full w-full space-y-4 pb-4'
|
||||
: 'flex flex-wrap justify-center content-center w-full min-h-full pb-8'
|
||||
}
|
||||
style={isMobileVariant ? undefined : { gap: `${previewPageGapPx}px` }}
|
||||
>
|
||||
{visiblePages.map((pageRows, pageIdx) => (
|
||||
<div
|
||||
key={pageIdx}
|
||||
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0"
|
||||
className="bg-white rounded shadow-2xl overflow-hidden relative border border-[#222] flex flex-col shrink-0 max-w-full"
|
||||
style={{
|
||||
width: `${currentPaperW * previewScale}px`,
|
||||
width: `${pageCardWidthPx}px`,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#f0f0f0] border-b border-gray-200 py-1.5 px-3 flex justify-between items-center text-[10px] font-semibold text-gray-500 select-none shrink-0">
|
||||
<span>第 {pageIdx + 1} 页 / 共 {totalPages} 页</span>
|
||||
<span>
|
||||
{/* <span>
|
||||
{currentPaperW} × {currentPaperH} mm · {paper.orientation === 'portrait' ? '纵向' : '横向'}
|
||||
</span>
|
||||
</span> */}
|
||||
</div>
|
||||
<div
|
||||
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
||||
className="bg-white relative select-none overflow-hidden shrink-0"
|
||||
style={{
|
||||
width: `${currentPaperW * previewScale}px`,
|
||||
width: `${pageCardWidthPx}px`,
|
||||
height: `${currentPaperH * previewScale}px`,
|
||||
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{layoutPageBackground && (
|
||||
<img
|
||||
src={layoutPageBackground.background}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full h-full pointer-events-none z-0"
|
||||
style={{
|
||||
objectFit: layoutPageBackground.fit,
|
||||
opacity: layoutPageBackground.opacity / 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="grid bg-white relative"
|
||||
className="relative z-10 flex items-center justify-center h-full box-border"
|
||||
style={{
|
||||
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid bg-transparent relative"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
||||
gridAutoRows: `${labelHPx}px`,
|
||||
@@ -806,8 +869,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
<div
|
||||
key={cellIdx}
|
||||
onClick={() => onSelectRowIndex(item.sourceIndex)}
|
||||
className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : ''
|
||||
}`}
|
||||
className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border`}
|
||||
style={{
|
||||
width: `${labelWPx}px`,
|
||||
height: `${labelHPx}px`,
|
||||
@@ -824,12 +886,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
rowIndex={item.sourceIndex}
|
||||
showBorder={false}
|
||||
mode="output"
|
||||
printColorMode={paper.printColorMode ?? 'cmyk'}
|
||||
{...referenceBackgroundRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -867,7 +932,13 @@ export const PreviewGrid: React.FC<PreviewGridProps> = ({
|
||||
activeRowIndex,
|
||||
onSelectRowIndex,
|
||||
}) => {
|
||||
const layout = buildPrintablePages(rows, paper, template, DEFAULT_EXPORT_RANGE);
|
||||
const layout = buildPrintablePages(
|
||||
rows,
|
||||
paper,
|
||||
template,
|
||||
DEFAULT_EXPORT_RANGE,
|
||||
template.variableDefaults ?? null
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PaperSettingsPanel
|
||||
|
||||
275
src/components/PrintModule.tsx
Normal file
275
src/components/PrintModule.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Images, Loader2, Printer, RefreshCw } from 'lucide-react';
|
||||
import type { ElementVisibilityMode, VisibilityRule } from '../types';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { CommitInput, CommitTextarea } from './CommitInput';
|
||||
import { ExportImageFilterFields } from './ExportImageFilterFields';
|
||||
import {
|
||||
addCustomPrinter,
|
||||
DEFAULT_PRINTER_ID,
|
||||
getAvailablePrinters,
|
||||
loadSelectedPrinterId,
|
||||
saveSelectedPrinterId,
|
||||
supportsDirectPrinting,
|
||||
type PrinterOption,
|
||||
} from '../labelPrint';
|
||||
|
||||
interface PrintModuleProps {
|
||||
disabled: boolean;
|
||||
printing: boolean;
|
||||
onPrint: (printerId: string) => void;
|
||||
imagesExporting?: boolean;
|
||||
onExportImages?: () => void;
|
||||
exportImageFileNamePattern?: string;
|
||||
onChangeExportImageFileNamePattern?: (pattern: string) => void;
|
||||
exportImageFilterMode?: ElementVisibilityMode;
|
||||
exportImageFilterRules?: VisibilityRule[];
|
||||
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
||||
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
||||
templateVariables?: string[];
|
||||
variant?: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
export const PrintModule: React.FC<PrintModuleProps> = ({
|
||||
disabled,
|
||||
printing,
|
||||
onPrint,
|
||||
imagesExporting = false,
|
||||
onExportImages,
|
||||
exportImageFileNamePattern = '',
|
||||
onChangeExportImageFileNamePattern,
|
||||
exportImageFilterMode = 'always',
|
||||
exportImageFilterRules = [],
|
||||
onChangeExportImageFilterMode,
|
||||
onChangeExportImageFilterRules,
|
||||
templateVariables = [],
|
||||
variant = 'desktop',
|
||||
}) => {
|
||||
const [printers, setPrinters] = useState<PrinterOption[]>([
|
||||
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
|
||||
]);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState(loadSelectedPrinterId);
|
||||
const [loadingPrinters, setLoadingPrinters] = useState(false);
|
||||
const [customPrinterName, setCustomPrinterName] = useState('');
|
||||
|
||||
const refreshPrinters = useCallback(async () => {
|
||||
setLoadingPrinters(true);
|
||||
try {
|
||||
const list = await getAvailablePrinters();
|
||||
setPrinters(list);
|
||||
const saved = loadSelectedPrinterId();
|
||||
if (list.some((p) => p.id === saved)) {
|
||||
setSelectedPrinterId(saved);
|
||||
} else {
|
||||
setSelectedPrinterId(DEFAULT_PRINTER_ID);
|
||||
}
|
||||
} finally {
|
||||
setLoadingPrinters(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPrinters();
|
||||
}, [refreshPrinters]);
|
||||
|
||||
const handlePrint = () => {
|
||||
saveSelectedPrinterId(selectedPrinterId);
|
||||
onPrint(selectedPrinterId);
|
||||
};
|
||||
|
||||
const handleAddCustomPrinter = () => {
|
||||
const trimmed = customPrinterName.trim();
|
||||
if (!trimmed) return;
|
||||
const added = addCustomPrinter(trimmed);
|
||||
setCustomPrinterName('');
|
||||
void refreshPrinters().then(() => {
|
||||
setSelectedPrinterId(added.id);
|
||||
saveSelectedPrinterId(added.id);
|
||||
});
|
||||
};
|
||||
|
||||
const directAvailable = supportsDirectPrinting(printers);
|
||||
const selectedPrinter = printers.find((p) => p.id === selectedPrinterId);
|
||||
const usesDirect = !!selectedPrinter?.direct;
|
||||
const exportBusy = printing || imagesExporting;
|
||||
|
||||
const appendFileNameToken = (token: string) => {
|
||||
if (!onChangeExportImageFileNamePattern) return;
|
||||
const current = exportImageFileNamePattern.trim();
|
||||
const next = current
|
||||
? `${current}${current.endsWith('_') || current.endsWith('-') || current.endsWith(' ') ? '' : '_'}${token}`
|
||||
: token;
|
||||
onChangeExportImageFileNamePattern(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="ps-field-label mb-0">打印机</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPrinters()}
|
||||
disabled={loadingPrinters}
|
||||
className="inline-flex items-center gap-1 text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${loadingPrinters ? 'animate-spin' : ''}`} />
|
||||
刷新列表
|
||||
</button>
|
||||
</div>
|
||||
<FieldSelect
|
||||
value={selectedPrinterId}
|
||||
onChange={(e) => setSelectedPrinterId(e.target.value)}
|
||||
className="ps-field text-[11px]"
|
||||
disabled={loadingPrinters || exportBusy}
|
||||
>
|
||||
{printers.map((printer) => (
|
||||
<option key={printer.id} value={printer.id}>
|
||||
{printer.name}
|
||||
{printer.direct ? ' · 直连' : ''}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
|
||||
{!directAvailable && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="ps-field-label">添加常用打印机名称</label>
|
||||
<div className="flex gap-1.5">
|
||||
<CommitInput
|
||||
value={customPrinterName}
|
||||
onCommit={setCustomPrinterName}
|
||||
placeholder="与系统中打印机名称一致"
|
||||
className="ps-field text-[11px] flex-1 min-w-0"
|
||||
disabled={exportBusy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomPrinter}
|
||||
disabled={!customPrinterName.trim() || exportBusy}
|
||||
className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||
{usesDirect
|
||||
? '将直接提交到所选打印机。'
|
||||
: directAvailable
|
||||
? '所选打印机将通过系统打印对话框确认后打印。'
|
||||
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
disabled={disabled || exportBusy || loadingPrinters}
|
||||
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
|
||||
!disabled && !exportBusy && !loadingPrinters
|
||||
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
|
||||
: 'bg-[#444] text-[#666] cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{printing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Printer className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'}
|
||||
</button>
|
||||
|
||||
{onExportImages && (
|
||||
<>
|
||||
<div className="border-t border-[#3a3a3a] pt-3 space-y-2">
|
||||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||
将当前数据范围内每一枚标签导出为单独图片,打包为 ZIP 下载。
|
||||
</p>
|
||||
{onChangeExportImageFileNamePattern && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="ps-field-label mb-0">图片文件名</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!exportImageFileNamePattern || exportBusy}
|
||||
onClick={() => onChangeExportImageFileNamePattern('')}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<CommitTextarea
|
||||
value={exportImageFileNamePattern}
|
||||
onCommit={onChangeExportImageFileNamePattern}
|
||||
placeholder="留空则按 001、002… 命名"
|
||||
rows={3}
|
||||
className="ps-field text-[11px] font-mono resize-y min-h-[72px]"
|
||||
disabled={exportBusy}
|
||||
/>
|
||||
<p className="text-[9px] text-[#777] leading-relaxed">
|
||||
与元素内容相同,可使用 {'{#SEQ}'}(导出序号)、{'{#INDEX}'}(数据行号)、{'{变量名}'};{'{#SEQ:4}'} 可补零。换行仅用于编辑排版,导出时会自动忽略。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => appendFileNameToken('{#SEQ}')}
|
||||
disabled={exportBusy}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{'{#SEQ}'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => appendFileNameToken('{#INDEX}')}
|
||||
disabled={exportBusy}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{'{#INDEX}'}
|
||||
</button>
|
||||
{templateVariables.map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => appendFileNameToken(`{${variable}}`)}
|
||||
disabled={exportBusy}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onChangeExportImageFilterMode && onChangeExportImageFilterRules && (
|
||||
<ExportImageFilterFields
|
||||
filterMode={exportImageFilterMode}
|
||||
filterRules={exportImageFilterRules}
|
||||
templateVariables={templateVariables}
|
||||
disabled={exportBusy}
|
||||
onChangeFilterMode={onChangeExportImageFilterMode}
|
||||
onChangeFilterRules={onChangeExportImageFilterRules}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportImages}
|
||||
disabled={disabled || exportBusy || loadingPrinters}
|
||||
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
|
||||
!disabled && !exportBusy && !loadingPrinters
|
||||
? 'bg-[#383838] hover:bg-[#444] text-[#e8e8e8] border border-[#4a4a4a]'
|
||||
: 'bg-[#444] text-[#666] cursor-not-allowed border border-[#333]'
|
||||
}`}
|
||||
>
|
||||
{imagesExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Images className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{imagesExporting ? '正在导出图片…' : '批量保存为图片'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
459
src/components/TableCellTextFields.tsx
Normal file
459
src/components/TableCellTextFields.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { ValueExtractFields } from './ValueExtractFields';
|
||||
import { ColorValueField } from './ColorValueField';
|
||||
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||||
import {
|
||||
applyCellTextElementChange,
|
||||
getCellSpan,
|
||||
getEffectiveCellStyle,
|
||||
} from '../tableUtils';
|
||||
import {
|
||||
Type,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignVerticalJustifyStart,
|
||||
AlignVerticalJustifyCenter,
|
||||
AlignVerticalJustifyEnd,
|
||||
} from 'lucide-react';
|
||||
|
||||
const PropInput = CommitInput;
|
||||
|
||||
interface TableCellTextFieldsProps {
|
||||
elem: TemplateElement;
|
||||
row: number;
|
||||
col: number;
|
||||
onChange: (elem: TemplateElement) => void;
|
||||
templateVariables?: string[];
|
||||
}
|
||||
|
||||
export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
||||
elem,
|
||||
row,
|
||||
col,
|
||||
onChange,
|
||||
templateVariables = [],
|
||||
}) => {
|
||||
const textElem = getEffectiveCellStyle(elem, row, col);
|
||||
const span = getCellSpan(elem, row, col);
|
||||
|
||||
const patchText = (updated: TemplateElement) => {
|
||||
onChange(applyCellTextElementChange(elem, row, col, updated));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
|
||||
<span className="text-[10px] font-semibold text-[#aaa] flex items-center gap-1">
|
||||
<Type className="w-3 h-3" />
|
||||
单元格文本
|
||||
</span>
|
||||
{(span.rowSpan > 1 || span.colSpan > 1) && (
|
||||
<p className="text-[9px] text-[#888]">合并区域 {span.rowSpan}×{span.colSpan}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="ps-field-label">内容</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!textElem.content}
|
||||
onClick={() => patchText({ ...textElem, content: '' })}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||
>
|
||||
清空内容
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={textElem.content}
|
||||
rows={2}
|
||||
className="ps-field resize-none"
|
||||
placeholder="输入文本,使用 {变量名} 引用变量"
|
||||
onChange={(e) => patchText({ ...textElem, content: e.target.value })}
|
||||
/>
|
||||
{templateVariables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{templateVariables.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const token = `{${v}}`;
|
||||
const content = textElem.content.includes(token)
|
||||
? textElem.content
|
||||
: textElem.content.trim()
|
||||
? `${textElem.content}${textElem.content.endsWith(' ') ? '' : ' '}${token}`
|
||||
: token;
|
||||
patchText({ ...textElem, content });
|
||||
}}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{`{${v}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">显示格式</label>
|
||||
<FieldSelect
|
||||
value={textElem.textFormat || 'text'}
|
||||
onChange={(e) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
textFormat: e.target.value as 'text' | 'number' | 'percent' | 'currency',
|
||||
})
|
||||
}
|
||||
className="ps-field"
|
||||
>
|
||||
<option value="text">文本</option>
|
||||
<option value="number">数值</option>
|
||||
<option value="percent">百分比</option>
|
||||
<option value="currency">货币 (¥)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{(textElem.textFormat === 'number' ||
|
||||
textElem.textFormat === 'percent' ||
|
||||
textElem.textFormat === 'currency') && (
|
||||
<div>
|
||||
<label className="ps-field-label">小数位数</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
value={textElem.decimalPlaces ?? 2}
|
||||
onCommit={(val) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">字号 (pt)</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="6"
|
||||
max="80"
|
||||
value={textElem.fontSize}
|
||||
onCommit={(val) => patchText({ ...textElem, fontSize: Number(val) || 12 })}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">字体库</label>
|
||||
<FieldSelect
|
||||
value={textElem.fontFamily}
|
||||
onChange={(e) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
fontFamily: e.target.value as 'sans' | 'serif' | 'mono',
|
||||
})
|
||||
}
|
||||
className="ps-field"
|
||||
>
|
||||
<option value="sans">黑体 (Sans)</option>
|
||||
<option value="serif">宋体 (Serif)</option>
|
||||
<option value="mono">等宽体 (Mono)</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||
<ColorValueField
|
||||
label="文本颜色"
|
||||
value={textElem.textColor || ''}
|
||||
onChange={(val) => patchText({ ...textElem, textColor: val || undefined })}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#111827"
|
||||
placeholder="#111827"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="ps-field-label mb-0">文本样式</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patchText({
|
||||
...textElem,
|
||||
fontWeight: textElem.fontWeight === 'bold' ? 'normal' : 'bold',
|
||||
})
|
||||
}
|
||||
className={`ps-btn ${textElem.fontWeight === 'bold' ? 'active' : ''}`}
|
||||
title="加粗"
|
||||
>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patchText({
|
||||
...textElem,
|
||||
fontStyle: textElem.fontStyle === 'italic' ? 'normal' : 'italic',
|
||||
})
|
||||
}
|
||||
className={`ps-btn ${textElem.fontStyle === 'italic' ? 'active' : ''}`}
|
||||
title="斜体"
|
||||
>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textUnderline: !textElem.textUnderline })}
|
||||
className={`ps-btn ${textElem.textUnderline ? 'active' : ''}`}
|
||||
title="下划线"
|
||||
>
|
||||
<Underline className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textLineThrough: !textElem.textLineThrough })}
|
||||
className={`ps-btn ${textElem.textLineThrough ? 'active' : ''}`}
|
||||
title="删除线"
|
||||
>
|
||||
<Strikethrough className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">水平拉伸</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
step="5"
|
||||
value={Math.round((textElem.textScaleX ?? 1) * 100)}
|
||||
onCommit={(val) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">垂直拉伸</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
step="5"
|
||||
value={Math.round((textElem.textScaleY ?? 1) * 100)}
|
||||
onCommit={(val) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">字间距</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={textElem.letterSpacing ?? 0}
|
||||
onCommit={(val) =>
|
||||
patchText({
|
||||
...textElem,
|
||||
letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
<p className="text-[9px] text-[#666] mt-0.5">mm</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="ps-field-label mb-0">排列方向</label>
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textWritingMode: 'horizontal' })}
|
||||
className={`ps-toggle-btn ${(textElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
|
||||
>
|
||||
横排
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textWritingMode: 'vertical' })}
|
||||
className={`ps-toggle-btn ${textElem.textWritingMode === 'vertical' ? 'active' : ''}`}
|
||||
>
|
||||
竖排
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="ps-field-label mb-0">对齐方式</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textAlign: 'left' })}
|
||||
className={`ps-toggle-btn ${textElem.textAlign === 'left' ? 'active' : ''}`}
|
||||
title="左对齐"
|
||||
>
|
||||
<AlignLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textAlign: 'center' })}
|
||||
className={`ps-toggle-btn ${textElem.textAlign === 'center' ? 'active' : ''}`}
|
||||
title="水平居中"
|
||||
>
|
||||
<AlignCenter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, textAlign: 'right' })}
|
||||
className={`ps-toggle-btn ${textElem.textAlign === 'right' ? 'active' : ''}`}
|
||||
title="右对齐"
|
||||
>
|
||||
<AlignRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, verticalAlign: 'top' })}
|
||||
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'top' ? 'active' : ''}`}
|
||||
title="顶对齐"
|
||||
>
|
||||
<AlignVerticalJustifyStart className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, verticalAlign: 'middle' })}
|
||||
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'middle' ? 'active' : ''}`}
|
||||
title="垂直居中"
|
||||
>
|
||||
<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, verticalAlign: 'bottom' })}
|
||||
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'bottom' ? 'active' : ''}`}
|
||||
title="底对齐"
|
||||
>
|
||||
<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textElem.wordWrap !== false}
|
||||
onChange={(e) => patchText({ ...textElem, wordWrap: e.target.checked })}
|
||||
className="ps-checkbox"
|
||||
/>
|
||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">最大行数</label>
|
||||
<CommitInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
step="1"
|
||||
value={textElem.textMaxLines ?? ''}
|
||||
onCommit={(val) => {
|
||||
const raw = String(val).trim();
|
||||
if (!raw) {
|
||||
patchText({ ...textElem, textMaxLines: undefined, textEllipsis: undefined });
|
||||
return;
|
||||
}
|
||||
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
|
||||
patchText({
|
||||
...textElem,
|
||||
textMaxLines: n > 0 ? n : undefined,
|
||||
textEllipsis: n > 0 ? textElem.textEllipsis : undefined,
|
||||
});
|
||||
}}
|
||||
className="ps-field font-mono"
|
||||
placeholder="不限"
|
||||
/>
|
||||
<p className="text-[9px] text-[#666] mt-0.5">留空表示不限制</p>
|
||||
</div>
|
||||
<div className="flex items-end pb-5">
|
||||
<label
|
||||
className={`flex items-center gap-2 cursor-pointer ${
|
||||
!textElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textElem.textEllipsis === true}
|
||||
disabled={!textElem.textMaxLines || textElem.textMaxLines <= 0}
|
||||
onChange={(e) =>
|
||||
patchText({ ...textElem, textEllipsis: e.target.checked ? true : undefined })
|
||||
}
|
||||
className="ps-checkbox"
|
||||
/>
|
||||
<span className="text-[12px] text-[#ccc]">超出显示省略号</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
||||
<div>
|
||||
<label className="ps-field-label">单元格背景</label>
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
textElem.backgroundColor && textElem.backgroundColor !== 'transparent'
|
||||
? textElem.backgroundColor
|
||||
: '#ffffff'
|
||||
}
|
||||
onChange={(e) => patchText({ ...textElem, backgroundColor: e.target.value })}
|
||||
className="color-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patchText({ ...textElem, backgroundColor: 'transparent' })}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||||
>
|
||||
清除背景
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ValueExtractFields
|
||||
config={textElem}
|
||||
onChange={(patch) => patchText({ ...textElem, ...patch })}
|
||||
/>
|
||||
<TextDisplayAffixFields elem={textElem} onChange={patchText} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
328
src/components/TablePropertyFields.tsx
Normal file
328
src/components/TablePropertyFields.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import {
|
||||
canMergeTableCells,
|
||||
canUnmergeTableCell,
|
||||
getCellAnchor,
|
||||
getMergeAnchorFromSelection,
|
||||
mergeTableCells,
|
||||
normalizeTableCellSelection,
|
||||
resizeTableGrid,
|
||||
scaleTableTracks,
|
||||
unmergeTableCell,
|
||||
updateTableCells,
|
||||
updateTableColWidth,
|
||||
updateTableRowHeight,
|
||||
equalizeTableColWidths,
|
||||
equalizeTableRowHeights,
|
||||
getTableColWidths,
|
||||
getTableRowHeights,
|
||||
getCellRawContent,
|
||||
type TableCellCoord,
|
||||
} from '../tableUtils';
|
||||
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
||||
import { TableCellTextFields } from './TableCellTextFields';
|
||||
import { ColorValueField } from './ColorValueField';
|
||||
|
||||
const PropInput = CommitInput;
|
||||
|
||||
interface TablePropertyFieldsProps {
|
||||
elem: TemplateElement;
|
||||
selectedCells: TableCellCoord[];
|
||||
onChange: (elem: TemplateElement) => void;
|
||||
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||
templateVariables?: string[];
|
||||
}
|
||||
|
||||
export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
|
||||
elem,
|
||||
selectedCells,
|
||||
onChange,
|
||||
onSelectTableCells,
|
||||
templateVariables = [],
|
||||
}) => {
|
||||
const rowHeights = getTableRowHeights(elem);
|
||||
const colWidths = getTableColWidths(elem);
|
||||
const normalizedCells = normalizeTableCellSelection(elem, selectedCells);
|
||||
const canMerge = canMergeTableCells(elem, selectedCells);
|
||||
const canUnmerge =
|
||||
normalizedCells.length === 1 &&
|
||||
canUnmergeTableCell(elem, normalizedCells[0].row, normalizedCells[0].col);
|
||||
|
||||
const primaryCell =
|
||||
normalizedCells.length === 1
|
||||
? getCellAnchor(elem, normalizedCells[0].row, normalizedCells[0].col)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 ps-section-divider">
|
||||
<h5 className="ps-section-title">
|
||||
<Table2 className="w-3.5 h-3.5" />
|
||||
表格结构
|
||||
</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">行数</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={elem.tableRows ?? 3}
|
||||
onCommit={(val) => {
|
||||
const rows = Math.max(1, Math.min(50, Number(val) || 1));
|
||||
onChange(resizeTableGrid(elem, rows, elem.tableCols ?? 3));
|
||||
}}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">列数</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={elem.tableCols ?? 3}
|
||||
onCommit={(val) => {
|
||||
const cols = Math.max(1, Math.min(50, Number(val) || 1));
|
||||
onChange(resizeTableGrid(elem, elem.tableRows ?? 3, cols));
|
||||
}}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-[#aaa]">行高 (mm)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(equalizeTableRowHeights(elem))}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
|
||||
title="将各行高度恢复为相等"
|
||||
>
|
||||
等分行高
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{rowHeights.map((h, i) => (
|
||||
<div key={`rh-${i}`}>
|
||||
<label className="text-[9px] text-[#666]">第 {i + 1} 行</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={h}
|
||||
onCommit={(val) =>
|
||||
onChange(updateTableRowHeight(elem, i, Number(val) || 0.5))
|
||||
}
|
||||
className="ps-field font-mono text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-[#aaa]">列宽 (mm)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(equalizeTableColWidths(elem))}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
|
||||
title="将各列宽度恢复为相等"
|
||||
>
|
||||
等分列宽
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{colWidths.map((w, i) => (
|
||||
<div key={`cw-${i}`}>
|
||||
<label className="text-[9px] text-[#666]">第 {i + 1} 列</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
value={w}
|
||||
onCommit={(val) =>
|
||||
onChange(updateTableColWidth(elem, i, Number(val) || 0.5))
|
||||
}
|
||||
className="ps-field font-mono text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">网格线宽 (mm)</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={elem.tableBorderWidth ?? 0.2}
|
||||
onCommit={(val) =>
|
||||
onChange({ ...elem, tableBorderWidth: Math.max(0, Number(val) || 0) })
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<ColorValueField
|
||||
label="网格线颜色"
|
||||
value={elem.tableBorderColor ?? ''}
|
||||
onChange={(val) => onChange({ ...elem, tableBorderColor: val || undefined })}
|
||||
variableOptions={templateVariables}
|
||||
defaultColor="#374151"
|
||||
placeholder="#374151"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{normalizedCells.length > 0 && (
|
||||
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
|
||||
<span className="text-[10px] bg-[#31a8ff] text-white font-bold px-1.5 py-0.5 inline-block">
|
||||
已选 {normalizedCells.length} 个单元格
|
||||
</span>
|
||||
<p className="text-[9px] text-[#666] leading-snug">
|
||||
单击选择单元格,拖动移动整个表格;再次点击已选单元格可取消。合并须为完整矩形。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canMerge}
|
||||
onClick={() => {
|
||||
const anchor = getMergeAnchorFromSelection(selectedCells);
|
||||
onChange(mergeTableCells(elem, selectedCells));
|
||||
onSelectTableCells?.([anchor]);
|
||||
}}
|
||||
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="合并所选单元格"
|
||||
>
|
||||
<Merge className="w-3 h-3" />
|
||||
合并单元格
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUnmerge}
|
||||
onClick={() => {
|
||||
const anchor = normalizedCells[0];
|
||||
onChange(unmergeTableCell(elem, anchor.row, anchor.col));
|
||||
onSelectTableCells?.([anchor]);
|
||||
}}
|
||||
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="取消合并"
|
||||
>
|
||||
<SplitSquareHorizontal className="w-3 h-3" />
|
||||
取消合并
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{normalizedCells.length === 1 && primaryCell && (
|
||||
<TableCellTextFields
|
||||
elem={elem}
|
||||
row={primaryCell.row}
|
||||
col={primaryCell.col}
|
||||
onChange={onChange}
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableCellContentFieldsProps {
|
||||
elem: TemplateElement;
|
||||
selectedCells: TableCellCoord[];
|
||||
onChange: (elem: TemplateElement) => void;
|
||||
templateVariables: string[];
|
||||
}
|
||||
|
||||
export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
|
||||
elem,
|
||||
selectedCells,
|
||||
onChange,
|
||||
templateVariables,
|
||||
}) => {
|
||||
if (selectedCells.length === 0) {
|
||||
return (
|
||||
<p className="text-[10px] text-[#777] leading-normal">
|
||||
在画布上点击单元格进行编辑;单元格内容默认为空,选中单个单元格可在属性面板设置文本内容与样式。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCells.length === 1) {
|
||||
return (
|
||||
<p className="text-[10px] text-[#777] leading-normal">
|
||||
已选中 1 个单元格,请在属性面板的「单元格文本」中编辑内容与样式。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCellRawContent(elem, selectedCells[0].row, selectedCells[0].col);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="ps-field-label">批量设置内容({selectedCells.length} 个单元格)</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!content}
|
||||
onClick={() => onChange(updateTableCells(elem, selectedCells, { content: '' }))}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||
>
|
||||
清空内容
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
rows={2}
|
||||
className="ps-field resize-none"
|
||||
placeholder="输入文本,使用 {变量名} 引用变量"
|
||||
onChange={(e) => {
|
||||
onChange(updateTableCells(elem, selectedCells, { content: e.target.value }));
|
||||
}}
|
||||
/>
|
||||
{templateVariables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{templateVariables.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = content + `{${v}}`;
|
||||
onChange(updateTableCells(elem, selectedCells, { content: next }));
|
||||
}}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{`{${v}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function scaleTableElementSize(
|
||||
elem: TemplateElement,
|
||||
newWidth: number,
|
||||
newHeight: number,
|
||||
startWidth = elem.width,
|
||||
startHeight = elem.height
|
||||
): TemplateElement {
|
||||
if (elem.type !== 'table') {
|
||||
return { ...elem, width: newWidth, height: newHeight };
|
||||
}
|
||||
return scaleTableTracks(
|
||||
elem,
|
||||
newWidth,
|
||||
newHeight,
|
||||
startWidth,
|
||||
startHeight,
|
||||
getTableRowHeights(elem),
|
||||
getTableColWidths(elem)
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const TextDisplayAffixFields: React.FC<TextDisplayAffixFieldsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
||||
在值提取完成后追加,仅影响显示;不参与「内容值」显示条件判断
|
||||
在值提取完成后追加,仅影响显示;反选模式下会一并参与从原值中去除;不参与「内容值」显示条件判断
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import React from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import type { ValueExtractConfig } from '../types';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface ValueExtractFieldsProps {
|
||||
elem: TemplateElement;
|
||||
onChange: (elem: TemplateElement) => void;
|
||||
config: ValueExtractConfig;
|
||||
onChange: (patch: Partial<ValueExtractConfig>) => void;
|
||||
}
|
||||
|
||||
const PropInput = CommitInput;
|
||||
|
||||
function getEffectiveExtractMode(elem: TemplateElement) {
|
||||
if (elem.textExtractMode !== undefined) return elem.textExtractMode;
|
||||
return elem.textSplitDelimiter?.trim() ? 'split' : 'none';
|
||||
function getEffectiveExtractMode(config: ValueExtractConfig) {
|
||||
if (config.textExtractMode !== undefined) return config.textExtractMode;
|
||||
return config.textSplitDelimiter?.trim() ? 'split' : 'none';
|
||||
}
|
||||
|
||||
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, onChange }) => {
|
||||
const extractMode = getEffectiveExtractMode(elem);
|
||||
type ExtractSelectValue = 'none' | 'split' | 'prefix' | 'suffix' | 'inverse';
|
||||
|
||||
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ config, onChange }) => {
|
||||
const isInverse = config.textExtractInverse === true;
|
||||
const extractMode = getEffectiveExtractMode(config);
|
||||
const selectValue: ExtractSelectValue = isInverse ? 'inverse' : extractMode;
|
||||
const showExtractFields = selectValue !== 'none';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="ps-field-label">值提取</label>
|
||||
<FieldSelect
|
||||
value={extractMode}
|
||||
value={selectValue}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix';
|
||||
const mode = e.target.value as ExtractSelectValue;
|
||||
if (mode === 'none') {
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractInverse: false,
|
||||
textExtractMode: 'none',
|
||||
textSplitDelimiter: undefined,
|
||||
textSplitIndex: undefined,
|
||||
@@ -36,14 +41,23 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === 'inverse') {
|
||||
onChange({
|
||||
textExtractInverse: true,
|
||||
textExtractMode: extractMode === 'none' ? 'prefix' : extractMode,
|
||||
textSplitIndex: config.textSplitIndex ?? 1,
|
||||
textExtractLength: config.textExtractLength ?? 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractInverse: false,
|
||||
textExtractMode: mode,
|
||||
textSplitIndex: mode === 'split' ? elem.textSplitIndex ?? 1 : elem.textSplitIndex,
|
||||
textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
|
||||
textExtractLength:
|
||||
mode === 'prefix' || mode === 'suffix'
|
||||
? elem.textExtractLength ?? 1
|
||||
: elem.textExtractLength,
|
||||
? config.textExtractLength ?? 1
|
||||
: config.textExtractLength,
|
||||
});
|
||||
}}
|
||||
className="ps-field"
|
||||
@@ -52,48 +66,76 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
||||
<option value="split">按分隔符</option>
|
||||
<option value="prefix">前 N 位</option>
|
||||
<option value="suffix">后 N 位</option>
|
||||
<option value="inverse">反选</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{extractMode === 'split' && (
|
||||
{isInverse && (
|
||||
<div>
|
||||
<label className="ps-field-label">去除段</label>
|
||||
<FieldSelect
|
||||
value={extractMode === 'none' ? 'prefix' : extractMode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'split' | 'prefix' | 'suffix';
|
||||
onChange({
|
||||
textExtractInverse: true,
|
||||
textExtractMode: mode,
|
||||
textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
|
||||
textExtractLength:
|
||||
mode === 'prefix' || mode === 'suffix'
|
||||
? config.textExtractLength ?? 1
|
||||
: config.textExtractLength,
|
||||
});
|
||||
}}
|
||||
className="ps-field"
|
||||
>
|
||||
<option value="prefix">前 N 位</option>
|
||||
<option value="split">按分隔符</option>
|
||||
<option value="suffix">后 N 位</option>
|
||||
</FieldSelect>
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
从原值中去掉「显示前缀 + 提取段 + 显示后缀」。例如原值 A-1-1-1,提取前 1 位并追加后缀
|
||||
「-」,则得到 1-1-1
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{showExtractFields && extractMode === 'split' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">分隔符</label>
|
||||
<PropInput
|
||||
type="text"
|
||||
value={elem.textSplitDelimiter ?? ''}
|
||||
value={config.textSplitDelimiter ?? ''}
|
||||
placeholder="如 -"
|
||||
onCommit={(val) => {
|
||||
const trimmed = val.trim();
|
||||
if (!trimmed) {
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractMode: 'none',
|
||||
textExtractMode: isInverse ? 'split' : 'none',
|
||||
textExtractInverse: isInverse ? true : false,
|
||||
textSplitDelimiter: undefined,
|
||||
textSplitIndex: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractMode: 'split',
|
||||
textSplitDelimiter: trimmed,
|
||||
textSplitIndex: elem.textSplitIndex ?? 1,
|
||||
textSplitIndex: config.textSplitIndex ?? 1,
|
||||
});
|
||||
}}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">显示第几位</label>
|
||||
<label className="ps-field-label">{isInverse ? '去除第几位' : '显示第几位'}</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={elem.textSplitIndex ?? 1}
|
||||
disabled={!elem.textSplitDelimiter?.trim()}
|
||||
value={config.textSplitIndex ?? 1}
|
||||
disabled={!config.textSplitDelimiter?.trim()}
|
||||
onCommit={(val) =>
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractMode: 'split',
|
||||
textSplitIndex: Math.max(1, Math.min(99, Number(val) || 1)),
|
||||
})
|
||||
@@ -101,34 +143,37 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
||||
变量替换为完整内容后,按分隔符拆分并取第 N 段(从 1 开始);段数不足时为空
|
||||
</p>
|
||||
{!isInverse && (
|
||||
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
||||
变量替换为完整内容后,按分隔符拆分并取第 N 段(从 1 开始);段数不足时为空
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(extractMode === 'prefix' || extractMode === 'suffix') && (
|
||||
{showExtractFields && (extractMode === 'prefix' || extractMode === 'suffix') && (
|
||||
<div>
|
||||
<label className="ps-field-label">
|
||||
{extractMode === 'prefix' ? '前' : '后'} N 位
|
||||
{isInverse ? '去除' : extractMode === 'prefix' ? '前' : '后'} N 位
|
||||
</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
value={elem.textExtractLength ?? 1}
|
||||
value={config.textExtractLength ?? 1}
|
||||
onCommit={(val) =>
|
||||
onChange({
|
||||
...elem,
|
||||
textExtractLength: Math.max(1, Math.min(999, Number(val) || 1)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
{extractMode === 'prefix'
|
||||
? '变量替换为完整内容后,取最前面的 N 个字符'
|
||||
: '变量替换为完整内容后,取最后面的 N 个字符'}
|
||||
</p>
|
||||
{!isInverse && (
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
{extractMode === 'prefix'
|
||||
? '变量替换为完整内容后,取最前面的 N 个字符'
|
||||
: '变量替换为完整内容后,取最后面的 N 个字符'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
98
src/components/VariableFilterRulesEditor.tsx
Normal file
98
src/components/VariableFilterRulesEditor.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import type { VisibilityRule } from '../types';
|
||||
import { resolveVariableFilterRules, toggleVisibilityRuleEnabled } from '../utils';
|
||||
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
||||
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
||||
|
||||
interface VariableFilterRulesEditorProps {
|
||||
rules?: VisibilityRule[];
|
||||
templateVariables: string[];
|
||||
disabled?: boolean;
|
||||
emptyHint?: string;
|
||||
description?: string;
|
||||
addTitle?: string;
|
||||
editTitle?: string;
|
||||
onChangeRules: (rules: VisibilityRule[]) => void;
|
||||
}
|
||||
|
||||
export const VariableFilterRulesEditor: React.FC<VariableFilterRulesEditorProps> = ({
|
||||
rules = [],
|
||||
templateVariables,
|
||||
disabled = false,
|
||||
emptyHint = '尚未添加过滤条件',
|
||||
description = '与元素「显示控制」相同:全部条件满足时才纳入;按当前数据行与变量默认值判断。',
|
||||
addTitle = '添加过滤条件',
|
||||
editTitle = '编辑过滤条件',
|
||||
onChangeRules,
|
||||
}) => {
|
||||
const [ruleDialog, setRuleDialog] = useState<{ mode: 'add' | 'edit'; index?: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const normalizedRules = resolveVariableFilterRules(rules);
|
||||
|
||||
const editingRule =
|
||||
ruleDialog?.mode === 'edit' && ruleDialog.index !== undefined
|
||||
? normalizedRules[ruleDialog.index]
|
||||
: undefined;
|
||||
|
||||
const handleConfirmRule = (rule: VisibilityRule) => {
|
||||
if (!ruleDialog) return;
|
||||
if (ruleDialog.mode === 'edit' && ruleDialog.index !== undefined) {
|
||||
onChangeRules(normalizedRules.map((item, index) => (index === ruleDialog.index ? rule : item)));
|
||||
return;
|
||||
}
|
||||
onChangeRules([...normalizedRules, rule]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[9px] text-[#777] leading-relaxed">{description}</p>
|
||||
{normalizedRules.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{normalizedRules.map((rule, index) => (
|
||||
<VisibilityRuleSummary
|
||||
key={`${rule.variable}-${rule.operator}-${index}`}
|
||||
rule={rule}
|
||||
onEdit={() => setRuleDialog({ mode: 'edit', index })}
|
||||
onDelete={() => onChangeRules(normalizedRules.filter((_, i) => i !== index))}
|
||||
onToggleEnabled={() =>
|
||||
onChangeRules(
|
||||
normalizedRules.map((item, i) =>
|
||||
i === index ? toggleVisibilityRuleEnabled(item) : item
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#888]">{emptyHint}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRuleDialog({ mode: 'add' })}
|
||||
disabled={disabled}
|
||||
className="ps-btn w-full text-[10px] justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加条件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VisibilityRuleDialog
|
||||
open={ruleDialog !== null}
|
||||
mode={ruleDialog?.mode ?? 'add'}
|
||||
initialRule={editingRule}
|
||||
variableOptions={templateVariables}
|
||||
variableOnly
|
||||
addTitle={addTitle}
|
||||
editTitle={editTitle}
|
||||
onClose={() => setRuleDialog(null)}
|
||||
onConfirm={handleConfirmRule}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { VisibilityRule } from '../types';
|
||||
import { visibilityOperatorNeedsValue } from '../utils';
|
||||
import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
|
||||
import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm';
|
||||
|
||||
const EMPTY_DRAFT: VisibilityRuleDraft = {
|
||||
@@ -11,11 +12,54 @@ const EMPTY_DRAFT: VisibilityRuleDraft = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
function pickRuleExtractFields(draft: VisibilityRuleDraft): Partial<VisibilityRule> {
|
||||
if (!valueExtractConfigHasExtract(draft)) {
|
||||
return {
|
||||
textExtractMode: undefined,
|
||||
textSplitDelimiter: undefined,
|
||||
textSplitIndex: undefined,
|
||||
textExtractLength: undefined,
|
||||
textExtractInverse: undefined,
|
||||
textDisplayPrefix: undefined,
|
||||
textDisplaySuffix: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
textExtractMode: draft.textExtractMode,
|
||||
textSplitDelimiter: draft.textSplitDelimiter,
|
||||
textSplitIndex: draft.textSplitIndex,
|
||||
textExtractLength: draft.textExtractLength,
|
||||
textExtractInverse: draft.textExtractInverse || undefined,
|
||||
textDisplayPrefix: draft.textDisplayPrefix || undefined,
|
||||
textDisplaySuffix: draft.textDisplaySuffix || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ruleToDraft(rule: VisibilityRule): VisibilityRuleDraft {
|
||||
return {
|
||||
target: rule.target ?? 'variable',
|
||||
variable: rule.variable,
|
||||
operator: rule.operator,
|
||||
value: rule.value ?? '',
|
||||
textExtractMode: rule.textExtractMode,
|
||||
textSplitDelimiter: rule.textSplitDelimiter,
|
||||
textSplitIndex: rule.textSplitIndex,
|
||||
textExtractLength: rule.textExtractLength,
|
||||
textExtractInverse: rule.textExtractInverse,
|
||||
textDisplayPrefix: rule.textDisplayPrefix,
|
||||
textDisplaySuffix: rule.textDisplaySuffix,
|
||||
};
|
||||
}
|
||||
|
||||
interface VisibilityRuleDialogProps {
|
||||
open: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
initialRule?: VisibilityRule;
|
||||
variableOptions: string[];
|
||||
variableOnly?: boolean;
|
||||
addTitle?: string;
|
||||
editTitle?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (rule: VisibilityRule) => void;
|
||||
}
|
||||
@@ -25,6 +69,9 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
mode,
|
||||
initialRule,
|
||||
variableOptions,
|
||||
variableOnly = false,
|
||||
addTitle = '添加显示条件',
|
||||
editTitle = '编辑显示条件',
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
@@ -33,19 +80,14 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'edit' && initialRule) {
|
||||
setDraft({
|
||||
target: initialRule.target ?? 'variable',
|
||||
variable: initialRule.variable,
|
||||
operator: initialRule.operator,
|
||||
value: initialRule.value ?? '',
|
||||
});
|
||||
setDraft(ruleToDraft(initialRule));
|
||||
} else {
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
target: variableOptions.length > 0 ? 'variable' : 'content',
|
||||
target: variableOnly || variableOptions.length > 0 ? 'variable' : 'content',
|
||||
});
|
||||
}
|
||||
}, [open, mode, initialRule, variableOptions.length]);
|
||||
}, [open, mode, initialRule, variableOptions.length, variableOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -59,12 +101,11 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!open || typeof document === 'undefined') return null;
|
||||
|
||||
const target = draft.target ?? 'variable';
|
||||
const canConfirm =
|
||||
target === 'content' || draft.variable.trim().length > 0;
|
||||
const title = mode === 'add' ? '添加显示条件' : '编辑显示条件';
|
||||
const canConfirm = target === 'content' || draft.variable.trim().length > 0;
|
||||
const title = mode === 'add' ? addTitle : editTitle;
|
||||
const confirmLabel = mode === 'add' ? '确定添加' : '保存';
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -77,12 +118,13 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
value: visibilityOperatorNeedsValue(draft.operator)
|
||||
? draft.value?.trim() || undefined
|
||||
: undefined,
|
||||
...pickRuleExtractFields(draft),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-modal-overlay" onClick={onClose} role="presentation">
|
||||
return createPortal(
|
||||
<div className="ps-modal-overlay" role="presentation">
|
||||
<div
|
||||
className="ps-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -109,6 +151,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
rule={draft}
|
||||
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
|
||||
variableOptions={variableOptions}
|
||||
variableOnly={variableOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="ps-modal-footer">
|
||||
@@ -125,6 +168,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,16 @@ import {
|
||||
VisibilityRule,
|
||||
VisibilityOperator,
|
||||
VisibilityRuleTarget,
|
||||
ValueExtractConfig,
|
||||
VISIBILITY_OPERATOR_LABELS,
|
||||
VISIBILITY_RULE_TARGET_LABELS,
|
||||
} from '../types';
|
||||
import { visibilityOperatorNeedsValue } from '../utils';
|
||||
import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { ValueExtractFields } from './ValueExtractFields';
|
||||
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||||
|
||||
export interface VisibilityRuleDraft {
|
||||
export interface VisibilityRuleDraft extends ValueExtractConfig {
|
||||
target: VisibilityRuleTarget;
|
||||
variable: string;
|
||||
operator: VisibilityOperator;
|
||||
@@ -20,37 +23,43 @@ interface VisibilityRuleFormProps {
|
||||
rule: VisibilityRuleDraft;
|
||||
onChange: (patch: Partial<VisibilityRule>) => void;
|
||||
variableOptions: string[];
|
||||
/** 为 true 时仅支持按变量判断(如批量导出过滤) */
|
||||
variableOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
rule,
|
||||
onChange,
|
||||
variableOptions,
|
||||
variableOnly = false,
|
||||
}) => {
|
||||
const target = rule.target ?? 'variable';
|
||||
const target = variableOnly ? 'variable' : (rule.target ?? 'variable');
|
||||
const hasExtract = valueExtractConfigHasExtract(rule);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="ps-field-label">条件对象</label>
|
||||
<FieldSelect
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
const nextTarget = e.target.value as VisibilityRuleTarget;
|
||||
onChange({
|
||||
target: nextTarget,
|
||||
variable: nextTarget === 'content' ? '' : rule.variable,
|
||||
});
|
||||
}}
|
||||
className="ps-field text-[11px] w-full mt-0.5"
|
||||
>
|
||||
{(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{VISIBILITY_RULE_TARGET_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{!variableOnly && (
|
||||
<div>
|
||||
<label className="ps-field-label">条件对象</label>
|
||||
<FieldSelect
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
const nextTarget = e.target.value as VisibilityRuleTarget;
|
||||
onChange({
|
||||
target: nextTarget,
|
||||
variable: nextTarget === 'content' ? '' : rule.variable,
|
||||
});
|
||||
}}
|
||||
className="ps-field text-[11px] w-full mt-0.5"
|
||||
>
|
||||
{(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{VISIBILITY_RULE_TARGET_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</div>
|
||||
)}
|
||||
{target === 'variable' ? (
|
||||
<div>
|
||||
<label className="ps-field-label">变量</label>
|
||||
@@ -68,15 +77,56 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
</FieldSelect>
|
||||
{variableOptions.length === 0 && (
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
请先在「变量管理」中添加变量,或改用「内容值」作为条件对象
|
||||
{variableOnly
|
||||
? '请先在模板中添加变量后再设置过滤条件'
|
||||
: '请先在「变量管理」中添加变量,或改用「内容值」作为条件对象'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||
使用本元素绑定内容解析并提取后的显示文本(含数值格式等;不含前缀/后缀字符)
|
||||
</p>
|
||||
!variableOnly && (
|
||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||
使用本元素绑定内容的变量替换与数值格式化结果(不含元素自身的值提取与显示前后缀)
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
<div className="pt-2 border-t border-[#3a3a3a] space-y-2">
|
||||
<div className="text-[10px] font-bold text-[#31a8ff]">值提取</div>
|
||||
<ValueExtractFields config={rule} onChange={onChange} />
|
||||
{hasExtract && rule.textExtractInverse && (
|
||||
<TextDisplayAffixFields
|
||||
elem={{
|
||||
id: '',
|
||||
type: 'text',
|
||||
name: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
content: '',
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
fontWeight: 'normal',
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: true,
|
||||
fontFamily: 'sans',
|
||||
textDisplayPrefix: rule.textDisplayPrefix,
|
||||
textDisplaySuffix: rule.textDisplaySuffix,
|
||||
}}
|
||||
onChange={(patch) =>
|
||||
onChange({
|
||||
textDisplayPrefix: patch.textDisplayPrefix,
|
||||
textDisplaySuffix: patch.textDisplaySuffix,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasExtract && !rule.textExtractInverse && (
|
||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||
条件判断前先对{target === 'content' ? '内容值' : '变量值'}做提取,再与目标值比较
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">条件</label>
|
||||
<FieldSelect
|
||||
@@ -99,14 +149,51 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
</div>
|
||||
{visibilityOperatorNeedsValue(rule.operator) && (
|
||||
<div>
|
||||
<label className="ps-field-label">目标值</label>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="ps-field-label">目标值</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!rule.value}
|
||||
onClick={() => onChange({ value: '' })}
|
||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value ?? ''}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
placeholder="比较目标值"
|
||||
placeholder="比较目标值,可使用 {变量名}"
|
||||
className="ps-field font-mono text-[11px] w-full mt-0.5"
|
||||
/>
|
||||
{variableOptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{variableOptions.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const token = `{${v}}`;
|
||||
const current = rule.value ?? '';
|
||||
onChange({
|
||||
value: current.includes(token)
|
||||
? current
|
||||
: current
|
||||
? `${current}${current.endsWith(' ') ? '' : ' '}${token}`
|
||||
: token,
|
||||
});
|
||||
}}
|
||||
className="ps-btn text-[9px] font-mono"
|
||||
>
|
||||
{`{${v}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
支持字面量或 {`{变量名}`};导出时按数据行解析变量后再比较
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { VisibilityRule } from '../types';
|
||||
import { formatVisibilityRuleDescription } from '../utils';
|
||||
import { formatVisibilityRuleDescription, isVisibilityRuleEnabled } from '../utils';
|
||||
|
||||
interface VisibilityRuleSummaryProps {
|
||||
rule: VisibilityRule;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleEnabled?: () => void;
|
||||
}
|
||||
|
||||
/** 将 {变量} 或「内容值」高亮展示 */
|
||||
@@ -37,31 +38,46 @@ export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
|
||||
rule,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className="visibility-rule-summary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="visibility-rule-summary-btn"
|
||||
title="编辑条件"
|
||||
>
|
||||
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title="编辑"
|
||||
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
title="删除条件"
|
||||
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const enabled = isVisibilityRuleEnabled(rule);
|
||||
|
||||
return (
|
||||
<div className={`visibility-rule-summary${enabled ? '' : ' is-disabled'}`}>
|
||||
{onToggleEnabled && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={onToggleEnabled}
|
||||
className="ps-checkbox shrink-0"
|
||||
title={enabled ? '禁用条件' : '启用条件'}
|
||||
aria-label={enabled ? '禁用条件' : '启用条件'}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="visibility-rule-summary-btn"
|
||||
title="编辑条件"
|
||||
>
|
||||
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title="编辑"
|
||||
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
title="删除条件"
|
||||
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,30 @@ export function getSelectionBounds(selected: TemplateElement[]) {
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** 将选中元素整体平移,使选区左上角移至 (newMinX, newMinY) */
|
||||
export function setSelectionOrigin(
|
||||
elements: TemplateElement[],
|
||||
selectedIds: string[],
|
||||
newMinX: number,
|
||||
newMinY: number
|
||||
): TemplateElement[] {
|
||||
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
|
||||
if (selected.length === 0) return elements;
|
||||
const { minX, minY } = getSelectionBounds(selected);
|
||||
const deltaX = round1(newMinX - minX);
|
||||
const deltaY = round1(newMinY - minY);
|
||||
if (deltaX === 0 && deltaY === 0) return elements;
|
||||
const idSet = new Set(selected.map((e) => e.id));
|
||||
return elements.map((el) => {
|
||||
if (!idSet.has(el.id)) return el;
|
||||
return {
|
||||
...el,
|
||||
x: round1(el.x + deltaX),
|
||||
y: round1(el.y + deltaY),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 选中元素对齐到共同选区边界 */
|
||||
export function alignToSelectionBounds(
|
||||
elements: TemplateElement[],
|
||||
|
||||
278
src/elementMask.ts
Normal file
278
src/elementMask.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { ElementMaskConfig, TemplateElement } from './types';
|
||||
import { traceRoundedRect, type CornerRadiiPx } from './elementBox';
|
||||
|
||||
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = trimmed;
|
||||
});
|
||||
}
|
||||
|
||||
function drawImageWithFit(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
fit: 'contain' | 'cover' | 'fill' = 'contain'
|
||||
) {
|
||||
if (fit === 'fill') {
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
return;
|
||||
}
|
||||
const imgRatio = img.width / img.height;
|
||||
const boxRatio = w / h;
|
||||
if (fit === 'contain') {
|
||||
let dw = w;
|
||||
let dh = h;
|
||||
let dx = x;
|
||||
let dy = y;
|
||||
if (imgRatio > boxRatio) {
|
||||
dh = w / imgRatio;
|
||||
dy = y + (h - dh) / 2;
|
||||
} else {
|
||||
dw = h * imgRatio;
|
||||
dx = x + (w - dw) / 2;
|
||||
}
|
||||
ctx.drawImage(img, dx, dy, dw, dh);
|
||||
return;
|
||||
}
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = img.width;
|
||||
let sh = img.height;
|
||||
if (imgRatio > boxRatio) {
|
||||
sw = img.height * boxRatio;
|
||||
sx = (img.width - sw) / 2;
|
||||
} else {
|
||||
sh = img.width / boxRatio;
|
||||
sy = (img.height - sh) / 2;
|
||||
}
|
||||
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
|
||||
}
|
||||
|
||||
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
|
||||
if (!mask) return false;
|
||||
if (mask.enabled === false) return false;
|
||||
if (mask.type === 'image') return !!mask.image?.trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
type: 'rect',
|
||||
mode: 'include',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: elem.width,
|
||||
height: elem.height,
|
||||
borderRadius: 0,
|
||||
imageFit: 'fill',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMaskRegionMm(
|
||||
elem: TemplateElement,
|
||||
mask: ElementMaskConfig
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
return {
|
||||
x: mask.x ?? 0,
|
||||
y: mask.y ?? 0,
|
||||
width: mask.width ?? elem.width,
|
||||
height: mask.height ?? elem.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMaskCornerRadiiPx(
|
||||
mask: ElementMaskConfig,
|
||||
scale: number,
|
||||
w: number,
|
||||
h: number
|
||||
): CornerRadiiPx {
|
||||
const maxR = Math.min(w, h) / 2;
|
||||
const baseMm = mask.borderRadius ?? 0;
|
||||
|
||||
const radiusFor = (specificMm?: number): number => {
|
||||
const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm;
|
||||
if (mm <= 0) return 0;
|
||||
return Math.min(maxR, mm * scale);
|
||||
};
|
||||
|
||||
return {
|
||||
tl: radiusFor(mask.borderRadiusTL),
|
||||
tr: radiusFor(mask.borderRadiusTR),
|
||||
br: radiusFor(mask.borderRadiusBR),
|
||||
bl: radiusFor(mask.borderRadiusBL),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMaskRegionPx(
|
||||
elem: TemplateElement,
|
||||
mask: ElementMaskConfig,
|
||||
scale: number
|
||||
) {
|
||||
const region = resolveMaskRegionMm(elem, mask);
|
||||
return {
|
||||
x: Math.round(region.x * scale),
|
||||
y: Math.round(region.y * scale),
|
||||
w: Math.max(1, Math.round(region.width * scale)),
|
||||
h: Math.max(1, Math.round(region.height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function fillRectMaskAlpha(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
mx: number,
|
||||
my: number,
|
||||
mw: number,
|
||||
mh: number,
|
||||
mask: ElementMaskConfig,
|
||||
scale: number
|
||||
) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/** 将图片按亮度转为 alpha 蒙版(白=不透明,黑=透明) */
|
||||
function buildImageLuminanceMaskCanvas(
|
||||
img: HTMLImageElement,
|
||||
w: number,
|
||||
h: number,
|
||||
fit: 'contain' | 'cover' | 'fill',
|
||||
invert: boolean
|
||||
): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return canvas;
|
||||
|
||||
drawImageWithFit(ctx, img, 0, 0, w, h, fit);
|
||||
const imageData = ctx.getImageData(0, 0, w, h);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
const alpha = invert ? 255 - luminance : luminance;
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = alpha;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** 生成与元素同尺寸的蒙版 alpha 图(不透明区域保留图层像素) */
|
||||
async function buildMaskAlphaCanvas(
|
||||
elem: TemplateElement,
|
||||
mask: ElementMaskConfig,
|
||||
ew: number,
|
||||
eh: number,
|
||||
scale: number
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, ew);
|
||||
canvas.height = Math.max(1, eh);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas;
|
||||
|
||||
const { x: mx, y: my, w: mw, h: mh } = resolveMaskRegionPx(elem, mask, scale);
|
||||
const include = (mask.mode ?? 'include') === 'include';
|
||||
|
||||
if (mask.type === 'image' && mask.image) {
|
||||
const img = await loadImageOrNull(mask.image);
|
||||
if (img) {
|
||||
const maskCanvas = buildImageLuminanceMaskCanvas(
|
||||
img,
|
||||
mw,
|
||||
mh,
|
||||
mask.imageFit ?? 'fill',
|
||||
!include
|
||||
);
|
||||
ctx.drawImage(maskCanvas, mx, my);
|
||||
} else if (include) {
|
||||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
if (include) {
|
||||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, ew, eh);
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** 对已绘制的图层离屏画布应用蒙版(destination-in) */
|
||||
async function applyMaskToLayerCanvas(
|
||||
layerCtx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
mask: ElementMaskConfig,
|
||||
ew: number,
|
||||
eh: number,
|
||||
scale: number
|
||||
) {
|
||||
const alphaCanvas = await buildMaskAlphaCanvas(elem, mask, ew, eh, scale);
|
||||
layerCtx.save();
|
||||
layerCtx.globalCompositeOperation = 'destination-in';
|
||||
layerCtx.drawImage(alphaCanvas, 0, 0);
|
||||
layerCtx.restore();
|
||||
}
|
||||
|
||||
type DrawLayerFn = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层(背景、内容、边框),应用蒙版后再合成到主画布。
|
||||
*/
|
||||
export async function composeElementLayer(
|
||||
mainCtx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
ex: number,
|
||||
ey: number,
|
||||
ew: number,
|
||||
eh: number,
|
||||
scale: number,
|
||||
drawLayer: DrawLayerFn
|
||||
) {
|
||||
const mask = isElementMaskActive(elem.mask) ? elem.mask : undefined;
|
||||
|
||||
if (!mask) {
|
||||
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||||
return;
|
||||
}
|
||||
|
||||
const layerCanvas = document.createElement('canvas');
|
||||
layerCanvas.width = Math.max(1, ew);
|
||||
layerCanvas.height = Math.max(1, eh);
|
||||
const layerCtx = layerCanvas.getContext('2d');
|
||||
if (!layerCtx) {
|
||||
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||||
return;
|
||||
}
|
||||
|
||||
await drawLayer(layerCtx, 0, 0, ew, eh);
|
||||
await applyMaskToLayerCanvas(layerCtx, elem, mask, ew, eh, scale);
|
||||
mainCtx.drawImage(layerCanvas, ex, ey);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 composeElementLayer */
|
||||
export const drawElementContentWithMask = composeElementLayer;
|
||||
113
src/hooks/useElementClipboard.ts
Normal file
113
src/hooks/useElementClipboard.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { LabelTemplate, TemplateElement } from '../types';
|
||||
import type { TableCellCoord } from '../tableUtils';
|
||||
import { cloneTemplateElementsForPaste } from '../utils';
|
||||
|
||||
/** 应用内元素剪贴板(内存,跨模板/跨页面模块共享) */
|
||||
const globalElementClipboard: { elements: TemplateElement[] | null } = { elements: null };
|
||||
|
||||
function getClipboardCount() {
|
||||
return globalElementClipboard.elements?.length ?? 0;
|
||||
}
|
||||
|
||||
function setGlobalClipboard(elements: TemplateElement[] | null) {
|
||||
globalElementClipboard.elements = elements;
|
||||
}
|
||||
|
||||
export interface UseElementClipboardOptions {
|
||||
template: LabelTemplate;
|
||||
onChange: (updated: LabelTemplate) => void;
|
||||
selectedElementIds: string[];
|
||||
onSelectElements: (ids: string[]) => void;
|
||||
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||
onCopySuccess?: (count: number) => void;
|
||||
onDuplicateSuccess?: (count: number) => void;
|
||||
}
|
||||
|
||||
export interface ElementClipboardActions {
|
||||
copySelectedElements: () => void;
|
||||
pasteClipboardElements: () => void;
|
||||
/** 复制并立即粘贴(移动端快捷复制) */
|
||||
copyAndPasteSelectedElements: () => void;
|
||||
canCopy: boolean;
|
||||
canPaste: boolean;
|
||||
clipboardCount: number;
|
||||
}
|
||||
|
||||
export function useElementClipboard({
|
||||
template,
|
||||
onChange,
|
||||
selectedElementIds,
|
||||
onSelectElements,
|
||||
onSelectTableCells,
|
||||
onCopySuccess,
|
||||
onDuplicateSuccess,
|
||||
}: UseElementClipboardOptions): ElementClipboardActions {
|
||||
const templateRef = useRef(template);
|
||||
templateRef.current = template;
|
||||
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
|
||||
|
||||
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
|
||||
setGlobalClipboard(elements);
|
||||
setClipboardCount(elements?.length ?? 0);
|
||||
}, []);
|
||||
|
||||
const canCopy = selectedElementIds.length > 0;
|
||||
const canPaste = clipboardCount > 0;
|
||||
|
||||
const copySelectedElements = useCallback(() => {
|
||||
if (selectedElementIds.length === 0) return;
|
||||
const current = templateRef.current;
|
||||
const idSet = new Set(selectedElementIds);
|
||||
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||
if (toCopy.length === 0) return;
|
||||
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
|
||||
syncClipboardCount(cloned);
|
||||
onCopySuccess?.(toCopy.length);
|
||||
}, [selectedElementIds, syncClipboardCount, onCopySuccess]);
|
||||
|
||||
const pasteClipboardElements = useCallback(() => {
|
||||
const source = globalElementClipboard.elements;
|
||||
if (!source || source.length === 0) return;
|
||||
const current = templateRef.current;
|
||||
const pasted = cloneTemplateElementsForPaste(source, 1);
|
||||
onChange({
|
||||
...current,
|
||||
elements: [...current.elements, ...pasted],
|
||||
});
|
||||
onSelectElements(pasted.map((el) => el.id));
|
||||
onSelectTableCells?.([]);
|
||||
syncClipboardCount(null);
|
||||
}, [onChange, onSelectElements, onSelectTableCells, syncClipboardCount]);
|
||||
|
||||
const copyAndPasteSelectedElements = useCallback(() => {
|
||||
if (selectedElementIds.length === 0) return;
|
||||
const current = templateRef.current;
|
||||
const idSet = new Set(selectedElementIds);
|
||||
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||
if (toCopy.length === 0) return;
|
||||
const pasted = cloneTemplateElementsForPaste(toCopy, 1);
|
||||
onChange({
|
||||
...current,
|
||||
elements: [...current.elements, ...pasted],
|
||||
});
|
||||
onSelectElements(pasted.map((el) => el.id));
|
||||
onSelectTableCells?.([]);
|
||||
onDuplicateSuccess?.(toCopy.length);
|
||||
}, [
|
||||
selectedElementIds,
|
||||
onChange,
|
||||
onSelectElements,
|
||||
onSelectTableCells,
|
||||
onDuplicateSuccess,
|
||||
]);
|
||||
|
||||
return {
|
||||
copySelectedElements,
|
||||
pasteClipboardElements,
|
||||
copyAndPasteSelectedElements,
|
||||
canCopy,
|
||||
canPaste,
|
||||
clipboardCount,
|
||||
};
|
||||
}
|
||||
@@ -17,9 +17,18 @@ export function useMediaQuery(query: string): boolean {
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** 与 Tailwind `md` 断点一致:< 768px 视为移动端 */
|
||||
/** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
|
||||
export const MOBILE_LAYOUT_MEDIA_QUERY =
|
||||
'(max-width: 767px), (max-width: 1023px) and (orientation: portrait)';
|
||||
|
||||
/** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 767px)');
|
||||
return useMediaQuery(MOBILE_LAYOUT_MEDIA_QUERY);
|
||||
}
|
||||
|
||||
/** 语义别名:是否使用移动端上下布局 */
|
||||
export function useMobileLayout(): boolean {
|
||||
return useIsMobile();
|
||||
}
|
||||
|
||||
/** 平板等窄屏:< 1024px */
|
||||
|
||||
205
src/index.css
205
src/index.css
@@ -1,10 +1,16 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -13,6 +19,15 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
html.app-dark-shell,
|
||||
html.app-dark-shell body {
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
/* 可滚动区域隐藏滚动条 */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
@@ -575,7 +590,7 @@ body {
|
||||
.ps-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -656,6 +671,20 @@ body {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.visibility-rule-summary.is-disabled {
|
||||
opacity: 0.62;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.visibility-rule-summary.is-disabled .visibility-rule-summary-text {
|
||||
color: #777;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.visibility-rule-summary.is-disabled .visibility-rule-summary-text .var-token {
|
||||
color: #5a8fb0;
|
||||
}
|
||||
|
||||
.visibility-rule-summary-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -724,11 +753,14 @@ body {
|
||||
}
|
||||
|
||||
.ps-layer-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid #222;
|
||||
@@ -754,6 +786,83 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ps-layer-item-hidden {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ps-layer-item-hidden .truncate {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ps-layer-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ps-layer-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* PC:未 hover 时不占位,悬停时浮于右侧 */
|
||||
.ps-layer-item-actions--float {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding-left: 16px;
|
||||
background: linear-gradient(to left, #2d2d2d 180px, transparent);
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.ps-layer-item:hover .ps-layer-item-actions--float {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
background: linear-gradient(to left, #383838 180px, transparent);
|
||||
}
|
||||
|
||||
.ps-layer-item.selected:hover .ps-layer-item-actions--float {
|
||||
background: linear-gradient(to left, #3d4f5f 180px, transparent);
|
||||
}
|
||||
|
||||
.ps-layer-item-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* .ps-layer-item-actions button:hover:not(:disabled) {
|
||||
background: #555;
|
||||
} */
|
||||
|
||||
.ps-layer-item-actions button:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ps-layer-item-actions button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ps-marquee-select {
|
||||
position: absolute;
|
||||
border: 1px solid #31a8ff;
|
||||
@@ -768,6 +877,12 @@ body {
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.ps-resize-outline {
|
||||
outline: 1px dashed var(--color-ps-accent);
|
||||
outline-offset: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(49, 168, 255, 0.2);
|
||||
}
|
||||
|
||||
.ps-unselected-dim-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -780,10 +895,6 @@ body {
|
||||
|
||||
.ps-unselected-dim-layer--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ps-unselected-dim {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@@ -867,8 +978,8 @@ body {
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* ── Mobile layout (< 768px) ── */
|
||||
@media (max-width: 767px) {
|
||||
/* ── Mobile layout:手机窄屏,或平板竖屏 ── */
|
||||
@media (max-width: 767px), (max-width: 1023px) and (orientation: portrait) {
|
||||
|
||||
/* Mobile export flow — shares mobile-design-view shell */
|
||||
.mobile-export-view {
|
||||
@@ -1477,13 +1588,25 @@ body {
|
||||
gap: 4px;
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
padding: 8px 6px;
|
||||
background: #252525;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-design-props-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.mobile-design-props-nav-spacer {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
@@ -1556,21 +1679,66 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-design-toolstrip-tools::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-design-tool-btn {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
min-height: 40px !important;
|
||||
border-radius: 10px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-design-tool-btn.active:hover {
|
||||
background: #1a1a1a;
|
||||
color: var(--color-ps-accent);
|
||||
}
|
||||
|
||||
.mobile-design-tool-btn:hover {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile-toolstrip-overflow-menu {
|
||||
min-width: 148px;
|
||||
padding: 4px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mobile-toolstrip-overflow-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-toolstrip-overflow-menu-item:hover,
|
||||
.mobile-toolstrip-overflow-menu-item.active {
|
||||
background: #1e3a4f;
|
||||
color: #31a8ff;
|
||||
}
|
||||
|
||||
.mobile-toolstrip-overflow-menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1654,7 +1822,8 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* 桌面宽屏 / 横屏平板:隐藏仅移动端渲染的视图 */
|
||||
@media (min-width: 768px) and (orientation: landscape), (min-width: 1024px) {
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
146
src/labelImagesExport.ts
Normal file
146
src/labelImagesExport.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import JSZip from 'jszip';
|
||||
import type { LabelTemplate } from './types';
|
||||
import {
|
||||
filterLabelsForImageExport,
|
||||
resolveExportImageFileName,
|
||||
type PrintableLabel,
|
||||
} from './utils';
|
||||
|
||||
export interface LabelImagesExportProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
phase: 'render' | 'zip' | 'save';
|
||||
zipPercent?: number;
|
||||
}
|
||||
|
||||
export interface ExportLabelsImagesZipOptions {
|
||||
printablePages: (PrintableLabel | null)[][];
|
||||
filename?: string;
|
||||
imageExtension: 'png' | 'jpg';
|
||||
fileNamePattern?: string;
|
||||
variableDefaults?: Record<string, string> | null;
|
||||
exportFilter?: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>;
|
||||
renderLabel: (label: PrintableLabel) => Promise<string>;
|
||||
onProgress?: (progress: LabelImagesExportProgress) => void;
|
||||
}
|
||||
|
||||
const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const RENDER_CONCURRENCY = 3;
|
||||
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const [header, base64] = dataUrl.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function assignUniqueZipEntryName(
|
||||
baseName: string,
|
||||
extension: string,
|
||||
usedNames: Map<string, number>
|
||||
): string {
|
||||
const first = `${baseName}.${extension}`;
|
||||
if (!usedNames.has(first)) {
|
||||
usedNames.set(first, 1);
|
||||
return first;
|
||||
}
|
||||
const duplicate = usedNames.get(first)! + 1;
|
||||
usedNames.set(first, duplicate);
|
||||
return `${baseName}_${duplicate}.${extension}`;
|
||||
}
|
||||
|
||||
/** 逐张渲染标签图片,按模板命名并打包为 ZIP 下载 */
|
||||
export async function exportLabelsImagesZipAsync(
|
||||
options: ExportLabelsImagesZipOptions
|
||||
): Promise<void> {
|
||||
const {
|
||||
printablePages,
|
||||
filename = 'labels.zip',
|
||||
imageExtension,
|
||||
fileNamePattern,
|
||||
variableDefaults = null,
|
||||
exportFilter,
|
||||
renderLabel,
|
||||
onProgress,
|
||||
} = options;
|
||||
|
||||
const labels = filterLabelsForImageExport(
|
||||
printablePages,
|
||||
exportFilter ?? { exportImageFilterMode: 'always' },
|
||||
variableDefaults
|
||||
);
|
||||
const total = labels.length;
|
||||
if (total === 0) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
const padWidth = String(total).length;
|
||||
const usedNames = new Map<string, number>();
|
||||
let done = 0;
|
||||
let progressTick = 0;
|
||||
|
||||
const report = (phase: LabelImagesExportProgress['phase'], zipPercent?: number) => {
|
||||
onProgress?.({ done, total, phase, zipPercent });
|
||||
};
|
||||
|
||||
report('render');
|
||||
|
||||
for (let i = 0; i < labels.length; i += RENDER_CONCURRENCY) {
|
||||
const batch = labels.slice(i, i + RENDER_CONCURRENCY);
|
||||
const rendered = await Promise.all(
|
||||
batch.map(async (label, batchIdx) => {
|
||||
const seq = i + batchIdx + 1;
|
||||
const dataUrl = await renderLabel(label);
|
||||
return { seq, label, dataUrl };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { seq, label, dataUrl } of rendered) {
|
||||
const baseName = resolveExportImageFileName(
|
||||
fileNamePattern,
|
||||
label,
|
||||
seq,
|
||||
padWidth,
|
||||
variableDefaults
|
||||
);
|
||||
const name = assignUniqueZipEntryName(baseName, imageExtension, usedNames);
|
||||
zip.file(name, dataUrlToBlob(dataUrl));
|
||||
done++;
|
||||
}
|
||||
|
||||
progressTick++;
|
||||
if (progressTick >= 2) {
|
||||
progressTick = 0;
|
||||
report('render');
|
||||
await yieldToMain();
|
||||
}
|
||||
}
|
||||
|
||||
report('render');
|
||||
await yieldToMain();
|
||||
|
||||
report('zip', 0);
|
||||
const zipBlob = await zip.generateAsync(
|
||||
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } },
|
||||
(metadata) => {
|
||||
report('zip', metadata.percent);
|
||||
}
|
||||
);
|
||||
|
||||
report('save');
|
||||
await yieldToMain();
|
||||
downloadBlob(zipBlob, filename);
|
||||
}
|
||||
224
src/labelPrint.ts
Normal file
224
src/labelPrint.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
const SELECTED_PRINTER_KEY = 'label-designer.selected-printer-id';
|
||||
const CUSTOM_PRINTERS_KEY = 'label-designer.custom-printers';
|
||||
|
||||
export const DEFAULT_PRINTER_ID = '__system_default__';
|
||||
|
||||
export interface PrinterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 是否来自 Web Printing API,可直接提交打印任务 */
|
||||
direct?: boolean;
|
||||
native?: WebPrintingNativePrinter;
|
||||
}
|
||||
|
||||
interface WebPrintingNativePrinter {
|
||||
printerId?: string;
|
||||
printerName: string;
|
||||
submitPrintJob(jobName: string, documentData: Blob, templateAttributes?: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface NavigatorWithPrinting extends Navigator {
|
||||
printing?: {
|
||||
getPrinters(): Promise<WebPrintingNativePrinter[]>;
|
||||
};
|
||||
}
|
||||
|
||||
function loadCustomPrinters(): PrinterOption[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_PRINTERS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item): item is { id: string; name: string } => {
|
||||
return (
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as { id?: string }).id === 'string' &&
|
||||
typeof (item as { name?: string }).name === 'string'
|
||||
);
|
||||
})
|
||||
.map((item) => ({ id: item.id, name: item.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCustomPrinters(printers: PrinterOption[]) {
|
||||
const custom = printers.filter((p) => p.id !== DEFAULT_PRINTER_ID && !p.direct);
|
||||
localStorage.setItem(CUSTOM_PRINTERS_KEY, JSON.stringify(custom));
|
||||
}
|
||||
|
||||
export function loadSelectedPrinterId(): string {
|
||||
return localStorage.getItem(SELECTED_PRINTER_KEY) || DEFAULT_PRINTER_ID;
|
||||
}
|
||||
|
||||
export function saveSelectedPrinterId(printerId: string) {
|
||||
localStorage.setItem(SELECTED_PRINTER_KEY, printerId);
|
||||
}
|
||||
|
||||
export async function getAvailablePrinters(): Promise<PrinterOption[]> {
|
||||
const options: PrinterOption[] = [
|
||||
{ id: DEFAULT_PRINTER_ID, name: '系统默认(打印对话框)' },
|
||||
];
|
||||
const seen = new Set<string>([DEFAULT_PRINTER_ID]);
|
||||
|
||||
const printing = (navigator as NavigatorWithPrinting).printing;
|
||||
if (printing?.getPrinters) {
|
||||
try {
|
||||
const nativePrinters = await printing.getPrinters();
|
||||
for (const printer of nativePrinters) {
|
||||
const id = printer.printerId || printer.printerName;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
options.push({
|
||||
id,
|
||||
name: printer.printerName,
|
||||
direct: true,
|
||||
native: printer,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('getPrinters failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
for (const custom of loadCustomPrinters()) {
|
||||
if (seen.has(custom.id)) continue;
|
||||
seen.add(custom.id);
|
||||
options.push(custom);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function addCustomPrinter(name: string): PrinterOption {
|
||||
const trimmed = name.trim();
|
||||
const id = `custom:${trimmed}`;
|
||||
const next: PrinterOption = { id, name: trimmed };
|
||||
const existing = loadCustomPrinters().filter((p) => p.id !== id);
|
||||
saveCustomPrinters([...existing, next]);
|
||||
return next;
|
||||
}
|
||||
|
||||
interface PrintSurface {
|
||||
win: Window;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function createPdfPrintSurface(url: string): PrintSurface | null {
|
||||
const popup = window.open(url, '_blank');
|
||||
if (popup) {
|
||||
return {
|
||||
win: popup,
|
||||
dispose: () => {
|
||||
try {
|
||||
popup.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('title', 'label-print');
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2147483646',
|
||||
});
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const iframeWin = iframe.contentWindow;
|
||||
if (!iframeWin) {
|
||||
iframe.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
win: iframeWin,
|
||||
dispose: () => iframe.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
function schedulePdfPrint(surface: PrintSurface, onDone: () => void) {
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
onDone();
|
||||
};
|
||||
|
||||
const trigger = () => {
|
||||
try {
|
||||
surface.win.focus();
|
||||
surface.win.print();
|
||||
finish();
|
||||
} catch (err) {
|
||||
console.warn('print() failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
surface.win.addEventListener('load', () => window.setTimeout(trigger, 800));
|
||||
window.setTimeout(trigger, 1500);
|
||||
window.setTimeout(trigger, 2800);
|
||||
window.setTimeout(finish, 4000);
|
||||
}
|
||||
|
||||
function printPdfBlob(blob: Blob): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!blob || blob.size === 0) {
|
||||
reject(new Error('Empty PDF'));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const surface = createPdfPrintSurface(url);
|
||||
if (!surface) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('无法打开打印窗口,请允许弹出窗口后重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
window.setTimeout(() => {
|
||||
surface.dispose();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60_000);
|
||||
};
|
||||
|
||||
schedulePdfPrint(surface, () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitLabelsPrint(
|
||||
pdfBlob: Blob,
|
||||
printerId: string,
|
||||
printers: PrinterOption[]
|
||||
): Promise<'direct' | 'dialog'> {
|
||||
const printer = printers.find((p) => p.id === printerId);
|
||||
if (printer?.direct && printer.native) {
|
||||
await printer.native.submitPrintJob('labels', pdfBlob);
|
||||
return 'direct';
|
||||
}
|
||||
await printPdfBlob(pdfBlob);
|
||||
return 'dialog';
|
||||
}
|
||||
|
||||
export function supportsDirectPrinting(printers: PrinterOption[]): boolean {
|
||||
return printers.some((p) => p.direct);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import QRCode from 'qrcode';
|
||||
import { TemplateElement, RecordRow } from './types';
|
||||
import { TemplateElement, RecordRow, LabelTemplate } from './types';
|
||||
import {
|
||||
resolveElementValue,
|
||||
isGraphicContentEmpty,
|
||||
@@ -17,6 +17,24 @@ import {
|
||||
drawElementBorders,
|
||||
drawElementChrome,
|
||||
} from './elementBox';
|
||||
import {
|
||||
iterTableCells,
|
||||
getCellAnchor,
|
||||
getCellData,
|
||||
getEffectiveCellStyle,
|
||||
getTableRows,
|
||||
getTableCols,
|
||||
buildTableGridBoundariesPx,
|
||||
getCellRectPx,
|
||||
} from './tableUtils';
|
||||
import { composeElementLayer, isElementMaskActive } from './elementMask';
|
||||
import {
|
||||
resolveElementColorsForRender,
|
||||
resolveRenderableColor,
|
||||
applyPrintColorModeToCanvas,
|
||||
type RenderColorContext,
|
||||
} from './colorUtils';
|
||||
import type { PrintColorMode } from './types';
|
||||
|
||||
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
||||
|
||||
@@ -79,6 +97,34 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return Promise.resolve(null);
|
||||
return loadImage(trimmed).catch(() => null);
|
||||
}
|
||||
|
||||
async function resolveElementImage(
|
||||
elem: TemplateElement,
|
||||
primarySrc: string,
|
||||
mode: ContentResolveMode
|
||||
): Promise<{ img: HTMLImageElement | null; showPlaceholder: boolean }> {
|
||||
const primary = primarySrc.trim();
|
||||
const fallback = (elem.defaultImage ?? '').trim();
|
||||
|
||||
let img = await loadImageOrNull(primary);
|
||||
if (!img && fallback) {
|
||||
img = await loadImageOrNull(fallback);
|
||||
}
|
||||
|
||||
if (img) return { img, showPlaceholder: false };
|
||||
|
||||
if (mode === 'design' && !primary && !fallback) {
|
||||
return { img: null, showPlaceholder: true };
|
||||
}
|
||||
|
||||
return { img: null, showPlaceholder: false };
|
||||
}
|
||||
|
||||
function drawImageWithFit(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
@@ -174,6 +220,58 @@ function layoutHorizontalTextLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
const TEXT_ELLIPSIS = '…';
|
||||
|
||||
function truncateLineWithEllipsis(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
line: string,
|
||||
maxWidth: number,
|
||||
ellipsis = TEXT_ELLIPSIS
|
||||
): string {
|
||||
if (!line) return ellipsis;
|
||||
if (ctx.measureText(line).width <= maxWidth) return line;
|
||||
let truncated = line;
|
||||
while (truncated.length > 0 && ctx.measureText(truncated + ellipsis).width > maxWidth) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
return truncated.length > 0 ? truncated + ellipsis : ellipsis;
|
||||
}
|
||||
|
||||
function applyHorizontalLineLimit(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
lines: string[],
|
||||
maxLines: number | undefined,
|
||||
ellipsis: boolean,
|
||||
writableW: number
|
||||
): string[] {
|
||||
if (!maxLines || maxLines <= 0 || lines.length <= maxLines) return lines;
|
||||
const visible = lines.slice(0, maxLines);
|
||||
if (ellipsis) {
|
||||
const lastIdx = visible.length - 1;
|
||||
visible[lastIdx] = truncateLineWithEllipsis(ctx, visible[lastIdx], writableW - 4);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function applyVerticalColumnLimit(
|
||||
columns: string[][],
|
||||
maxLines: number | undefined,
|
||||
ellipsis: boolean
|
||||
): string[][] {
|
||||
if (!maxLines || maxLines <= 0 || columns.length <= maxLines) return columns;
|
||||
const visible = columns.slice(0, maxLines);
|
||||
if (ellipsis && columns.length > maxLines) {
|
||||
const lastCol = [...visible[visible.length - 1]];
|
||||
if (lastCol.length > 0) {
|
||||
lastCol[lastCol.length - 1] = TEXT_ELLIPSIS;
|
||||
visible[visible.length - 1] = lastCol;
|
||||
} else {
|
||||
visible[visible.length - 1] = [TEXT_ELLIPSIS];
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** 竖排:按列组织字符,列高受限时换列 */
|
||||
function layoutVerticalTextColumns(
|
||||
value: string,
|
||||
@@ -215,6 +313,318 @@ function layoutVerticalTextColumns(
|
||||
return columns;
|
||||
}
|
||||
|
||||
function drawTextContentInBox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
boxX: number,
|
||||
boxY: number,
|
||||
boxW: number,
|
||||
boxH: number,
|
||||
value: string,
|
||||
scale: number
|
||||
) {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const padPx = {
|
||||
top: Math.round(pad.top * scale),
|
||||
right: Math.round(pad.right * scale),
|
||||
bottom: Math.round(pad.bottom * scale),
|
||||
left: Math.round(pad.left * scale),
|
||||
};
|
||||
const writableW = Math.max(1, boxW - padPx.left - padPx.right);
|
||||
const writableH = Math.max(1, boxH - padPx.top - padPx.bottom);
|
||||
|
||||
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
|
||||
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
|
||||
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
|
||||
const fontFamily =
|
||||
elem.fontFamily === 'mono'
|
||||
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
|
||||
: elem.fontFamily === 'serif'
|
||||
? 'Georgia, Times New Roman, serif'
|
||||
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
|
||||
|
||||
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
|
||||
ctx.fillStyle = elem.textColor || '#111827';
|
||||
|
||||
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
|
||||
`${letterSpacingPx}px`;
|
||||
}
|
||||
|
||||
const wordWrap = elem.wordWrap !== false;
|
||||
const lineHeight = fontSizePx * 1.25;
|
||||
const columnWidth = lineHeight;
|
||||
const isVertical = elem.textWritingMode === 'vertical';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
|
||||
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
|
||||
const transformOriginX = boxX + padPx.left + writableW / 2;
|
||||
const transformOriginY = boxY + padPx.top + writableH / 2;
|
||||
|
||||
ctx.save();
|
||||
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||
ctx.translate(transformOriginX, transformOriginY);
|
||||
ctx.scale(textScaleX, textScaleY);
|
||||
ctx.translate(-transformOriginX, -transformOriginY);
|
||||
}
|
||||
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
|
||||
if (isVertical) {
|
||||
let columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||
columns = applyVerticalColumnLimit(columns, elem.textMaxLines, elem.textEllipsis === true);
|
||||
const totalColumnsWidth = columns.length * columnWidth;
|
||||
|
||||
let startX: number;
|
||||
if (elem.textAlign === 'center') {
|
||||
startX = boxX + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
} else if (elem.textAlign === 'right') {
|
||||
startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
} else {
|
||||
startX = boxX + padPx.left + columnWidth / 2;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
const column = columns[col];
|
||||
const colTextHeight = column.length * lineHeight;
|
||||
let colStartY: number;
|
||||
if (vAlign === 'top') {
|
||||
colStartY = boxY + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
colStartY =
|
||||
boxY + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
const colX = startX + col * columnWidth;
|
||||
for (let i = 0; i < column.length; i++) {
|
||||
const charY = colStartY + i * lineHeight;
|
||||
if (charY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
|
||||
ctx.fillText(column[i], colX, charY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||
lines = applyHorizontalLineLimit(
|
||||
ctx,
|
||||
lines,
|
||||
elem.textMaxLines,
|
||||
elem.textEllipsis === true,
|
||||
writableW
|
||||
);
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
let textX = boxX + padPx.left;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = boxX + padPx.left + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = boxX + padPx.left + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = boxX + padPx.left + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = boxY + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY = boxY + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= boxY + boxH - padPx.bottom) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
|
||||
}
|
||||
}
|
||||
|
||||
function alignStrokeCoord(v: number): number {
|
||||
return Math.round(v) + 0.5;
|
||||
}
|
||||
|
||||
/** 统一绘制表格网格线,避免相邻单元格各画一条边造成双线 */
|
||||
function drawTableGridLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
ex: number,
|
||||
ey: number,
|
||||
scale: number
|
||||
) {
|
||||
const borderMm = elem.tableBorderWidth ?? 0.2;
|
||||
if (borderMm <= 0) return;
|
||||
|
||||
const borderW = borderMm * scale;
|
||||
if (borderW <= 0) return;
|
||||
|
||||
const borderColor = elem.tableBorderColor ?? '#374151';
|
||||
const rows = getTableRows(elem);
|
||||
const cols = getTableCols(elem);
|
||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
|
||||
|
||||
const left = colBounds[0];
|
||||
const top = rowBounds[0];
|
||||
const width = colBounds[colBounds.length - 1] - left;
|
||||
const height = rowBounds[rowBounds.length - 1] - top;
|
||||
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = borderW;
|
||||
ctx.beginPath();
|
||||
|
||||
const inset = borderW / 2;
|
||||
const right = left + width;
|
||||
const bottom = top + height;
|
||||
// 外框:描边完全落在网格区域内,避免 clip / 画布边缘裁切右、下边
|
||||
ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
|
||||
ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
|
||||
ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
|
||||
ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
|
||||
ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
|
||||
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
|
||||
ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
|
||||
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
|
||||
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const y = alignStrokeCoord(rowBounds[r]);
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const anchorAbove = getCellAnchor(elem, r - 1, c);
|
||||
const anchorBelow = getCellAnchor(elem, r, c);
|
||||
const sameMerge =
|
||||
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
|
||||
if (!sameMerge) {
|
||||
ctx.moveTo(alignStrokeCoord(colBounds[c]), y);
|
||||
ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let c = 1; c < cols; c++) {
|
||||
const x = alignStrokeCoord(colBounds[c]);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const anchorLeft = getCellAnchor(elem, r, c - 1);
|
||||
const anchorRight = getCellAnchor(elem, r, c);
|
||||
const sameMerge =
|
||||
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
|
||||
if (!sameMerge) {
|
||||
ctx.moveTo(x, alignStrokeCoord(rowBounds[r]));
|
||||
ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
async function drawTableElement(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
ex: number,
|
||||
ey: number,
|
||||
ew: number,
|
||||
eh: number,
|
||||
scale: number,
|
||||
rowData: RecordRow | null,
|
||||
rowIndex: number,
|
||||
variableDefaults: Record<string, string> | null | undefined,
|
||||
mode: ContentResolveMode,
|
||||
colorCtx: RenderColorContext
|
||||
) {
|
||||
const renderElem = resolveElementColorsForRender(elem, colorCtx);
|
||||
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(renderElem);
|
||||
|
||||
const drawCellContent = async (
|
||||
targetCtx: CanvasRenderingContext2D,
|
||||
ox: number,
|
||||
oy: number
|
||||
) => {
|
||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ox, oy);
|
||||
const gridX = colBounds[0];
|
||||
const gridY = rowBounds[0];
|
||||
const gridW = colBounds[colBounds.length - 1] - gridX;
|
||||
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
||||
targetCtx.save();
|
||||
targetCtx.beginPath();
|
||||
targetCtx.rect(gridX, gridY, gridW, gridH);
|
||||
targetCtx.clip();
|
||||
|
||||
const anchors = iterTableCells(elem);
|
||||
for (const { row, col } of anchors) {
|
||||
const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
|
||||
elem,
|
||||
row,
|
||||
col,
|
||||
scale,
|
||||
ox,
|
||||
oy
|
||||
);
|
||||
|
||||
const cellData = getCellData(elem, row, col);
|
||||
const cellBg = resolveRenderableColor(cellData?.backgroundColor, colorCtx);
|
||||
if (cellBg && cellBg !== 'transparent') {
|
||||
targetCtx.fillStyle = cellBg;
|
||||
targetCtx.fillRect(cellX, cellY, cellW, cellH);
|
||||
}
|
||||
|
||||
const textElem = resolveElementColorsForRender(
|
||||
getEffectiveCellStyle(elem, row, col),
|
||||
colorCtx
|
||||
);
|
||||
if (!textElem.content.trim()) continue;
|
||||
|
||||
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
|
||||
const displayText = String(cellValue ?? '').trim();
|
||||
if (!displayText) continue;
|
||||
|
||||
drawTextContentInBox(
|
||||
targetCtx,
|
||||
{
|
||||
...textElem,
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
cellX,
|
||||
cellY,
|
||||
cellW,
|
||||
cellH,
|
||||
displayText,
|
||||
scale
|
||||
);
|
||||
}
|
||||
|
||||
targetCtx.restore();
|
||||
};
|
||||
|
||||
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||
await drawCellContent(layerCtx, ox, oy);
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
});
|
||||
|
||||
drawTableGridLines(ctx, renderElem, ex, ey, scale);
|
||||
}
|
||||
|
||||
function drawImagePlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
@@ -247,6 +657,10 @@ export interface RenderLabelOptions {
|
||||
transparentBackground?: boolean;
|
||||
variableDefaults?: Record<string, string> | null;
|
||||
mode?: ContentResolveMode;
|
||||
/** 输出参考背景(生成/打印),绘制在元素下层 */
|
||||
referenceBackground?: string;
|
||||
referenceBackgroundOpacity?: number;
|
||||
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */
|
||||
scale?: number;
|
||||
/** 输出格式,默认 png */
|
||||
@@ -254,6 +668,138 @@ export interface RenderLabelOptions {
|
||||
jpegQuality?: number;
|
||||
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
|
||||
clipToCanvas?: boolean;
|
||||
/** 排版/印刷色彩模式 */
|
||||
printColorMode?: PrintColorMode;
|
||||
}
|
||||
|
||||
export type ReferenceBackgroundRenderOptions = Pick<
|
||||
RenderLabelOptions,
|
||||
'referenceBackground' | 'referenceBackgroundOpacity' | 'referenceBackgroundFit'
|
||||
>;
|
||||
|
||||
type ReferenceBackgroundTemplate = Pick<
|
||||
LabelTemplate,
|
||||
| 'previewReferenceBackground'
|
||||
| 'previewReferenceBackgroundOpacity'
|
||||
| 'previewReferenceBackgroundFit'
|
||||
| 'enablePreviewReferenceBackground'
|
||||
>;
|
||||
|
||||
function buildReferenceBackgroundRenderOptions(
|
||||
template: ReferenceBackgroundTemplate
|
||||
): ReferenceBackgroundRenderOptions {
|
||||
if (!template.previewReferenceBackground) {
|
||||
return {};
|
||||
}
|
||||
if (template.enablePreviewReferenceBackground === false) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
referenceBackground: template.previewReferenceBackground,
|
||||
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
|
||||
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
/** 模板列表 / 设计预览等:参考图且启用预览时绘制 */
|
||||
export function resolveReferenceBackgroundForPreview(
|
||||
template: ReferenceBackgroundTemplate
|
||||
): ReferenceBackgroundRenderOptions {
|
||||
return buildReferenceBackgroundRenderOptions(template);
|
||||
}
|
||||
|
||||
/** 模板开启「输出参考背景」时,供 PDF / 打印 / 排版预览使用的渲染参数 */
|
||||
export function resolveReferenceBackgroundForOutput(
|
||||
template: ReferenceBackgroundTemplate &
|
||||
Pick<LabelTemplate, 'includeReferenceBackgroundInOutput'>
|
||||
): ReferenceBackgroundRenderOptions {
|
||||
if (!template.includeReferenceBackgroundInOutput) {
|
||||
return {};
|
||||
}
|
||||
if (!template.previewReferenceBackground) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
referenceBackground: template.previewReferenceBackground,
|
||||
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
|
||||
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
export type LayoutPageBackgroundRenderOptions = {
|
||||
background: string;
|
||||
opacity: number;
|
||||
fit: 'contain' | 'cover' | 'fill';
|
||||
};
|
||||
|
||||
type LayoutPageBackgroundTemplate = Pick<
|
||||
LabelTemplate,
|
||||
| 'layoutPageBackground'
|
||||
| 'layoutPageBackgroundOpacity'
|
||||
| 'layoutPageBackgroundFit'
|
||||
| 'enableLayoutPageBackground'
|
||||
| 'includeLayoutPageBackgroundInOutput'
|
||||
>;
|
||||
|
||||
function buildLayoutPageBackgroundRenderOptions(
|
||||
template: LayoutPageBackgroundTemplate,
|
||||
forOutput: boolean
|
||||
): LayoutPageBackgroundRenderOptions | null {
|
||||
if (!template.layoutPageBackground) return null;
|
||||
if (!forOutput && template.enableLayoutPageBackground === false) return null;
|
||||
if (forOutput && !template.includeLayoutPageBackgroundInOutput) return null;
|
||||
return {
|
||||
background: template.layoutPageBackground,
|
||||
opacity: template.layoutPageBackgroundOpacity ?? 100,
|
||||
fit: template.layoutPageBackgroundFit ?? 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
/** 排版预览:整页背景渲染参数 */
|
||||
export function resolveLayoutPageBackgroundForPreview(
|
||||
template: LayoutPageBackgroundTemplate
|
||||
): LayoutPageBackgroundRenderOptions | null {
|
||||
return buildLayoutPageBackgroundRenderOptions(template, false);
|
||||
}
|
||||
|
||||
/** PDF / 打印:整页背景渲染参数 */
|
||||
export function resolveLayoutPageBackgroundForOutput(
|
||||
template: LayoutPageBackgroundTemplate
|
||||
): LayoutPageBackgroundRenderOptions | null {
|
||||
return buildLayoutPageBackgroundRenderOptions(template, true);
|
||||
}
|
||||
|
||||
/** 将整页背景渲染为 data URL(白底 + 背景图) */
|
||||
export async function renderLayoutPageBackgroundDataUrl(
|
||||
paperWidthMm: number,
|
||||
paperHeightMm: number,
|
||||
options: LayoutPageBackgroundRenderOptions,
|
||||
scale = 4,
|
||||
imageFormat: 'png' | 'jpeg' = 'png',
|
||||
printColorMode: PrintColorMode = 'rgb'
|
||||
): Promise<string | null> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(paperWidthMm * scale));
|
||||
canvas.height = Math.max(1, Math.round(paperHeightMm * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const img = await loadImageOrNull(options.background);
|
||||
if (!img) return null;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.min(1, Math.max(0, options.opacity / 100));
|
||||
drawImageWithFit(ctx, img, 0, 0, canvas.width, canvas.height, options.fit);
|
||||
ctx.restore();
|
||||
|
||||
applyPrintColorModeToCanvas(canvas, printColorMode);
|
||||
|
||||
return imageFormat === 'jpeg'
|
||||
? canvas.toDataURL('image/jpeg', 0.95)
|
||||
: canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/** 离屏渲染标签为 PNG data URL */
|
||||
@@ -268,16 +814,28 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
transparentBackground = false,
|
||||
variableDefaults = null,
|
||||
mode = 'design',
|
||||
referenceBackground,
|
||||
referenceBackgroundOpacity = 100,
|
||||
referenceBackgroundFit = 'cover',
|
||||
scale: scaleOption = 16,
|
||||
imageFormat = 'png',
|
||||
jpegQuality = 0.92,
|
||||
clipToCanvas = true,
|
||||
printColorMode = 'rgb',
|
||||
} = options;
|
||||
|
||||
const colorCtx: RenderColorContext = {
|
||||
printColorMode,
|
||||
row: rowData,
|
||||
variableDefaults,
|
||||
mode,
|
||||
rowIndex,
|
||||
};
|
||||
|
||||
const scale = scaleOption;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(50, Math.round(widthMm * scale));
|
||||
canvas.height = Math.max(50, Math.round(heightMm * scale));
|
||||
canvas.width = Math.max(1, Math.round(widthMm * scale));
|
||||
canvas.height = Math.max(1, Math.round(heightMm * scale));
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
@@ -297,6 +855,16 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
if (referenceBackground) {
|
||||
const refImg = await loadImageOrNull(referenceBackground);
|
||||
if (refImg) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.min(1, Math.max(0, referenceBackgroundOpacity / 100));
|
||||
drawImageWithFit(ctx, refImg, 0, 0, canvas.width, canvas.height, referenceBackgroundFit);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
if (clipToCanvas) {
|
||||
ctx.beginPath();
|
||||
@@ -316,176 +884,54 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
|
||||
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
|
||||
|
||||
if (isGraphicContentEmpty(elem, value, mode)) {
|
||||
if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderElem = resolveElementColorsForRender(elem, colorCtx);
|
||||
|
||||
ctx.save();
|
||||
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
|
||||
|
||||
if (elem.type === 'text') {
|
||||
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(elem);
|
||||
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(renderElem);
|
||||
|
||||
ctx.save();
|
||||
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
|
||||
ctx.clip();
|
||||
|
||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
||||
|
||||
const padPx = (() => {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
return {
|
||||
top: Math.round(pad.top * scale),
|
||||
right: Math.round(pad.right * scale),
|
||||
bottom: Math.round(pad.bottom * scale),
|
||||
left: Math.round(pad.left * scale),
|
||||
};
|
||||
})();
|
||||
const writableW = Math.max(1, ew - padPx.left - padPx.right);
|
||||
const writableH = Math.max(1, eh - padPx.top - padPx.bottom);
|
||||
|
||||
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
|
||||
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
|
||||
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
|
||||
const fontFamily =
|
||||
elem.fontFamily === 'mono'
|
||||
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
|
||||
: elem.fontFamily === 'serif'
|
||||
? 'Georgia, Times New Roman, serif'
|
||||
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
|
||||
|
||||
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
|
||||
ctx.fillStyle = elem.textColor || '#111827';
|
||||
|
||||
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
|
||||
`${letterSpacingPx}px`;
|
||||
}
|
||||
|
||||
const wordWrap = elem.wordWrap !== false;
|
||||
const lineHeight = fontSizePx * 1.25;
|
||||
const columnWidth = lineHeight;
|
||||
const isVertical = elem.textWritingMode === 'vertical';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
|
||||
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
|
||||
const transformOriginX = ex + padPx.left + writableW / 2;
|
||||
const transformOriginY = ey + padPx.top + writableH / 2;
|
||||
|
||||
ctx.save();
|
||||
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||
ctx.translate(transformOriginX, transformOriginY);
|
||||
ctx.scale(textScaleX, textScaleY);
|
||||
ctx.translate(-transformOriginX, -transformOriginY);
|
||||
}
|
||||
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
|
||||
if (isVertical) {
|
||||
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||
const totalColumnsWidth = columns.length * columnWidth;
|
||||
|
||||
let startX: number;
|
||||
if (elem.textAlign === 'center') {
|
||||
startX = ex + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
} else if (elem.textAlign === 'right') {
|
||||
startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
} else {
|
||||
startX = ex + padPx.left + columnWidth / 2;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
const column = columns[col];
|
||||
const colTextHeight = column.length * lineHeight;
|
||||
let colStartY: number;
|
||||
if (vAlign === 'top') {
|
||||
colStartY = ey + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
colStartY =
|
||||
ey + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
const colX = startX + col * columnWidth;
|
||||
for (let i = 0; i < column.length; i++) {
|
||||
const charY = colStartY + i * lineHeight;
|
||||
if (charY - lineHeight / 2 <= ey + eh - padPx.bottom) {
|
||||
ctx.fillText(column[i], colX, charY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
let textX = ex + padPx.left;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = ex + padPx.left + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = ex + padPx.left + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = ex + padPx.left + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = ey + padPx.top + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY =
|
||||
ey + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= ey + eh - padPx.bottom) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
|
||||
}
|
||||
ctx.restore();
|
||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
||||
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||
drawTextContentInBox(layerCtx, renderElem, ox, oy, ow, oh, String(value), scale);
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
});
|
||||
} else if (elem.type === 'barcode') {
|
||||
try {
|
||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(renderElem);
|
||||
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
||||
|
||||
if (barcodeValue) {
|
||||
JsBarcode(tempCanvas, barcodeValue, {
|
||||
format: elem.barcodeFormat || 'CODE128',
|
||||
width: 2,
|
||||
height: 40,
|
||||
displayValue: elem.showText ?? false,
|
||||
fontSize: 14,
|
||||
margin: 2,
|
||||
background: GENERATOR_TRANSPARENT_BG,
|
||||
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||
if (!barcodeValue) {
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
return;
|
||||
}
|
||||
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
JsBarcode(tempCanvas, barcodeValue, {
|
||||
format: elem.barcodeFormat || 'CODE128',
|
||||
width: 2,
|
||||
height: 40,
|
||||
displayValue: elem.showText ?? false,
|
||||
fontSize: 14,
|
||||
margin: 2,
|
||||
background: GENERATOR_TRANSPARENT_BG,
|
||||
});
|
||||
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
});
|
||||
|
||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Barcode drawing failed', error);
|
||||
if (mode === 'design') {
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
@@ -499,27 +945,33 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
}
|
||||
} else if (elem.type === 'qrcode') {
|
||||
try {
|
||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(renderElem);
|
||||
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
||||
|
||||
if (qrValue) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(tempCanvas, qrValue, {
|
||||
width: Math.max(64, inner.w),
|
||||
margin: 0,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: GENERATOR_TRANSPARENT_BG,
|
||||
},
|
||||
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||
if (!qrValue) {
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
return;
|
||||
}
|
||||
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(tempCanvas, qrValue, {
|
||||
width: Math.max(64, inner.w),
|
||||
margin: 0,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: GENERATOR_TRANSPARENT_BG,
|
||||
},
|
||||
});
|
||||
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
});
|
||||
|
||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('QR code drawing failed', error);
|
||||
if (mode === 'design') {
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
@@ -528,23 +980,61 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
}
|
||||
}
|
||||
} else if (elem.type === 'image') {
|
||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||
const src = value ? String(value).trim() : '';
|
||||
if (!src) {
|
||||
if (mode === 'design') {
|
||||
const primarySrc = value ? String(value).trim() : '';
|
||||
const { img, showPlaceholder } = await resolveElementImage(elem, primarySrc, mode);
|
||||
|
||||
if (!img && !showPlaceholder) {
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(renderElem);
|
||||
const hasMask = isElementMaskActive(elem.mask);
|
||||
|
||||
if (hasMask) {
|
||||
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||
if (img) {
|
||||
drawImageWithFit(
|
||||
layerCtx,
|
||||
img,
|
||||
inner.x,
|
||||
inner.y,
|
||||
inner.w,
|
||||
inner.h,
|
||||
elem.imageFit ?? 'contain'
|
||||
);
|
||||
} else {
|
||||
drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||
}
|
||||
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||
});
|
||||
} else {
|
||||
drawElementChrome(ctx, ex, ey, ew, eh, renderElem, scale);
|
||||
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||
if (img) {
|
||||
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
||||
} else {
|
||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const img = await loadImage(src);
|
||||
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
||||
} catch {
|
||||
if (mode === 'design') {
|
||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '加载失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (elem.type === 'table') {
|
||||
await drawTableElement(
|
||||
ctx,
|
||||
elem,
|
||||
ex,
|
||||
ey,
|
||||
ew,
|
||||
eh,
|
||||
scale,
|
||||
rowData,
|
||||
rowIndex,
|
||||
variableDefaults,
|
||||
mode,
|
||||
colorCtx
|
||||
);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -552,6 +1042,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
|
||||
ctx.restore();
|
||||
|
||||
applyPrintColorModeToCanvas(canvas, printColorMode);
|
||||
|
||||
return imageFormat === 'jpeg'
|
||||
? canvas.toDataURL('image/jpeg', jpegQuality)
|
||||
: canvas.toDataURL('image/png');
|
||||
|
||||
144
src/pdfExport.ts
144
src/pdfExport.ts
@@ -1,6 +1,10 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
||||
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
|
||||
import {
|
||||
resolveLayoutPageBackgroundForOutput,
|
||||
renderLayoutPageBackgroundDataUrl,
|
||||
} from './labelRenderer';
|
||||
|
||||
export interface PdfExportProgress {
|
||||
done: number;
|
||||
@@ -26,6 +30,39 @@ const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const PAGE_RENDER_CONCURRENCY = 3;
|
||||
|
||||
type PageBackgroundCache = { dataUrl: string | null; key: string | null };
|
||||
|
||||
async function addPageBackgroundIfNeeded(
|
||||
pdf: jsPDF,
|
||||
template: LabelTemplate,
|
||||
paper: PaperConfig,
|
||||
paperW: number,
|
||||
paperH: number,
|
||||
imageFormat: 'PNG' | 'JPEG',
|
||||
cache: PageBackgroundCache
|
||||
) {
|
||||
const options = resolveLayoutPageBackgroundForOutput(template);
|
||||
if (!options) return;
|
||||
|
||||
const printColorMode = paper.printColorMode ?? 'cmyk';
|
||||
const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}|${printColorMode}`;
|
||||
if (cache.key !== key) {
|
||||
cache.key = key;
|
||||
cache.dataUrl = await renderLayoutPageBackgroundDataUrl(
|
||||
paperW,
|
||||
paperH,
|
||||
options,
|
||||
4,
|
||||
imageFormat === 'JPEG' ? 'jpeg' : 'png',
|
||||
printColorMode
|
||||
);
|
||||
}
|
||||
|
||||
if (cache.dataUrl) {
|
||||
pdf.addImage(cache.dataUrl, imageFormat, 0, 0, paperW, paperH, undefined, 'FAST');
|
||||
}
|
||||
}
|
||||
|
||||
function parseColorToRgb(color: string): [number, number, number] {
|
||||
const c = color.trim();
|
||||
if (c.startsWith('#')) {
|
||||
@@ -141,6 +178,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
||||
|
||||
let done = 0;
|
||||
let progressTick = 0;
|
||||
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||
|
||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||
@@ -151,6 +189,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
||||
pdf.addPage([paperW, paperH], orientation);
|
||||
}
|
||||
|
||||
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
|
||||
|
||||
const pageRows = printablePages[pIdx];
|
||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||
@@ -205,3 +245,107 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
||||
await yieldToMain();
|
||||
pdf.save(filename);
|
||||
}
|
||||
|
||||
/** 与 exportLabelsPdfAsync 相同渲染流程,返回 PDF Blob(用于浏览器打印) */
|
||||
export async function exportLabelsPdfBlobAsync(
|
||||
options: Omit<AsyncExportLabelsPdfOptions, 'filename'>
|
||||
): Promise<Blob> {
|
||||
const {
|
||||
paper,
|
||||
template,
|
||||
printablePages,
|
||||
gridCols,
|
||||
cutLine,
|
||||
imageFormat,
|
||||
renderLabel,
|
||||
onProgress,
|
||||
} = options;
|
||||
|
||||
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||
const orientation = paperW > paperH ? 'landscape' : 'portrait';
|
||||
const labelW = template.width;
|
||||
const labelH = template.height;
|
||||
const totalPages = printablePages.length;
|
||||
|
||||
const totalLabels = printablePages.reduce(
|
||||
(sum, page) => sum + page.filter((item) => item !== null).length,
|
||||
0
|
||||
);
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation,
|
||||
unit: 'mm',
|
||||
format: [paperW, paperH],
|
||||
compress: true,
|
||||
});
|
||||
|
||||
let done = 0;
|
||||
let progressTick = 0;
|
||||
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||
|
||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||
};
|
||||
|
||||
for (let pIdx = 0; pIdx < printablePages.length; pIdx++) {
|
||||
if (pIdx > 0) {
|
||||
pdf.addPage([paperW, paperH], orientation);
|
||||
}
|
||||
|
||||
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
|
||||
|
||||
const pageRows = printablePages[pIdx];
|
||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||
report(pIdx + 1, 'render');
|
||||
|
||||
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
|
||||
for (let cellIdx = 0; cellIdx < pageRows.length; cellIdx++) {
|
||||
const item = pageRows[cellIdx];
|
||||
if (!item) continue;
|
||||
pageJobs.push({ cellIdx, label: item });
|
||||
}
|
||||
|
||||
for (let i = 0; i < pageJobs.length; i += PAGE_RENDER_CONCURRENCY) {
|
||||
const batch = pageJobs.slice(i, i + PAGE_RENDER_CONCURRENCY);
|
||||
const rendered = await Promise.all(
|
||||
batch.map(async (job) => ({
|
||||
...job,
|
||||
dataUrl: await renderLabel(job.label),
|
||||
}))
|
||||
);
|
||||
|
||||
for (const { cellIdx, dataUrl } of rendered) {
|
||||
const col = cellIdx % gridCols;
|
||||
const row = Math.floor(cellIdx / gridCols);
|
||||
const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x;
|
||||
const y = paper.marginTop + row * (labelH + paper.rowGap) + cutChannel.y;
|
||||
const drawW = labelW - 2 * cutChannel.x;
|
||||
const drawH = labelH - 2 * cutChannel.y;
|
||||
|
||||
pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST');
|
||||
|
||||
done++;
|
||||
}
|
||||
|
||||
progressTick++;
|
||||
if (progressTick >= 2) {
|
||||
progressTick = 0;
|
||||
report(pIdx + 1, 'render');
|
||||
await yieldToMain();
|
||||
}
|
||||
}
|
||||
|
||||
if (cutLine.enabled && pageRows.length > 0) {
|
||||
drawPageGridCutLines(pdf, paper, labelW, labelH, gridCols, gridRows, cutLine);
|
||||
}
|
||||
|
||||
report(pIdx + 1, 'render');
|
||||
await yieldToMain();
|
||||
}
|
||||
|
||||
report(totalPages, 'save');
|
||||
await yieldToMain();
|
||||
return pdf.output('blob');
|
||||
}
|
||||
|
||||
757
src/tableUtils.ts
Normal file
757
src/tableUtils.ts
Normal file
@@ -0,0 +1,757 @@
|
||||
import { TemplateElement, TableCellData } from './types';
|
||||
import { resolvePaddingMm } from './elementBox';
|
||||
|
||||
export interface TableCellCoord {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export function tableCellKey(row: number, col: number): string {
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
export function parseTableCellKey(key: string): TableCellCoord | null {
|
||||
const [r, c] = key.split(',').map(Number);
|
||||
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
|
||||
return { row: r, col: c };
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function getTableRows(elem: TemplateElement): number {
|
||||
return Math.max(1, elem.tableRows ?? 3);
|
||||
}
|
||||
|
||||
export function getTableCols(elem: TemplateElement): number {
|
||||
return Math.max(1, elem.tableCols ?? 3);
|
||||
}
|
||||
|
||||
export function getTableInnerSizeMm(elem: TemplateElement): { width: number; height: number } {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
return {
|
||||
width: Math.max(1, elem.width - pad.left - pad.right),
|
||||
height: Math.max(1, elem.height - pad.top - pad.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTableRowHeights(elem: TemplateElement): number[] {
|
||||
const rows = getTableRows(elem);
|
||||
const stored = elem.tableRowHeights;
|
||||
if (stored && stored.length === rows) {
|
||||
return stored.map((h) => Math.max(0.5, h));
|
||||
}
|
||||
const { height } = getTableInnerSizeMm(elem);
|
||||
const even = round1(height / rows);
|
||||
return Array.from({ length: rows }, () => even);
|
||||
}
|
||||
|
||||
export function getTableColWidths(elem: TemplateElement): number[] {
|
||||
const cols = getTableCols(elem);
|
||||
const stored = elem.tableColWidths;
|
||||
if (stored && stored.length === cols) {
|
||||
return stored.map((w) => Math.max(0.5, w));
|
||||
}
|
||||
const { width } = getTableInnerSizeMm(elem);
|
||||
const even = round1(width / cols);
|
||||
return Array.from({ length: cols }, () => even);
|
||||
}
|
||||
|
||||
/** 调整末项(必要时向前)使轨道总和精确等于 targetTotal */
|
||||
function absorbTrackSumDiff(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||
const result = tracks.map((t) => round1(Math.max(min, t)));
|
||||
let diff = round1(targetTotal - result.reduce((a, b) => a + b, 0));
|
||||
for (let i = result.length - 1; i >= 0 && diff !== 0; i--) {
|
||||
const adjusted = round1(Math.max(min, result[i] + diff));
|
||||
const applied = round1(adjusted - result[i]);
|
||||
result[i] = adjusted;
|
||||
diff = round1(diff - applied);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按比例缩放轨道,严格使总和等于 targetTotal(不放大目标总量) */
|
||||
function scaleTracksToExactTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||
if (tracks.length === 0) return [];
|
||||
const count = tracks.length;
|
||||
const floor = count * min;
|
||||
if (targetTotal <= floor + 0.01) {
|
||||
return absorbTrackSumDiff(Array.from({ length: count }, () => min), targetTotal, min);
|
||||
}
|
||||
const current = tracks.reduce((a, b) => a + b, 0);
|
||||
if (current <= 0) {
|
||||
const even = round1(targetTotal / count);
|
||||
return absorbTrackSumDiff(Array.from({ length: count }, () => even), targetTotal, min);
|
||||
}
|
||||
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * targetTotal)));
|
||||
return absorbTrackSumDiff(scaled, targetTotal, min);
|
||||
}
|
||||
|
||||
/** 将轨道尺寸总和调整为 targetTotal(保持相对比例,末项吸收取整误差) */
|
||||
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||
if (tracks.length === 0) return [];
|
||||
const safeTotal = Math.max(tracks.length * min, targetTotal);
|
||||
const current = tracks.reduce((a, b) => a + b, 0);
|
||||
if (current <= 0) {
|
||||
return distributeTracks(safeTotal, tracks.length, min);
|
||||
}
|
||||
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * safeTotal)));
|
||||
const sum = scaled.reduce((a, b) => a + b, 0);
|
||||
const diff = round1(safeTotal - sum);
|
||||
if (scaled.length > 0 && diff !== 0) {
|
||||
scaled[scaled.length - 1] = round1(Math.max(min, scaled[scaled.length - 1] + diff));
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/** 调整单条轨道尺寸,保持轨道总和不变;优先由 index 之后的轨道吸收差值 */
|
||||
function resizeTrackKeepingTotal(
|
||||
tracks: number[],
|
||||
index: number,
|
||||
sizeMm: number,
|
||||
min = 0.5
|
||||
): number[] {
|
||||
if (index < 0 || index >= tracks.length) return tracks;
|
||||
const total = round1(tracks.reduce((a, b) => a + b, 0));
|
||||
if (tracks.length === 1) {
|
||||
return [round1(Math.max(min, Math.min(total, sizeMm)))];
|
||||
}
|
||||
|
||||
const beforeSum = round1(tracks.slice(0, index).reduce((a, b) => a + b, 0));
|
||||
const afterCount = tracks.length - index - 1;
|
||||
const beforeCount = index;
|
||||
|
||||
const maxForIndex =
|
||||
afterCount > 0
|
||||
? round1(total - beforeSum - afterCount * min)
|
||||
: round1(total - beforeCount * min);
|
||||
const newSize = round1(Math.max(min, Math.min(maxForIndex, sizeMm)));
|
||||
const result = [...tracks];
|
||||
result[index] = newSize;
|
||||
|
||||
if (afterCount > 0) {
|
||||
const afterRemaining = round1(total - beforeSum - newSize);
|
||||
const scaledAfter = scaleTracksToExactTotal(tracks.slice(index + 1), afterRemaining, min);
|
||||
for (let i = 0; i < scaledAfter.length; i++) {
|
||||
result[index + 1 + i] = scaledAfter[i];
|
||||
}
|
||||
} else if (beforeCount > 0) {
|
||||
const beforeRemaining = round1(total - newSize);
|
||||
const scaledBefore = scaleTracksToExactTotal(tracks.slice(0, index), beforeRemaining, min);
|
||||
for (let i = 0; i < scaledBefore.length; i++) {
|
||||
result[i] = scaledBefore[i];
|
||||
}
|
||||
}
|
||||
|
||||
return absorbTrackSumDiff(result, total, min);
|
||||
}
|
||||
|
||||
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
|
||||
function distributeTracks(total: number, count: number, min = 0.5): number[] {
|
||||
if (count <= 0) return [];
|
||||
const safeTotal = Math.max(count * min, total);
|
||||
const even = round1(safeTotal / count);
|
||||
const tracks = Array.from({ length: count }, () => even);
|
||||
const sum = tracks.reduce((a, b) => a + b, 0);
|
||||
const diff = round1(safeTotal - sum);
|
||||
if (tracks.length > 0 && diff !== 0) {
|
||||
tracks[tracks.length - 1] = round1(Math.max(min, tracks[tracks.length - 1] + diff));
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/** 内边距变化后保持元素外框尺寸不变,按比例重算行高/列宽以适配新的内容区 */
|
||||
export function expandTableForPadding(elem: TemplateElement): TemplateElement {
|
||||
if (elem.type !== 'table') return elem;
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const innerW = Math.max(1, elem.width - pad.left - pad.right);
|
||||
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
|
||||
return {
|
||||
...elem,
|
||||
tableRowHeights: scaleTracksToTotal(getTableRowHeights(elem), innerH),
|
||||
tableColWidths: scaleTracksToTotal(getTableColWidths(elem), innerW),
|
||||
width: elem.width,
|
||||
height: elem.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncTableDimensionsFromTracks(elem: TemplateElement): TemplateElement {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const rowHeights = getTableRowHeights(elem);
|
||||
const colWidths = getTableColWidths(elem);
|
||||
return {
|
||||
...elem,
|
||||
tableRowHeights: rowHeights,
|
||||
tableColWidths: colWidths,
|
||||
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
||||
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleTableTracks(
|
||||
elem: TemplateElement,
|
||||
newWidth: number,
|
||||
newHeight: number,
|
||||
startWidth: number,
|
||||
startHeight: number,
|
||||
startRowHeights: number[],
|
||||
startColWidths: number[]
|
||||
): TemplateElement {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const startInnerW = Math.max(0.1, startWidth - pad.left - pad.right);
|
||||
const startInnerH = Math.max(0.1, startHeight - pad.top - pad.bottom);
|
||||
const newInnerW = Math.max(1, newWidth - pad.left - pad.right);
|
||||
const newInnerH = Math.max(1, newHeight - pad.top - pad.bottom);
|
||||
const scaleW = newInnerW / startInnerW;
|
||||
const scaleH = newInnerH / startInnerH;
|
||||
|
||||
const tableRowHeights = scaleTracksToExactTotal(
|
||||
startRowHeights.map((h) => h * scaleH),
|
||||
newInnerH
|
||||
);
|
||||
const tableColWidths = scaleTracksToExactTotal(
|
||||
startColWidths.map((w) => w * scaleW),
|
||||
newInnerW
|
||||
);
|
||||
|
||||
return {
|
||||
...elem,
|
||||
x: elem.x,
|
||||
y: elem.y,
|
||||
width: round1(newWidth),
|
||||
height: round1(newHeight),
|
||||
tableRowHeights,
|
||||
tableColWidths,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCellData(elem: TemplateElement, row: number, col: number): TableCellData | undefined {
|
||||
return elem.tableCells?.[tableCellKey(row, col)];
|
||||
}
|
||||
|
||||
export function getCellAnchor(elem: TemplateElement, row: number, col: number): TableCellCoord {
|
||||
const cell = getCellData(elem, row, col);
|
||||
if (cell?.mergeAnchor) {
|
||||
return { row: cell.mergeAnchor.row, col: cell.mergeAnchor.col };
|
||||
}
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
export function getCellSpan(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number
|
||||
): { rowSpan: number; colSpan: number } {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const anchorCell = getCellData(elem, anchor.row, anchor.col);
|
||||
return {
|
||||
rowSpan: Math.max(1, anchorCell?.rowSpan ?? 1),
|
||||
colSpan: Math.max(1, anchorCell?.colSpan ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function isCellAnchor(elem: TemplateElement, row: number, col: number): boolean {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
return anchor.row === row && anchor.col === col;
|
||||
}
|
||||
|
||||
export function iterTableCells(elem: TemplateElement): TableCellCoord[] {
|
||||
const rows = getTableRows(elem);
|
||||
const cols = getTableCols(elem);
|
||||
const result: TableCellCoord[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (isCellAnchor(elem, r, c)) {
|
||||
result.push({ row: r, col: c });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getCellRectMm(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const rowHeights = getTableRowHeights(elem);
|
||||
const colWidths = getTableColWidths(elem);
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
|
||||
|
||||
let y = pad.top;
|
||||
for (let r = 0; r < anchor.row; r++) y += rowHeights[r] ?? 0;
|
||||
let x = pad.left;
|
||||
for (let c = 0; c < anchor.col; c++) x += colWidths[c] ?? 0;
|
||||
|
||||
let width = 0;
|
||||
for (let c = anchor.col; c < anchor.col + colSpan; c++) width += colWidths[c] ?? 0;
|
||||
let height = 0;
|
||||
for (let r = anchor.row; r < anchor.row + rowSpan; r++) height += rowHeights[r] ?? 0;
|
||||
|
||||
return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) };
|
||||
}
|
||||
|
||||
/** 表格行列像素边界(累积取整,避免单元格背景/选区出现缝隙) */
|
||||
export function buildTableGridBoundariesPx(
|
||||
elem: TemplateElement,
|
||||
scale: number,
|
||||
originX = 0,
|
||||
originY = 0
|
||||
): { rowBounds: number[]; colBounds: number[] } {
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const rowHeights = getTableRowHeights(elem);
|
||||
const colWidths = getTableColWidths(elem);
|
||||
|
||||
const rowBounds: number[] = [originY + Math.round(pad.top * scale)];
|
||||
for (const h of rowHeights) {
|
||||
rowBounds.push(rowBounds[rowBounds.length - 1] + Math.round(h * scale));
|
||||
}
|
||||
|
||||
const colBounds: number[] = [originX + Math.round(pad.left * scale)];
|
||||
for (const w of colWidths) {
|
||||
colBounds.push(colBounds[colBounds.length - 1] + Math.round(w * scale));
|
||||
}
|
||||
|
||||
return { rowBounds, colBounds };
|
||||
}
|
||||
|
||||
/** 单元格像素矩形,与 canvas 绘制及设计器选区 overlay 对齐 */
|
||||
export function getCellRectPx(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number,
|
||||
scale: number,
|
||||
originX = 0,
|
||||
originY = 0
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, originX, originY);
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
|
||||
|
||||
const x = colBounds[anchor.col];
|
||||
const y = rowBounds[anchor.row];
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: colBounds[anchor.col + colSpan] - x,
|
||||
height: rowBounds[anchor.row + rowSpan] - y,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultTableElement(id: string, index: number): TemplateElement {
|
||||
const rows = 3;
|
||||
const cols = 3;
|
||||
const rowH = 5;
|
||||
const colW = 10;
|
||||
return {
|
||||
id,
|
||||
type: 'table',
|
||||
name: `表格 ${index}`,
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: cols * colW,
|
||||
height: rows * rowH,
|
||||
content: '',
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
wordWrap: true,
|
||||
fontWeight: 'normal',
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans',
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundOpacity: 0,
|
||||
tableRows: rows,
|
||||
tableCols: cols,
|
||||
tableRowHeights: Array.from({ length: rows }, () => rowH),
|
||||
tableColWidths: Array.from({ length: cols }, () => colW),
|
||||
tableBorderWidth: 0.2,
|
||||
tableBorderColor: '#374151',
|
||||
tableCells: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeTableGrid(
|
||||
elem: TemplateElement,
|
||||
newRows: number,
|
||||
newCols: number
|
||||
): TemplateElement {
|
||||
const rows = Math.max(1, Math.min(50, newRows));
|
||||
const cols = Math.max(1, Math.min(50, newCols));
|
||||
|
||||
const newCells: Record<string, TableCellData> = {};
|
||||
if (elem.tableCells) {
|
||||
for (const [key, data] of Object.entries(elem.tableCells)) {
|
||||
const coord = parseTableCellKey(key);
|
||||
if (!coord || coord.row >= rows || coord.col >= cols) continue;
|
||||
const anchor = data.mergeAnchor ?? { row: coord.row, col: coord.col };
|
||||
if (anchor.row >= rows || anchor.col >= cols) continue;
|
||||
newCells[key] = data;
|
||||
}
|
||||
}
|
||||
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const innerW = Math.max(1, elem.width - pad.left - pad.right);
|
||||
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
|
||||
|
||||
const next: TemplateElement = {
|
||||
...elem,
|
||||
tableRows: rows,
|
||||
tableCols: cols,
|
||||
tableRowHeights: distributeTracks(innerH, rows),
|
||||
tableColWidths: distributeTracks(innerW, cols),
|
||||
tableCells: newCells,
|
||||
width: elem.width,
|
||||
height: elem.height,
|
||||
};
|
||||
return sanitizeTableMerges(next);
|
||||
}
|
||||
|
||||
function selectionBounds(selection: TableCellCoord[]) {
|
||||
const rows = selection.map((s) => s.row);
|
||||
const cols = selection.map((s) => s.col);
|
||||
return {
|
||||
minR: Math.min(...rows),
|
||||
maxR: Math.max(...rows),
|
||||
minC: Math.min(...cols),
|
||||
maxC: Math.max(...cols),
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并选区的左上角锚点坐标 */
|
||||
export function getMergeAnchorFromSelection(selection: TableCellCoord[]): TableCellCoord {
|
||||
const { minR, minC } = selectionBounds(selection);
|
||||
return { row: minR, col: minC };
|
||||
}
|
||||
|
||||
/** 将选区归一化为各合并区域的主单元格,去重 */
|
||||
export function normalizeTableCellSelection(
|
||||
elem: TemplateElement,
|
||||
selection: TableCellCoord[]
|
||||
): TableCellCoord[] {
|
||||
const seen = new Set<string>();
|
||||
const result: TableCellCoord[] = [];
|
||||
for (const coord of selection) {
|
||||
const anchor = getCellAnchor(elem, coord.row, coord.col);
|
||||
const key = tableCellKey(anchor.row, anchor.col);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push({ row: anchor.row, col: anchor.col });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canMergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): boolean {
|
||||
if (selection.length < 2) return false;
|
||||
const { minR, maxR, minC, maxC } = selectionBounds(selection);
|
||||
const expected = (maxR - minR + 1) * (maxC - minC + 1);
|
||||
if (selection.length !== expected) return false;
|
||||
|
||||
const selSet = new Set(selection.map((s) => tableCellKey(s.row, s.col)));
|
||||
for (let r = minR; r <= maxR; r++) {
|
||||
for (let c = minC; c <= maxC; c++) {
|
||||
if (!selSet.has(tableCellKey(r, c))) return false;
|
||||
const anchor = getCellAnchor(elem, r, c);
|
||||
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||
for (let ar = anchor.row; ar < anchor.row + span.rowSpan; ar++) {
|
||||
for (let ac = anchor.col; ac < anchor.col + span.colSpan; ac++) {
|
||||
if (!selSet.has(tableCellKey(ar, ac))) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): TemplateElement {
|
||||
if (!canMergeTableCells(elem, selection)) return elem;
|
||||
const { minR, maxR, minC, maxC } = selectionBounds(selection);
|
||||
const rowSpan = maxR - minR + 1;
|
||||
const colSpan = maxC - minC + 1;
|
||||
const cells = { ...(elem.tableCells ?? {}) };
|
||||
|
||||
for (let r = minR; r <= maxR; r++) {
|
||||
for (let c = minC; c <= maxC; c++) {
|
||||
const key = tableCellKey(r, c);
|
||||
const prev = cells[key] ?? {};
|
||||
if (r === minR && c === minC) {
|
||||
cells[key] = { ...prev, rowSpan, colSpan, mergeAnchor: undefined };
|
||||
} else {
|
||||
cells[key] = { ...prev, mergeAnchor: { row: minR, col: minC }, rowSpan: undefined, colSpan: undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...elem, tableCells: cells };
|
||||
}
|
||||
|
||||
export function canUnmergeTableCell(elem: TemplateElement, row: number, col: number): boolean {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||
return span.rowSpan > 1 || span.colSpan > 1;
|
||||
}
|
||||
|
||||
export function unmergeTableCell(elem: TemplateElement, row: number, col: number): TemplateElement {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||
if (span.rowSpan === 1 && span.colSpan === 1) return elem;
|
||||
|
||||
const cells = { ...(elem.tableCells ?? {}) };
|
||||
for (let r = anchor.row; r < anchor.row + span.rowSpan; r++) {
|
||||
for (let c = anchor.col; c < anchor.col + span.colSpan; c++) {
|
||||
const key = tableCellKey(r, c);
|
||||
const prev = { ...(cells[key] ?? {}) };
|
||||
delete prev.mergeAnchor;
|
||||
delete prev.rowSpan;
|
||||
delete prev.colSpan;
|
||||
if (Object.keys(prev).length === 0) {
|
||||
delete cells[key];
|
||||
} else {
|
||||
cells[key] = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...elem, tableCells: cells };
|
||||
}
|
||||
|
||||
function sanitizeTableMerges(elem: TemplateElement): TemplateElement {
|
||||
const rows = getTableRows(elem);
|
||||
const cols = getTableCols(elem);
|
||||
const cells = { ...(elem.tableCells ?? {}) };
|
||||
for (const key of Object.keys(cells)) {
|
||||
const coord = parseTableCellKey(key);
|
||||
if (!coord || coord.row >= rows || coord.col >= cols) {
|
||||
delete cells[key];
|
||||
}
|
||||
}
|
||||
return { ...elem, tableCells: cells };
|
||||
}
|
||||
|
||||
export function updateTableRowHeight(
|
||||
elem: TemplateElement,
|
||||
rowIndex: number,
|
||||
heightMm: number
|
||||
): TemplateElement {
|
||||
if (rowIndex < 0 || rowIndex >= getTableRows(elem)) return elem;
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const innerH = getTableInnerSizeMm(elem).height;
|
||||
const rowHeights = scaleTracksToExactTotal(getTableRowHeights(elem), innerH);
|
||||
const isLast = rowIndex === rowHeights.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
const nextHeights = [...rowHeights];
|
||||
nextHeights[rowIndex] = round1(Math.max(0.5, heightMm));
|
||||
const newInnerH = round1(nextHeights.reduce((a, b) => a + b, 0));
|
||||
return {
|
||||
...elem,
|
||||
tableRowHeights: nextHeights,
|
||||
height: round1(newInnerH + pad.top + pad.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...elem,
|
||||
tableRowHeights: resizeTrackKeepingTotal(rowHeights, rowIndex, heightMm),
|
||||
height: elem.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTableColWidth(
|
||||
elem: TemplateElement,
|
||||
colIndex: number,
|
||||
widthMm: number
|
||||
): TemplateElement {
|
||||
if (colIndex < 0 || colIndex >= getTableCols(elem)) return elem;
|
||||
const pad = resolvePaddingMm(elem);
|
||||
const innerW = getTableInnerSizeMm(elem).width;
|
||||
const colWidths = scaleTracksToExactTotal(getTableColWidths(elem), innerW);
|
||||
const isLast = colIndex === colWidths.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
const nextWidths = [...colWidths];
|
||||
nextWidths[colIndex] = round1(Math.max(0.5, widthMm));
|
||||
const newInnerW = round1(nextWidths.reduce((a, b) => a + b, 0));
|
||||
return {
|
||||
...elem,
|
||||
tableColWidths: nextWidths,
|
||||
width: round1(newInnerW + pad.left + pad.right),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...elem,
|
||||
tableColWidths: resizeTrackKeepingTotal(colWidths, colIndex, widthMm),
|
||||
width: elem.width,
|
||||
};
|
||||
}
|
||||
|
||||
/** 在现有元素高度内均分行高 */
|
||||
export function equalizeTableRowHeights(elem: TemplateElement): TemplateElement {
|
||||
const rows = getTableRows(elem);
|
||||
const innerH = getTableInnerSizeMm(elem).height;
|
||||
return {
|
||||
...elem,
|
||||
tableRowHeights: scaleTracksToExactTotal(
|
||||
Array.from({ length: rows }, () => innerH / rows),
|
||||
innerH
|
||||
),
|
||||
height: elem.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 在现有元素宽度内均分列宽 */
|
||||
export function equalizeTableColWidths(elem: TemplateElement): TemplateElement {
|
||||
const cols = getTableCols(elem);
|
||||
const innerW = getTableInnerSizeMm(elem).width;
|
||||
return {
|
||||
...elem,
|
||||
tableColWidths: scaleTracksToExactTotal(
|
||||
Array.from({ length: cols }, () => innerW / cols),
|
||||
innerW
|
||||
),
|
||||
width: elem.width,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单元格原始内容,未设置时为空字符串 */
|
||||
export function getCellRawContent(elem: TemplateElement, row: number, col: number): string {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const cell = getCellData(elem, anchor.row, anchor.col);
|
||||
return cell?.content ?? '';
|
||||
}
|
||||
|
||||
export function resolveTableCellContent(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number
|
||||
): string {
|
||||
return getCellRawContent(elem, row, col);
|
||||
}
|
||||
|
||||
export function getEffectiveCellStyle(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number
|
||||
): TemplateElement {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const cell = getCellData(elem, anchor.row, anchor.col);
|
||||
const pick = <K extends keyof TableCellData & keyof TemplateElement>(
|
||||
key: K,
|
||||
fallback: TemplateElement[K]
|
||||
): TemplateElement[K] => {
|
||||
const cellVal = cell?.[key as keyof TableCellData];
|
||||
if (cellVal !== undefined) return cellVal as TemplateElement[K];
|
||||
const elemVal = elem[key];
|
||||
return (elemVal !== undefined ? elemVal : fallback) as TemplateElement[K];
|
||||
};
|
||||
return {
|
||||
...elem,
|
||||
type: 'text',
|
||||
content: getCellRawContent(elem, anchor.row, anchor.col),
|
||||
fontSize: pick('fontSize', elem.fontSize),
|
||||
textAlign: pick('textAlign', elem.textAlign),
|
||||
verticalAlign: pick('verticalAlign', elem.verticalAlign ?? 'middle'),
|
||||
fontWeight: pick('fontWeight', elem.fontWeight),
|
||||
fontStyle: pick('fontStyle', elem.fontStyle ?? 'normal'),
|
||||
textUnderline: pick('textUnderline', elem.textUnderline ?? false),
|
||||
textLineThrough: pick('textLineThrough', elem.textLineThrough ?? false),
|
||||
textScaleX: pick('textScaleX', elem.textScaleX ?? 1),
|
||||
textScaleY: pick('textScaleY', elem.textScaleY ?? 1),
|
||||
letterSpacing: pick('letterSpacing', elem.letterSpacing ?? 0),
|
||||
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
||||
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
||||
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
||||
textMaxLines: pick('textMaxLines', elem.textMaxLines),
|
||||
textEllipsis: pick('textEllipsis', elem.textEllipsis ?? false),
|
||||
fontFamily: pick('fontFamily', elem.fontFamily),
|
||||
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
||||
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
||||
numberPartDisplay: pick('numberPartDisplay', elem.numberPartDisplay ?? 'full'),
|
||||
textExtractMode: pick('textExtractMode', elem.textExtractMode ?? 'none'),
|
||||
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
|
||||
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
|
||||
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
|
||||
textExtractInverse: cell?.textExtractInverse ?? elem.textExtractInverse,
|
||||
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
|
||||
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
|
||||
backgroundColor: cell?.backgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
||||
'content',
|
||||
'fontSize',
|
||||
'textAlign',
|
||||
'verticalAlign',
|
||||
'fontWeight',
|
||||
'fontStyle',
|
||||
'textUnderline',
|
||||
'textLineThrough',
|
||||
'textScaleX',
|
||||
'textScaleY',
|
||||
'letterSpacing',
|
||||
'textWritingMode',
|
||||
'textColor',
|
||||
'backgroundColor',
|
||||
'wordWrap',
|
||||
'textMaxLines',
|
||||
'textEllipsis',
|
||||
'fontFamily',
|
||||
'textFormat',
|
||||
'decimalPlaces',
|
||||
'numberPartDisplay',
|
||||
'textExtractMode',
|
||||
'textSplitDelimiter',
|
||||
'textSplitIndex',
|
||||
'textExtractLength',
|
||||
'textExtractInverse',
|
||||
'textDisplayPrefix',
|
||||
'textDisplaySuffix',
|
||||
];
|
||||
|
||||
export function applyCellTextElementChange(
|
||||
elem: TemplateElement,
|
||||
row: number,
|
||||
col: number,
|
||||
textElem: TemplateElement
|
||||
): TemplateElement {
|
||||
const patch: Partial<TableCellData> = {};
|
||||
for (const key of CELL_TEXT_PATCH_KEYS) {
|
||||
const val = textElem[key as keyof TemplateElement];
|
||||
if (val !== undefined) {
|
||||
(patch as Record<string, unknown>)[key] = val;
|
||||
}
|
||||
}
|
||||
return updateTableCells(elem, [{ row, col }], patch);
|
||||
}
|
||||
|
||||
export function updateTableCells(
|
||||
elem: TemplateElement,
|
||||
coords: TableCellCoord[],
|
||||
patch: Partial<TableCellData>
|
||||
): TemplateElement {
|
||||
const cells = { ...(elem.tableCells ?? {}) };
|
||||
for (const { row, col } of coords) {
|
||||
const anchor = getCellAnchor(elem, row, col);
|
||||
const key = tableCellKey(anchor.row, anchor.col);
|
||||
cells[key] = { ...(cells[key] ?? {}), ...patch };
|
||||
}
|
||||
return { ...elem, tableCells: cells };
|
||||
}
|
||||
|
||||
export function cellCoordKey(coord: TableCellCoord): string {
|
||||
return tableCellKey(coord.row, coord.col);
|
||||
}
|
||||
|
||||
/** 点击单元格:已选则取消,未选则加入选区(无需 Shift/Ctrl) */
|
||||
export function toggleTableCellSelection(
|
||||
current: TableCellCoord[],
|
||||
coord: TableCellCoord
|
||||
): TableCellCoord[] {
|
||||
const key = cellCoordKey(coord);
|
||||
const exists = current.some((c) => cellCoordKey(c) === key);
|
||||
if (exists) return current.filter((c) => cellCoordKey(c) !== key);
|
||||
return [...current, coord];
|
||||
}
|
||||
174
src/types.ts
174
src/types.ts
@@ -10,18 +10,41 @@ export const TEXT_EXTRACT_MODE_LABELS: Record<TextExtractMode, string> = {
|
||||
prefix: '前 N 位',
|
||||
suffix: '后 N 位',
|
||||
};
|
||||
|
||||
/** 变量/内容值提取配置(元素、表格单元格、显示条件等共用) */
|
||||
export interface ValueExtractConfig {
|
||||
textExtractMode?: TextExtractMode;
|
||||
textSplitDelimiter?: string;
|
||||
textSplitIndex?: number;
|
||||
textExtractLength?: number;
|
||||
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||
textExtractInverse?: boolean;
|
||||
textDisplayPrefix?: string;
|
||||
textDisplaySuffix?: string;
|
||||
}
|
||||
|
||||
export type ElementVisibilityMode = 'always' | 'conditional';
|
||||
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq';
|
||||
export type VisibilityOperator =
|
||||
| 'empty'
|
||||
| 'not_empty'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'contains'
|
||||
| 'not_contains';
|
||||
/** 显示条件判断对象:变量值 或 本元素解析后的内容值 */
|
||||
export type VisibilityRuleTarget = 'variable' | 'content';
|
||||
|
||||
export interface VisibilityRule {
|
||||
export interface VisibilityRule extends ValueExtractConfig {
|
||||
/** 条件对象,默认 variable */
|
||||
target?: VisibilityRuleTarget;
|
||||
variable: string;
|
||||
operator: VisibilityOperator;
|
||||
/** gt / lt / eq / neq 时的比较目标值 */
|
||||
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */
|
||||
value?: string;
|
||||
/** 为 false 时跳过该条件;默认启用 */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const VISIBILITY_RULE_TARGET_LABELS: Record<VisibilityRuleTarget, string> = {
|
||||
@@ -36,14 +59,59 @@ export const VISIBILITY_OPERATOR_LABELS: Record<VisibilityOperator, string> = {
|
||||
lt: '小于',
|
||||
eq: '等于',
|
||||
neq: '不等于',
|
||||
contains: '包含',
|
||||
not_contains: '不包含',
|
||||
};
|
||||
export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
|
||||
|
||||
/** 表格单元格数据;键为 `${row},${col}` */
|
||||
export interface TableCellData {
|
||||
/** 被合并单元格指向主单元格坐标 */
|
||||
mergeAnchor?: { row: number; col: number };
|
||||
/** 主单元格合并行数,默认 1 */
|
||||
rowSpan?: number;
|
||||
/** 主单元格合并列数,默认 1 */
|
||||
colSpan?: number;
|
||||
content?: string;
|
||||
fontSize?: number;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
fontWeight?: 'normal' | 'bold';
|
||||
fontStyle?: 'normal' | 'italic';
|
||||
textUnderline?: boolean;
|
||||
textLineThrough?: boolean;
|
||||
textScaleX?: number;
|
||||
textScaleY?: number;
|
||||
letterSpacing?: number;
|
||||
textWritingMode?: 'horizontal' | 'vertical';
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
wordWrap?: boolean;
|
||||
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制 */
|
||||
textMaxLines?: number;
|
||||
/** 超出最大行数时末行/末列显示省略号 */
|
||||
textEllipsis?: boolean;
|
||||
fontFamily?: 'sans' | 'serif' | 'mono';
|
||||
textFormat?: TextFormat;
|
||||
decimalPlaces?: number;
|
||||
numberPartDisplay?: NumberPartDisplay;
|
||||
textExtractMode?: TextExtractMode;
|
||||
textSplitDelimiter?: string;
|
||||
textSplitIndex?: number;
|
||||
textExtractLength?: number;
|
||||
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||
textExtractInverse?: boolean;
|
||||
textDisplayPrefix?: string;
|
||||
textDisplaySuffix?: string;
|
||||
}
|
||||
|
||||
export interface TemplateElement {
|
||||
id: string;
|
||||
type: 'text' | 'barcode' | 'qrcode' | 'image';
|
||||
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
||||
name: string;
|
||||
/** 用户是否手动修改过图层名称;为 true 时不再根据内容自动同步名称 */
|
||||
nameCustomized?: boolean;
|
||||
x: number; // in mm
|
||||
y: number; // in mm
|
||||
width: number; // in mm
|
||||
@@ -67,6 +135,10 @@ export interface TemplateElement {
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
/** 是否自动换行,仅 type=text,默认 true */
|
||||
wordWrap?: boolean;
|
||||
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制,仅 type=text */
|
||||
textMaxLines?: number;
|
||||
/** 超出最大行数时显示省略号,仅 type=text */
|
||||
textEllipsis?: boolean;
|
||||
/** 文本排列方向,仅 type=text,默认 horizontal */
|
||||
textWritingMode?: 'horizontal' | 'vertical';
|
||||
fontWeight: 'normal' | 'bold';
|
||||
@@ -96,8 +168,14 @@ export interface TemplateElement {
|
||||
paddingBottom?: number;
|
||||
paddingLeft?: number;
|
||||
locked?: boolean;
|
||||
/** 图层是否隐藏;隐藏后在预览、导出、打印及画布中均不显示 */
|
||||
hidden?: boolean;
|
||||
/** 图片缩放方式,仅 type=image 时有效 */
|
||||
imageFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
||||
defaultImage?: string;
|
||||
/** 输出模式下内容值为空时仍渲染;条码/二维码/图片有效,默认 false */
|
||||
showWhenContentEmpty?: boolean;
|
||||
/** 文本显示格式,仅 type=text 时有效 */
|
||||
textFormat?: TextFormat;
|
||||
/** 数值/百分比/货币的小数位数,默认 2 */
|
||||
@@ -112,6 +190,8 @@ export interface TemplateElement {
|
||||
textSplitIndex?: number;
|
||||
/** 前 N / 后 N 位提取时的位数 */
|
||||
textExtractLength?: number;
|
||||
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||
textExtractInverse?: boolean;
|
||||
/** 提取后在显示文本前追加的前缀字符,仅 type=text;不参与内容值条件 */
|
||||
textDisplayPrefix?: string;
|
||||
/** 提取后在显示文本后追加的后缀字符,仅 type=text;不参与内容值条件 */
|
||||
@@ -137,6 +217,51 @@ export interface TemplateElement {
|
||||
borderRadiusTR?: number;
|
||||
borderRadiusBR?: number;
|
||||
borderRadiusBL?: number;
|
||||
/** 表格行数,仅 type=table */
|
||||
tableRows?: number;
|
||||
/** 表格列数,仅 type=table */
|
||||
tableCols?: number;
|
||||
/** 各行高度 (mm),仅 type=table */
|
||||
tableRowHeights?: number[];
|
||||
/** 各列宽度 (mm),仅 type=table */
|
||||
tableColWidths?: number[];
|
||||
/** 单元格数据,键 `${row},${col}`,仅 type=table */
|
||||
tableCells?: Record<string, TableCellData>;
|
||||
/** 单元格网格线宽 (mm),仅 type=table,默认 0.2 */
|
||||
tableBorderWidth?: number;
|
||||
/** 单元格网格线颜色,仅 type=table */
|
||||
tableBorderColor?: string;
|
||||
/** 图层蒙版;在预览与导出时裁剪元素可见区域 */
|
||||
mask?: ElementMaskConfig;
|
||||
}
|
||||
|
||||
export type ElementMaskShape = 'rect' | 'image';
|
||||
|
||||
export interface ElementMaskConfig {
|
||||
/** 是否启用蒙版 */
|
||||
enabled?: boolean;
|
||||
/** 蒙版形状:矩形矢量区域或图片 alpha */
|
||||
type?: ElementMaskShape;
|
||||
/** 相对元素左上角的 X 偏移 (mm) */
|
||||
x?: number;
|
||||
/** 相对元素左上角的 Y 偏移 (mm) */
|
||||
y?: number;
|
||||
/** 蒙版区域宽度 (mm),默认与元素同宽 */
|
||||
width?: number;
|
||||
/** 蒙版区域高度 (mm),默认与元素同高 */
|
||||
height?: number;
|
||||
/** 圆角半径 (mm) */
|
||||
borderRadius?: number;
|
||||
borderRadiusTL?: number;
|
||||
borderRadiusTR?: number;
|
||||
borderRadiusBR?: number;
|
||||
borderRadiusBL?: number;
|
||||
/** 图片蒙版(data URI / URL),type=image 时使用 */
|
||||
image?: string;
|
||||
/** 图片蒙版在区域内的适应方式 */
|
||||
imageFit?: 'contain' | 'cover' | 'fill';
|
||||
/** include=保留蒙版区域内,exclude=保留区域外;默认 include */
|
||||
mode?: 'include' | 'exclude';
|
||||
}
|
||||
|
||||
export interface DataSourceMeta {
|
||||
@@ -161,7 +286,7 @@ export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom';
|
||||
|
||||
export interface ExportRangeConfig {
|
||||
mode: ExportRangeMode;
|
||||
/** 自定义范围:1-based 序号,如 "1,3,5-10" */
|
||||
/** 自定义范围:1-based 序号,如 "1,3,5-10"(排版过滤,不写入模板) */
|
||||
custom?: string;
|
||||
}
|
||||
|
||||
@@ -175,14 +300,51 @@ export interface LabelTemplate {
|
||||
variableList?: string[];
|
||||
/** 设计预览用:变量名 → 默认展示值 */
|
||||
variableDefaults?: Record<string, string>;
|
||||
/** 数据过滤条件(写入模板);全部满足的数据行才参与排版/导出 */
|
||||
dataFilterRules?: VisibilityRule[];
|
||||
/** 整页排版配置(每模板独立) */
|
||||
paper?: PaperConfig;
|
||||
/** PDF 裁切参考线配置 */
|
||||
cutLine?: CutLineConfig;
|
||||
/** 设计画布预览参考背景(URL / data URI) */
|
||||
previewReferenceBackground?: string;
|
||||
/** 预览参考背景不透明度 0–100,默认 100 */
|
||||
previewReferenceBackgroundOpacity?: number;
|
||||
/** 预览参考背景适应方式,默认 cover */
|
||||
previewReferenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 设计预览(画布、模板列表缩略图)是否显示参考背景,默认 true */
|
||||
enablePreviewReferenceBackground?: boolean;
|
||||
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
|
||||
includeReferenceBackgroundInOutput?: boolean;
|
||||
/** 排版整页背景图(URL / data URI) */
|
||||
layoutPageBackground?: string;
|
||||
/** 排版整页背景不透明度 0–100,默认 100 */
|
||||
layoutPageBackgroundOpacity?: number;
|
||||
/** 排版整页背景适应方式,默认 cover */
|
||||
layoutPageBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 排版预览是否显示整页背景,默认 true */
|
||||
enableLayoutPageBackground?: boolean;
|
||||
/** PDF / 打印输出时是否绘制整页背景,默认 false */
|
||||
includeLayoutPageBackgroundInOutput?: boolean;
|
||||
/** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */
|
||||
exportImageFileNamePattern?: string;
|
||||
/** 批量导出图片:all=全部导出,conditional=按条件过滤 */
|
||||
exportImageFilterMode?: ElementVisibilityMode;
|
||||
/** 批量导出图片过滤条件;全部满足时才导出该枚标签 */
|
||||
exportImageFilterRules?: VisibilityRule[];
|
||||
}
|
||||
|
||||
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
|
||||
|
||||
/** 排版/印刷输出色彩模式 */
|
||||
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
|
||||
|
||||
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
|
||||
rgb: '标准(轻度印刷补偿)',
|
||||
grayscale: '黑白(灰度)',
|
||||
cmyk: 'CMYK 印刷模拟(推荐)',
|
||||
};
|
||||
|
||||
export interface PaperConfig {
|
||||
type: PaperType;
|
||||
width: number; // in mm
|
||||
@@ -195,6 +357,8 @@ export interface PaperConfig {
|
||||
columnGap: number; // in mm (horizontal spacing between labels)
|
||||
rowGap: number; // in mm (vertical spacing between labels)
|
||||
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
|
||||
/** 排版预览与导出时的色彩模式,默认 rgb */
|
||||
printColorMode?: PrintColorMode;
|
||||
}
|
||||
|
||||
export interface RecordRow {
|
||||
|
||||
657
src/utils.ts
657
src/utils.ts
@@ -13,6 +13,7 @@ import {
|
||||
VisibilityRule,
|
||||
VisibilityOperator,
|
||||
VISIBILITY_OPERATOR_LABELS,
|
||||
ValueExtractConfig,
|
||||
} from './types';
|
||||
|
||||
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
|
||||
@@ -27,6 +28,105 @@ export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
|
||||
|
||||
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
|
||||
|
||||
/** 粘贴副本时相对原位置的偏移 (mm) */
|
||||
export const PASTE_ELEMENT_OFFSET_MM = 2;
|
||||
|
||||
/** 在标签画布上居中放置元素(mm,保留 1 位小数) */
|
||||
export function centerElementOnLabel(
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
elementWidth: number,
|
||||
elementHeight: number
|
||||
): { x: number; y: number } {
|
||||
const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10;
|
||||
const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10;
|
||||
return {
|
||||
x: Math.max(0, x),
|
||||
y: Math.max(0, y),
|
||||
};
|
||||
}
|
||||
|
||||
const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/;
|
||||
const MAX_LAYER_NAME_FROM_CONTENT = 40;
|
||||
|
||||
/** 是否为新建时的默认图层名(含粘贴副本后缀) */
|
||||
export function isAutoGeneratedElementName(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === '未命名元素') return true;
|
||||
if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true;
|
||||
if (trimmed.endsWith(' 副本')) {
|
||||
const base = trimmed.slice(0, -3).trim();
|
||||
if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 是否应根据内容自动同步图层名称 */
|
||||
export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean {
|
||||
if (elem.nameCustomized) return false;
|
||||
return isAutoGeneratedElementName(elem.name);
|
||||
}
|
||||
|
||||
/** 从元素内容推导图层显示名称 */
|
||||
export function deriveElementNameFromContent(
|
||||
content: string,
|
||||
type: TemplateElement['type']
|
||||
): string {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return '未命名元素';
|
||||
|
||||
if (type === 'image') {
|
||||
if (trimmed.startsWith('data:')) return '图片';
|
||||
const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim();
|
||||
if (withoutVars && /^https?:\/\//i.test(withoutVars)) {
|
||||
try {
|
||||
const path = withoutVars.split(/[?#]/)[0];
|
||||
const segment = path.split('/').filter(Boolean).pop();
|
||||
if (segment) {
|
||||
return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const singleLine = trimmed.split(/\r?\n/)[0].trim();
|
||||
if (!singleLine) return '未命名元素';
|
||||
if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素';
|
||||
return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||
}
|
||||
|
||||
/** 更新内容并在需要时同步图层名称 */
|
||||
export function withContentAndSyncedName(
|
||||
elem: TemplateElement,
|
||||
content: string
|
||||
): TemplateElement {
|
||||
const updated = { ...elem, content };
|
||||
if (!shouldSyncElementNameFromContent(elem)) return updated;
|
||||
return {
|
||||
...updated,
|
||||
name: deriveElementNameFromContent(content, elem.type),
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝元素并生成新 id,用于复制/粘贴 */
|
||||
export function cloneTemplateElementsForPaste(
|
||||
elements: TemplateElement[],
|
||||
pasteRepeat: number
|
||||
): TemplateElement[] {
|
||||
const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat);
|
||||
const stamp = Date.now();
|
||||
return elements.map((element, index) => {
|
||||
const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement;
|
||||
cloned.id = `${element.type}_${stamp}_${index}`;
|
||||
cloned.name = `${element.name} 副本`;
|
||||
cloned.x = Math.round((element.x + offset) * 10) / 10;
|
||||
cloned.y = Math.round((element.y + offset) * 10) / 10;
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
|
||||
return {
|
||||
fileName: data.fileName,
|
||||
@@ -157,11 +257,67 @@ export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
|
||||
export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) =>
|
||||
a.mode === b.mode && (a.custom ?? '') === (b.custom ?? '');
|
||||
|
||||
/** 模板数据过滤规则(仅 variable 目标) */
|
||||
export function resolveDataFilterRules(
|
||||
template: Pick<LabelTemplate, 'dataFilterRules'>
|
||||
): VisibilityRule[] {
|
||||
return resolveVariableFilterRules(template.dataFilterRules);
|
||||
}
|
||||
|
||||
/** 按模板数据过滤条件筛选 0-based 行索引 */
|
||||
export function getDataFilteredIndices(
|
||||
rows: RecordRow[],
|
||||
template: Pick<LabelTemplate, 'dataFilterRules'>,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): number[] {
|
||||
const total = rows.length;
|
||||
if (total <= 0) return [];
|
||||
const rules = getActiveVisibilityRules(resolveDataFilterRules(template));
|
||||
if (rules.length === 0) return Array.from({ length: total }, (_, i) => i);
|
||||
return Array.from({ length: total }, (_, i) => i).filter((i) =>
|
||||
rowMatchesVariableFilterRules(rows[i], i, rules, variableDefaults)
|
||||
);
|
||||
}
|
||||
|
||||
/** 在候选行上应用排版过滤(奇偶/自定义序号,基于原始 1-based 行号) */
|
||||
export function getLayoutRangeIndices(
|
||||
candidateIndices: number[],
|
||||
totalRows: number,
|
||||
range: ExportRangeConfig
|
||||
): number[] {
|
||||
if (candidateIndices.length === 0) return [];
|
||||
switch (range.mode) {
|
||||
case 'odd':
|
||||
return candidateIndices.filter((i) => (i + 1) % 2 === 1);
|
||||
case 'even':
|
||||
return candidateIndices.filter((i) => (i + 1) % 2 === 0);
|
||||
case 'custom': {
|
||||
const parsed = parseCustomExportRange(range.custom ?? '', totalRows);
|
||||
const candidateSet = new Set(candidateIndices);
|
||||
return parsed.filter((i) => candidateSet.has(i));
|
||||
}
|
||||
case 'all':
|
||||
default:
|
||||
return candidateIndices;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按数据过滤 + 排版过滤得到最终 0-based 行索引(保持升序) */
|
||||
export function getExportRangeIndices(
|
||||
rows: RecordRow[],
|
||||
range: ExportRangeConfig,
|
||||
template: Pick<LabelTemplate, 'dataFilterRules'>,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): number[] {
|
||||
const dataFiltered = getDataFilteredIndices(rows, template, variableDefaults);
|
||||
return getLayoutRangeIndices(dataFiltered, rows.length, range);
|
||||
}
|
||||
|
||||
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
||||
enabled: true,
|
||||
style: 'dashed',
|
||||
width: 0.1,
|
||||
color: '#cccccc',
|
||||
width: 0.2,
|
||||
color: '#333333',
|
||||
};
|
||||
|
||||
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
|
||||
@@ -245,22 +401,6 @@ export interface PrintableLabel {
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
/** 按导出范围筛选 0-based 行索引(保持升序) */
|
||||
export function getExportRangeIndices(total: number, range: ExportRangeConfig): number[] {
|
||||
if (total <= 0) return [];
|
||||
switch (range.mode) {
|
||||
case 'odd':
|
||||
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 1);
|
||||
case 'even':
|
||||
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0);
|
||||
case 'custom':
|
||||
return parseCustomExportRange(range.custom ?? '', total);
|
||||
case 'all':
|
||||
default:
|
||||
return Array.from({ length: total }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析自定义范围字符串(1-based 序号) */
|
||||
export function parseCustomExportRange(input: string, total: number): number[] {
|
||||
const trimmed = input.trim();
|
||||
@@ -286,12 +426,13 @@ export function parseCustomExportRange(input: string, total: number): number[] {
|
||||
export function buildPrintablePages(
|
||||
rows: RecordRow[],
|
||||
paper: PaperConfig,
|
||||
labelSize: { width: number; height: number },
|
||||
exportRange: ExportRangeConfig
|
||||
template: Pick<LabelTemplate, 'width' | 'height' | 'dataFilterRules'>,
|
||||
exportRange: ExportRangeConfig,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
) {
|
||||
const gridInfo = calculateGrid(paper, labelSize);
|
||||
const gridInfo = calculateGrid(paper, template);
|
||||
const labelsPerPage = gridInfo.cols * gridInfo.rows;
|
||||
const indices = getExportRangeIndices(rows.length, exportRange);
|
||||
const indices = getExportRangeIndices(rows, exportRange, template, variableDefaults);
|
||||
const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i }));
|
||||
const totalPages = Math.ceil(selected.length / labelsPerPage) || 1;
|
||||
const pages: (PrintableLabel | null)[][] = [];
|
||||
@@ -393,18 +534,43 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
|
||||
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||
columnGap: 2,
|
||||
rowGap: 2,
|
||||
columnGap: 0,
|
||||
rowGap: 0,
|
||||
flow: 'left-right-top-bottom',
|
||||
printColorMode: 'cmyk',
|
||||
};
|
||||
|
||||
/** 根据纸张排版参数反算单个标签尺寸 (mm) */
|
||||
export function computeLabelSizeFromLayout(
|
||||
paper: PaperConfig,
|
||||
cols: number,
|
||||
rows: number
|
||||
): { width: number; height: number } | null {
|
||||
if (cols < 1 || rows < 1) return null;
|
||||
|
||||
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||
const usableW = paperW - paper.marginLeft - paper.marginRight;
|
||||
const usableH = paperH - paper.marginTop - paper.marginBottom;
|
||||
if (usableW <= 0 || usableH <= 0) return null;
|
||||
|
||||
const width = (usableW - (cols - 1) * paper.columnGap) / cols;
|
||||
const height = (usableH - (rows - 1) * paper.rowGap) / rows;
|
||||
if (width < 10 || height < 10) return null;
|
||||
|
||||
return {
|
||||
width: Math.round(width * 10) / 10,
|
||||
height: Math.round(height * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
|
||||
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
|
||||
const { dataSource, dataSourceMeta, showPdfCutLines, exportRange: _exportRange, ...rest } = tmpl;
|
||||
const { ...rest } = tmpl;
|
||||
const cutLine: CutLineConfig = {
|
||||
...DEFAULT_CUT_LINE_CONFIG,
|
||||
...rest.cutLine,
|
||||
enabled: rest.cutLine?.enabled ?? showPdfCutLines !== false,
|
||||
enabled: rest.cutLine?.enabled ?? true,
|
||||
width:
|
||||
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
|
||||
? rest.cutLine.width
|
||||
@@ -495,36 +661,76 @@ export function resolveElementBackgroundFill(
|
||||
return color;
|
||||
}
|
||||
|
||||
/** 从显示/过滤条件中收集引用的变量名 */
|
||||
function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set<string>) {
|
||||
const name = (rule.variable ?? '').trim();
|
||||
if (name) vars.add(name);
|
||||
if (rule.value) {
|
||||
for (const v of extractVariablesFromContent(rule.value)) {
|
||||
vars.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set<string>) {
|
||||
if (!rules) return;
|
||||
for (const rule of rules) {
|
||||
collectVariablesFromVisibilityRule(rule, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从元素内容(含表格单元格)提取变量名 */
|
||||
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
||||
const vars = new Set<string>();
|
||||
for (const v of extractVariablesFromContent(elem.content)) {
|
||||
vars.add(v);
|
||||
}
|
||||
if (elem.type === 'table' && elem.tableCells) {
|
||||
for (const cell of Object.values(elem.tableCells)) {
|
||||
if (cell.content) {
|
||||
for (const v of extractVariablesFromContent(cell.content)) {
|
||||
vars.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
/** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */
|
||||
export function extractTemplateVariables(template: {
|
||||
variableList?: string[];
|
||||
elements: TemplateElement[];
|
||||
dataFilterRules?: VisibilityRule[];
|
||||
exportImageFilterRules?: VisibilityRule[];
|
||||
}): string[] {
|
||||
const vars = new Set<string>(template.variableList ?? []);
|
||||
for (const elem of template.elements) {
|
||||
for (const v of extractVariablesFromContent(elem.content)) {
|
||||
for (const v of extractVariablesFromElement(elem)) {
|
||||
vars.add(v);
|
||||
}
|
||||
for (const rule of resolveVisibilityRules(elem)) {
|
||||
const name = rule.variable.trim();
|
||||
if (name) vars.add(name);
|
||||
}
|
||||
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||
}
|
||||
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
/** 被元素内容或显示条件引用的变量 */
|
||||
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
|
||||
export function getUsedTemplateVariables(template: {
|
||||
elements: TemplateElement[];
|
||||
dataFilterRules?: VisibilityRule[];
|
||||
exportImageFilterRules?: VisibilityRule[];
|
||||
}): string[] {
|
||||
const vars = new Set<string>();
|
||||
for (const elem of template.elements) {
|
||||
for (const v of extractVariablesFromContent(elem.content)) {
|
||||
for (const v of extractVariablesFromElement(elem)) {
|
||||
vars.add(v);
|
||||
}
|
||||
for (const rule of resolveVisibilityRules(elem)) {
|
||||
const name = rule.variable.trim();
|
||||
if (name) vars.add(name);
|
||||
}
|
||||
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||
}
|
||||
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
@@ -560,28 +766,53 @@ export function isConditionalVisibility(elem: TemplateElement): boolean {
|
||||
}
|
||||
|
||||
export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean {
|
||||
return operator === 'gt' || operator === 'lt' || operator === 'eq' || operator === 'neq';
|
||||
return (
|
||||
operator === 'gt' ||
|
||||
operator === 'lt' ||
|
||||
operator === 'eq' ||
|
||||
operator === 'neq' ||
|
||||
operator === 'contains' ||
|
||||
operator === 'not_contains'
|
||||
);
|
||||
}
|
||||
|
||||
export function isVisibilityRuleEnabled(rule: VisibilityRule): boolean {
|
||||
return rule.enabled !== false;
|
||||
}
|
||||
|
||||
export function getActiveVisibilityRules(rules: VisibilityRule[]): VisibilityRule[] {
|
||||
return rules.filter(isVisibilityRuleEnabled);
|
||||
}
|
||||
|
||||
export function toggleVisibilityRuleEnabled(rule: VisibilityRule): VisibilityRule {
|
||||
return { ...rule, enabled: isVisibilityRuleEnabled(rule) ? false : undefined };
|
||||
}
|
||||
|
||||
/** 将显示条件格式化为可读说明 */
|
||||
export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
|
||||
const extractNote = formatValueExtractSummary(rule);
|
||||
const subjectLabel =
|
||||
rule.target === 'content' ? '内容值' : `{${rule.variable}}`;
|
||||
const subject = extractNote ? `${subjectLabel}(${extractNote})` : subjectLabel;
|
||||
switch (rule.operator) {
|
||||
case 'empty':
|
||||
return `${subjectLabel} 为空`;
|
||||
return `${subject} 为空`;
|
||||
case 'not_empty':
|
||||
return `${subjectLabel} 不为空`;
|
||||
return `${subject} 不为空`;
|
||||
case 'gt':
|
||||
return `${subjectLabel} 大于 ${rule.value ?? '—'}`;
|
||||
return `${subject} 大于 ${rule.value ?? '—'}`;
|
||||
case 'lt':
|
||||
return `${subjectLabel} 小于 ${rule.value ?? '—'}`;
|
||||
return `${subject} 小于 ${rule.value ?? '—'}`;
|
||||
case 'eq':
|
||||
return `${subjectLabel} 等于 ${rule.value ?? '—'}`;
|
||||
return `${subject} 等于 ${rule.value ?? '—'}`;
|
||||
case 'neq':
|
||||
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`;
|
||||
return `${subject} 不等于 ${rule.value ?? '—'}`;
|
||||
case 'contains':
|
||||
return `${subject} 包含 ${rule.value ?? '—'}`;
|
||||
case 'not_contains':
|
||||
return `${subject} 不包含 ${rule.value ?? '—'}`;
|
||||
default:
|
||||
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
||||
return `${subject} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,9 +826,17 @@ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[]
|
||||
return elem.visibilityRules
|
||||
.map((r) => ({
|
||||
target: r.target ?? 'variable',
|
||||
variable: r.variable.trim(),
|
||||
variable: (r.variable ?? '').trim(),
|
||||
operator: r.operator,
|
||||
value: r.value,
|
||||
enabled: r.enabled,
|
||||
textExtractMode: r.textExtractMode,
|
||||
textSplitDelimiter: r.textSplitDelimiter,
|
||||
textSplitIndex: r.textSplitIndex,
|
||||
textExtractLength: r.textExtractLength,
|
||||
textExtractInverse: r.textExtractInverse,
|
||||
textDisplayPrefix: r.textDisplayPrefix,
|
||||
textDisplaySuffix: r.textDisplaySuffix,
|
||||
}))
|
||||
.filter((r) => r.target === 'content' || r.variable);
|
||||
}
|
||||
@@ -643,7 +882,7 @@ function compareVariableToTarget(
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 读取显示条件所判断的原始值 */
|
||||
/** 读取显示条件所判断的原始值(含条件专用值提取) */
|
||||
function getVisibilityRuleRawValue(
|
||||
rule: VisibilityRule,
|
||||
elem: TemplateElement | undefined,
|
||||
@@ -652,15 +891,43 @@ function getVisibilityRuleRawValue(
|
||||
defaults?: Record<string, string> | null,
|
||||
mode: ContentResolveMode = 'output'
|
||||
): string | undefined {
|
||||
let resolved: string | undefined;
|
||||
|
||||
if (isVisibilityContentTarget(rule)) {
|
||||
if (!elem) return undefined;
|
||||
const effectiveRow =
|
||||
row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null);
|
||||
const resolved = resolveElementCoreValue(elem, effectiveRow, rowIndex, defaults, 'output');
|
||||
const trimmed = resolved.trim();
|
||||
return trimmed || undefined;
|
||||
const formatSubst = textElementNeedsNumberFormatSubst(elem)
|
||||
? (raw: string) => formatTextNumberSubstValue(elem, raw)
|
||||
: undefined;
|
||||
const trimmed = resolveContent(elem.content, effectiveRow, rowIndex, defaults, {
|
||||
mode: 'output',
|
||||
formatSubst,
|
||||
}).trim();
|
||||
resolved = trimmed || undefined;
|
||||
} else {
|
||||
resolved = getVariableRawValue(rule.variable, row, defaults, mode);
|
||||
}
|
||||
return getVariableRawValue(rule.variable, row, defaults, mode);
|
||||
|
||||
if (resolved === undefined) return undefined;
|
||||
if (!valueExtractConfigHasExtract(rule)) return resolved;
|
||||
const extracted = applyValueExtractConfig(resolved, rule);
|
||||
const trimmed = extracted.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
/** 解析条件目标值(支持 {变量名}) */
|
||||
export function resolveVisibilityRuleCompareTarget(
|
||||
targetTemplate: string | undefined,
|
||||
row: RecordRow | null,
|
||||
defaults: Record<string, string> | null | undefined,
|
||||
mode: ContentResolveMode,
|
||||
rowIndex: number = 0
|
||||
): string {
|
||||
const trimmed = (targetTemplate ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (!trimmed.includes('{')) return trimmed;
|
||||
return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim();
|
||||
}
|
||||
|
||||
/** 单条显示条件是否满足 */
|
||||
@@ -674,6 +941,13 @@ export function evaluateVisibilityRule(
|
||||
): boolean {
|
||||
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
|
||||
const isEmpty = raw === undefined;
|
||||
const compareTarget = resolveVisibilityRuleCompareTarget(
|
||||
rule.value,
|
||||
row,
|
||||
defaults,
|
||||
mode,
|
||||
rowIndex
|
||||
);
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'empty':
|
||||
@@ -684,12 +958,20 @@ export function evaluateVisibilityRule(
|
||||
case 'lt':
|
||||
case 'eq': {
|
||||
if (isEmpty) return false;
|
||||
return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator);
|
||||
return compareVariableToTarget(raw!, compareTarget, rule.operator);
|
||||
}
|
||||
case 'neq': {
|
||||
const target = (rule.value ?? '').trim();
|
||||
if (isEmpty) return target !== '';
|
||||
return compareVariableToTarget(raw!, target, 'neq');
|
||||
if (isEmpty) return compareTarget !== '';
|
||||
return compareVariableToTarget(raw!, compareTarget, 'neq');
|
||||
}
|
||||
case 'contains': {
|
||||
if (!compareTarget || isEmpty) return false;
|
||||
return raw!.includes(compareTarget);
|
||||
}
|
||||
case 'not_contains': {
|
||||
if (!compareTarget) return true;
|
||||
if (isEmpty) return true;
|
||||
return !raw!.includes(compareTarget);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
@@ -731,6 +1013,138 @@ export function resolveContent(
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理为安全的文件名(不含扩展名) */
|
||||
export function sanitizeFileName(name: string, fallback = 'label'): string {
|
||||
const cleaned = name
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/[\u0000-\u001f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '')
|
||||
.slice(0, 120);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析批量导出图片的文件名模板。
|
||||
* - {#SEQ}:导出顺序(1-based);{#SEQ:4} 可补零
|
||||
* - {#INDEX}:数据行序号(1-based);{#INDEX:4} 可补零
|
||||
* - {变量名}:当前行数据或 variableDefaults
|
||||
*/
|
||||
export function resolveExportImageFileName(
|
||||
pattern: string | undefined,
|
||||
label: PrintableLabel,
|
||||
seq: number,
|
||||
padWidth: number,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): string {
|
||||
if (!pattern?.trim()) {
|
||||
return String(seq).padStart(padWidth, '0');
|
||||
}
|
||||
|
||||
const normalizedPattern = pattern.replace(/\r?\n/g, '').trim();
|
||||
if (!normalizedPattern) {
|
||||
return String(seq).padStart(padWidth, '0');
|
||||
}
|
||||
|
||||
const resolved = normalizedPattern.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
||||
const special = key.match(/^(#SEQ|#INDEX)(?::(\d+))?$/);
|
||||
if (special) {
|
||||
const width = special[2] ? parseInt(special[2], 10) : 0;
|
||||
const raw = special[1] === '#SEQ' ? String(seq) : String(label.sourceIndex + 1);
|
||||
return width > 0 ? raw.padStart(width, '0') : raw;
|
||||
}
|
||||
if (key === '#SEQ') return String(seq);
|
||||
if (key === '#INDEX') return String(label.sourceIndex + 1);
|
||||
|
||||
let val: string | undefined;
|
||||
if (label.row && label.row[key] !== undefined && String(label.row[key]).trim() !== '') {
|
||||
val = String(label.row[key]);
|
||||
} else if (
|
||||
variableDefaults &&
|
||||
variableDefaults[key] !== undefined &&
|
||||
String(variableDefaults[key]).trim() !== ''
|
||||
) {
|
||||
val = String(variableDefaults[key]);
|
||||
}
|
||||
return val ?? '';
|
||||
});
|
||||
|
||||
return sanitizeFileName(resolved);
|
||||
}
|
||||
|
||||
/** 解析变量过滤规则(仅 variable 目标) */
|
||||
export function resolveVariableFilterRules(rules?: VisibilityRule[]): VisibilityRule[] {
|
||||
return (rules ?? [])
|
||||
.map((rule) => ({
|
||||
target: rule.target ?? 'variable',
|
||||
variable: (rule.variable ?? '').trim(),
|
||||
operator: rule.operator,
|
||||
value: rule.value,
|
||||
enabled: rule.enabled,
|
||||
}))
|
||||
.filter((rule) => rule.target === 'variable' && rule.variable);
|
||||
}
|
||||
|
||||
/** 数据行是否满足全部变量过滤条件 */
|
||||
export function rowMatchesVariableFilterRules(
|
||||
row: RecordRow,
|
||||
rowIndex: number,
|
||||
rules: VisibilityRule[],
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): boolean {
|
||||
const activeRules = getActiveVisibilityRules(rules);
|
||||
if (activeRules.length === 0) return true;
|
||||
return activeRules.every((rule) =>
|
||||
evaluateVisibilityRule(rule, row, variableDefaults, 'output', undefined, rowIndex)
|
||||
);
|
||||
}
|
||||
|
||||
export function isExportImageFilterConditional(
|
||||
template: Pick<LabelTemplate, 'exportImageFilterMode'>
|
||||
): boolean {
|
||||
return template.exportImageFilterMode === 'conditional';
|
||||
}
|
||||
|
||||
export function resolveExportImageFilterRules(
|
||||
template: Pick<LabelTemplate, 'exportImageFilterRules'>
|
||||
): VisibilityRule[] {
|
||||
return resolveVariableFilterRules(template.exportImageFilterRules);
|
||||
}
|
||||
|
||||
/** 批量导出图片时,当前数据行是否应导出 */
|
||||
export function shouldExportLabelImage(
|
||||
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
|
||||
label: PrintableLabel,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): boolean {
|
||||
if (!isExportImageFilterConditional(template)) return true;
|
||||
const rules = resolveExportImageFilterRules(template);
|
||||
return rowMatchesVariableFilterRules(label.row, label.sourceIndex, rules, variableDefaults);
|
||||
}
|
||||
|
||||
export function filterLabelsForImageExport(
|
||||
printablePages: (PrintableLabel | null)[][],
|
||||
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
|
||||
variableDefaults?: Record<string, string> | null
|
||||
): PrintableLabel[] {
|
||||
return flattenPrintableLabelsForExport(printablePages).filter((label) =>
|
||||
shouldExportLabelImage(template, label, variableDefaults)
|
||||
);
|
||||
}
|
||||
|
||||
function flattenPrintableLabelsForExport(
|
||||
printablePages: (PrintableLabel | null)[][]
|
||||
): PrintableLabel[] {
|
||||
const labels: PrintableLabel[] = [];
|
||||
for (const page of printablePages) {
|
||||
for (const item of page) {
|
||||
if (item) labels.push(item);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/** 格式化单个数值用于文本显示 */
|
||||
export function formatDisplayValue(
|
||||
value: string,
|
||||
@@ -818,41 +1232,65 @@ export function applyTextSplitSegment(
|
||||
}
|
||||
|
||||
/** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */
|
||||
export function getTextExtractMode(elem: Pick<
|
||||
TemplateElement,
|
||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'
|
||||
>): TextExtractMode {
|
||||
if (elem.textExtractMode !== undefined) return elem.textExtractMode;
|
||||
const delimiter = elem.textSplitDelimiter?.trim();
|
||||
const splitIndex = elem.textSplitIndex ?? 0;
|
||||
export function getTextExtractMode(
|
||||
config: Pick<ValueExtractConfig, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'>
|
||||
): TextExtractMode {
|
||||
if (config.textExtractMode !== undefined) return config.textExtractMode;
|
||||
const delimiter = config.textSplitDelimiter?.trim();
|
||||
const splitIndex = config.textSplitIndex ?? 0;
|
||||
if (delimiter && splitIndex >= 1) return 'split';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** 值提取配置是否生效 */
|
||||
export function valueExtractConfigHasExtract(config: ValueExtractConfig): boolean {
|
||||
const mode = getTextExtractMode(config);
|
||||
if (mode === 'split') {
|
||||
const delimiter = config.textSplitDelimiter?.trim();
|
||||
const index = config.textSplitIndex ?? 0;
|
||||
return !!(delimiter && index >= 1);
|
||||
}
|
||||
if (mode === 'prefix' || mode === 'suffix') {
|
||||
return (config.textExtractLength ?? 0) >= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 值提取配置的可读摘要 */
|
||||
export function formatValueExtractSummary(config: ValueExtractConfig): string | null {
|
||||
if (!valueExtractConfigHasExtract(config)) return null;
|
||||
const mode = getTextExtractMode(config);
|
||||
let base: string;
|
||||
if (mode === 'split') {
|
||||
const delimiter = config.textSplitDelimiter?.trim() ?? '';
|
||||
const index = config.textSplitIndex ?? 1;
|
||||
base = `按「${delimiter}」第 ${index} 段`;
|
||||
} else if (mode === 'prefix') {
|
||||
base = `前 ${config.textExtractLength ?? 1} 位`;
|
||||
} else {
|
||||
base = `后 ${config.textExtractLength ?? 1} 位`;
|
||||
}
|
||||
return config.textExtractInverse ? `反选 ${base}` : `提取 ${base}`;
|
||||
}
|
||||
|
||||
/** 按配置提取变量值片段 */
|
||||
export function applyTextExtract(
|
||||
value: string,
|
||||
elem: Pick<
|
||||
TemplateElement,
|
||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
|
||||
>
|
||||
): string {
|
||||
export function applyTextExtract(value: string, config: ValueExtractConfig): string {
|
||||
const trimmed = value.trim();
|
||||
const mode = getTextExtractMode(elem);
|
||||
const mode = getTextExtractMode(config);
|
||||
switch (mode) {
|
||||
case 'split': {
|
||||
const delimiter = elem.textSplitDelimiter?.trim();
|
||||
const index = elem.textSplitIndex ?? 0;
|
||||
const delimiter = config.textSplitDelimiter?.trim();
|
||||
const index = config.textSplitIndex ?? 0;
|
||||
if (!delimiter || index < 1) return trimmed;
|
||||
return applyTextSplitSegment(trimmed, delimiter, index);
|
||||
}
|
||||
case 'prefix': {
|
||||
const n = elem.textExtractLength ?? 0;
|
||||
const n = config.textExtractLength ?? 0;
|
||||
if (n < 1) return trimmed;
|
||||
return trimmed.slice(0, n);
|
||||
}
|
||||
case 'suffix': {
|
||||
const n = elem.textExtractLength ?? 0;
|
||||
const n = config.textExtractLength ?? 0;
|
||||
if (n < 1) return trimmed;
|
||||
return trimmed.slice(-n);
|
||||
}
|
||||
@@ -861,6 +1299,44 @@ export function applyTextExtract(
|
||||
}
|
||||
}
|
||||
|
||||
/** 按配置应用值提取(含反选) */
|
||||
export function applyValueExtractConfig(value: string, config: ValueExtractConfig): string {
|
||||
if (!valueExtractConfigHasExtract(config)) return value;
|
||||
if (config.textExtractInverse) {
|
||||
return applyTextExtractInverse(value, { type: 'text', ...config });
|
||||
}
|
||||
return applyTextExtract(value, config);
|
||||
}
|
||||
|
||||
function removeFirstOccurrence(value: string, needle: string): string {
|
||||
if (!needle) return value;
|
||||
const index = value.indexOf(needle);
|
||||
if (index === -1) return value;
|
||||
return value.slice(0, index) + value.slice(index + needle.length);
|
||||
}
|
||||
|
||||
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||
export function applyTextExtractInverse(
|
||||
value: string,
|
||||
elem: Pick<
|
||||
ValueExtractConfig,
|
||||
| 'textExtractMode'
|
||||
| 'textSplitDelimiter'
|
||||
| 'textSplitIndex'
|
||||
| 'textExtractLength'
|
||||
| 'textDisplayPrefix'
|
||||
| 'textDisplaySuffix'
|
||||
> & { type?: TemplateElement['type'] }
|
||||
): string {
|
||||
const full = value.trim();
|
||||
const extracted = applyTextExtract(full, elem);
|
||||
const needle =
|
||||
elem.type === 'text' || elem.textDisplayPrefix || elem.textDisplaySuffix
|
||||
? applyTextDisplayAffix(extracted, elem)
|
||||
: extracted;
|
||||
return removeFirstOccurrence(full, needle);
|
||||
}
|
||||
|
||||
/** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */
|
||||
export function elementHasValueExtract(
|
||||
elem: Pick<
|
||||
@@ -868,16 +1344,7 @@ export function elementHasValueExtract(
|
||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
|
||||
>
|
||||
): boolean {
|
||||
const mode = getTextExtractMode(elem);
|
||||
if (mode === 'split') {
|
||||
const delimiter = elem.textSplitDelimiter?.trim();
|
||||
const index = elem.textSplitIndex ?? 0;
|
||||
return !!(delimiter && index >= 1);
|
||||
}
|
||||
if (mode === 'prefix' || mode === 'suffix') {
|
||||
return (elem.textExtractLength ?? 0) >= 1;
|
||||
}
|
||||
return false;
|
||||
return valueExtractConfigHasExtract(elem);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 elementHasValueExtract */
|
||||
@@ -944,6 +1411,11 @@ export function getVariableRawValue(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 图层是否在画布与输出中显示 */
|
||||
export function isElementLayerVisible(elem: TemplateElement): boolean {
|
||||
return elem.hidden !== true;
|
||||
}
|
||||
|
||||
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
||||
export function shouldRenderElement(
|
||||
elem: TemplateElement,
|
||||
@@ -952,11 +1424,15 @@ export function shouldRenderElement(
|
||||
defaults?: Record<string, string> | null,
|
||||
mode: ContentResolveMode = 'output'
|
||||
): boolean {
|
||||
if (!isElementLayerVisible(elem)) return false;
|
||||
|
||||
if (!isConditionalVisibility(elem)) return true;
|
||||
|
||||
const rules = resolveVisibilityRules(elem);
|
||||
if (rules.length > 0) {
|
||||
return rules.every((rule) =>
|
||||
const activeRules = getActiveVisibilityRules(rules);
|
||||
if (activeRules.length === 0) return true;
|
||||
return activeRules.every((rule) =>
|
||||
evaluateVisibilityRule(rule, row, defaults, mode, elem, rowIndex)
|
||||
);
|
||||
}
|
||||
@@ -984,7 +1460,9 @@ export function resolveElementCoreValue(
|
||||
let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
|
||||
|
||||
if (elementHasValueExtract(elem)) {
|
||||
resolved = applyTextExtract(resolved, elem);
|
||||
resolved = elem.textExtractInverse
|
||||
? applyTextExtractInverse(resolved, elem)
|
||||
: applyTextExtract(resolved, elem);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
@@ -1000,10 +1478,11 @@ export function resolveElementValue(
|
||||
): string {
|
||||
const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
|
||||
if (elem.type !== 'text') return core;
|
||||
if (elem.textExtractInverse && elementHasValueExtract(elem)) return core;
|
||||
return applyTextDisplayAffix(core, elem);
|
||||
}
|
||||
|
||||
/** 输出模式下条码/二维码/图片内容为空时不渲染 */
|
||||
/** 输出模式下条码/二维码/图片内容为空时不渲染(除非 showWhenContentEmpty 为 true) */
|
||||
export function isGraphicContentEmpty(
|
||||
elem: TemplateElement,
|
||||
value: string,
|
||||
@@ -1011,7 +1490,9 @@ export function isGraphicContentEmpty(
|
||||
): boolean {
|
||||
if (mode === 'design') return false;
|
||||
if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false;
|
||||
return !value || !String(value).trim();
|
||||
const empty = !value || !String(value).trim();
|
||||
if (!empty) return false;
|
||||
return !elem.showWhenContentEmpty;
|
||||
}
|
||||
|
||||
// Generate columns and rows based on paper dimensions, label dimensions, gaps, and margins
|
||||
|
||||
Reference in New Issue
Block a user