base-paint 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts.d.ts +62 -0
- package/dist/charts.d.ts.map +1 -0
- package/dist/charts.js +1677 -0
- package/dist/charts.js.map +1 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +7 -1
- package/dist/contract.js.map +1 -1
- package/dist/controls.d.ts +114 -0
- package/dist/controls.d.ts.map +1 -0
- package/dist/controls.js +724 -0
- package/dist/controls.js.map +1 -0
- package/dist/help.d.ts +24 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +604 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/spec/charts.d.ts +222 -0
- package/dist/spec/charts.d.ts.map +1 -0
- package/dist/spec/charts.js +35 -0
- package/dist/spec/charts.js.map +1 -0
- package/dist/spec/controls.d.ts +329 -0
- package/dist/spec/controls.d.ts.map +1 -0
- package/dist/spec/controls.js +119 -0
- package/dist/spec/controls.js.map +1 -0
- package/dist/spec/help.d.ts +353 -0
- package/dist/spec/help.d.ts.map +1 -0
- package/dist/spec/help.js +167 -0
- package/dist/spec/help.js.map +1 -0
- package/dist/spec/index.d.ts +40 -0
- package/dist/spec/index.d.ts.map +1 -0
- package/dist/spec/index.js +164 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/style.d.ts +46 -0
- package/dist/spec/style.d.ts.map +1 -0
- package/dist/spec/style.js +39 -0
- package/dist/spec/style.js.map +1 -0
- package/dist/spec/template.d.ts +363 -0
- package/dist/spec/template.d.ts.map +1 -0
- package/dist/spec/template.js +252 -0
- package/dist/spec/template.js.map +1 -0
- package/dist/spec/text.d.ts +157 -0
- package/dist/spec/text.d.ts.map +1 -0
- package/dist/spec/text.js +83 -0
- package/dist/spec/text.js.map +1 -0
- package/dist/style.d.ts +19 -0
- package/dist/style.d.ts.map +1 -1
- package/dist/style.js +923 -0
- package/dist/style.js.map +1 -1
- package/dist/template.d.ts +27 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +362 -0
- package/dist/template.js.map +1 -0
- package/dist/text.d.ts +19 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +448 -0
- package/dist/text.js.map +1 -0
- package/package.json +2 -2
package/dist/controls.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/** base-paint/controls:#76 控件层运行时(契约 §3.3/§6.3)。
|
|
2
|
+
*
|
|
3
|
+
* 落地冻结面 44 条里 #76 的 13 条 `pending`(10 个运行时出口 + 3 个类型出口):
|
|
4
|
+
* `copyText`/`createCopyRuntime`/`bindCopyAction`/`buildSharedHelpersJs`/
|
|
5
|
+
* `renderToast`/`createToastController`/`renderActionBar`/`renderStatusBadge`/
|
|
6
|
+
* `renderEmptyState`/`renderErrorReceipt`。签名逐字取 `src/spec/controls.ts`,本文件不改任何冻结值。
|
|
7
|
+
*
|
|
8
|
+
* 三条硬边界(逐条对齐 §3.3/§6.3,违反即红):
|
|
9
|
+
* 1. **无宿主 ≠ 无浏览器**(C-1):本层不依赖 DSH 宿主注入(无宿主 DOM 容器、无宿主全局),
|
|
10
|
+
* 浏览器能力一律经**显式端口**(`CopyPorts`/`ToastHostPort`/`CopyActionHostPort`)传入;
|
|
11
|
+
* 2. **模块代码不得触碰 DOM**(C-8/AC-7):本文件(编译进 `dist/controls.js`)的**代码**里不出现
|
|
12
|
+
* `document.`/`window.`/`navigator.`——DOM 只允许出现在 `buildSharedHelpersJs` **产出的 JS 文本**里
|
|
13
|
+
* (`SHARED_HELPERS_JS_RULE.domAllowed = true`),由 §6.3 追加验收的 dist 扫描断言钉死;
|
|
14
|
+
* 3. **零内联脚本**(C-2):渲染出的按钮只带 `ACTION_ID_ATTR` 与 `DEFAULT_DATA_ATTR`,
|
|
15
|
+
* 激活一律走 `bindCopyAction`(事件委派),产出 JS 必须是**经典 script** 作用域可跑的 IIFE。
|
|
16
|
+
*
|
|
17
|
+
* 文档未规定处的取值(本文显式记账,不留暗猜;契约 §3.3「文档无规定」项):
|
|
18
|
+
* - 各控件 HTML 结构/类名细节 → 只冻结「`ilife-` 前缀 + `CONTROL_STYLE_SECTIONS` 命名空间」;
|
|
19
|
+
* 逐区尺寸/色值 owner #75,本文件**不产**任何样式常量(数值一律读 `ACTION_BAR_DEFAULTS` 等冻结常量);
|
|
20
|
+
* - `copyText` 非空串失败的 `reason` → 通道 2 返回假值 = `'fallback-failed'`/通道 2 **同步抛错** =
|
|
21
|
+
* `'fallback-threw'`(只冻结了空串短路的 `'empty'`;两通道皆失败一律**不抛错**,§3.3);
|
|
22
|
+
* - `copyText` 的 toast 挂载与回调**先后** → 先挂反馈(失败徽章恒在,不被回调异常吞掉),再回调一次;
|
|
23
|
+
* - `copyText('', ports)` 的判定**次序** → 先校验 `ports`(非法即 `bad-input`)再判空串短路,即
|
|
24
|
+
* `copyText('', null)` 抛 `bad-input` 而非返回 `reason:'empty'`(次序文档无规定,本文定死);
|
|
25
|
+
* - `renderToast` 的 `actions` 数量上限 → 不截断(旧层 `slice(0, 2)` 未进冻结面);
|
|
26
|
+
* - `renderToast` 非法 `badge.type` → 回落 `'ok'`(旧层口径);
|
|
27
|
+
* - `renderActionBar` 的 `CopyButtonInput.text` 缺席 → 仍渲染按钮、不写 `DEFAULT_DATA_ATTR`(binder 靠
|
|
28
|
+
* `readDataText → undefined` 跳过);`text === ''` → 仍写 `data-t=""`(binder 送进空串短路,无反馈);
|
|
29
|
+
* 场景按钮 `kind` 取冻结 `ACTION_BAR_KINDS` **全量**(含 `ghost`),不以 §3.3 表格的「primary/red」为限;
|
|
30
|
+
* `format` 不参与渲染(序列化归 #77);
|
|
31
|
+
* - `renderStatusBadge` 的 `text === ''` → 视为缺省(旧层口径);非法 `status` 降级 `'empty'`(冻结语义);
|
|
32
|
+
* - `renderEmptyState` 的 `actionHtml` → **受信 HTML 透传**(契约同口径,调用方负责其内容安全),可含
|
|
33
|
+
* 内联 `onclick`;「零注入面」只约束 base-paint **自产**标记,不约束调用方透传的受信片段;
|
|
34
|
+
* - helpers JS 的反馈栈容量 → 恒取冻结 `TOAST_DEFAULTS.maxStack`/`mobileMaxStack`(按视口),**不读**
|
|
35
|
+
* 静态 `renderToast` 产出的 `data-max`(后者供页面自建 `createToastController` 读取,两栈互不相干);
|
|
36
|
+
* - `renderErrorReceipt` 的 `retryPrompt` → 修正重试按钮的**文案**(缺省 `修正重试`);该按钮**不带**
|
|
37
|
+
* `ACTION_ID_ATTR`——冻结输入面没有 retry actionId,不得自造 id(§6.3 红线 8),绑定归调用方;
|
|
38
|
+
* - `copyText` 判定通道 2 成功用**真值**(旧层 `_fbCopy(s) ? ok() : fail()` 同口径);对布尔返回值
|
|
39
|
+
* 与 `=== true` 等价,只影响调用方返回非布尔真值的情形;
|
|
40
|
+
* - **「≤820px 收窄为 3」是页面运行时行为,不是模块行为**(FX-76-2 总架构师裁定):模块侧
|
|
41
|
+
* `createToastController` 保持**宿主无关**(AC-7 不变,不读任何浏览器全局);收窄由**页面侧**
|
|
42
|
+
* `buildSharedHelpersJs` 的**产出文本**读 `matchMedia('(max-width: mobileMaxPx)')` 承担
|
|
43
|
+
* (`domAllowed`),或由调用方经 `ToastInput.maxStack` 显式施加。旧层 `window.matchMedia`
|
|
44
|
+
* (`base.js:96`)本就是页面侧代码。两侧分工见契约 §3.3「toast 移动端收窄的分工(FX-76-2)」。
|
|
45
|
+
*/
|
|
46
|
+
import { STYLE_PREFIX } from './style.js';
|
|
47
|
+
import { ACTION_BAR_DEFAULTS, ACTION_BAR_KINDS, ACTION_ID_ATTR, COPY_ACTION_IDS, COPY_TEXT_DEFAULTS, DEFAULT_DATA_ATTR, ESCAPE_HTML_CHARS, ESCAPE_HTML_ENTITIES, STATUS_DEFAULT_TEXT, STATUS_KINDS, TOAST_DEFAULTS, } from './spec/index.js';
|
|
48
|
+
/* ── 错误形态(§3.3:与既有 RenderError 并列、互不继承、一律抛出、不返空) ── */
|
|
49
|
+
/** 控件层错误。**不**从 `src/index.ts` 导出:`SPEC_FROZEN_SURFACE` 44 条内无该运行时条目,
|
|
50
|
+
* 调用方按 `name === 'ControlsError'` + `code` 判定(与 `fillTemplate` 的 `TemplateError` 同口径)。 */
|
|
51
|
+
export class ControlsError extends Error {
|
|
52
|
+
code;
|
|
53
|
+
constructor(code, message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'ControlsError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function badInput(message) {
|
|
60
|
+
throw new ControlsError('bad-input', message);
|
|
61
|
+
}
|
|
62
|
+
/* ── 转义(AC-14:唯一口径 = 冻结的 `ESCAPE_HTML_CHARS`/`ESCAPE_HTML_ENTITIES`) ── */
|
|
63
|
+
const ESCAPE_RE = new RegExp('[' + ESCAPE_HTML_CHARS.join('') + ']', 'g');
|
|
64
|
+
function esc(value) {
|
|
65
|
+
return value.replace(ESCAPE_RE, (ch) => ESCAPE_HTML_ENTITIES[ch] ?? ch);
|
|
66
|
+
}
|
|
67
|
+
/* ── 入参校验小件(失败行为恒为 `ControlsError` code `bad-input`) ── */
|
|
68
|
+
function assertPlainObject(value, field) {
|
|
69
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
70
|
+
badInput(field + ' 必须是对象');
|
|
71
|
+
}
|
|
72
|
+
function assertActionId(value, field) {
|
|
73
|
+
if (typeof value !== 'string' || value === '')
|
|
74
|
+
badInput(field + ' 必须是非空字符串');
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/** 零注入面:含内联事件处理器字段(`onclick`/`onClick`/任何 `on*`)的入参一律拒。 */
|
|
78
|
+
function assertNoInlineHandler(value, field) {
|
|
79
|
+
for (const key of Object.keys(value)) {
|
|
80
|
+
if (/^on/i.test(key))
|
|
81
|
+
badInput(field + ' 不得含内联事件处理器字段:' + key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function isStatusKind(value) {
|
|
85
|
+
return STATUS_KINDS.includes(value);
|
|
86
|
+
}
|
|
87
|
+
/* ── toast(`renderToast` + `createToastController`) ─────────────────── */
|
|
88
|
+
/** 图标字形:`TOAST_ICONS` 是**冻结的枚举名**(不是字形),字形属文档未规定项(旧层 `base.js:74` 同值)。 */
|
|
89
|
+
const TOAST_ICON_GLYPHS = Object.freeze({
|
|
90
|
+
copy: '📋',
|
|
91
|
+
ok: '✅',
|
|
92
|
+
warn: '⚠️',
|
|
93
|
+
danger: '❌',
|
|
94
|
+
info: '💡',
|
|
95
|
+
});
|
|
96
|
+
/** 徽章类型白名单:取冻结类型 `ToastBadge['type']` 的成员(非法值回落 `'ok'`,旧层口径)。 */
|
|
97
|
+
const TOAST_BADGE_TYPES = ['ok', 'warn', 'danger'];
|
|
98
|
+
/** 关闭按钮文案(文档无规定;沿用旧基线 `✓ 知道了`)。 */
|
|
99
|
+
const TOAST_CLOSE_LABEL = '✓ 知道了';
|
|
100
|
+
/** 关闭按钮的命名空间类(helpers JS 按 `prefix` 派生同名选择器,见 `buildSharedHelpersJs`)。 */
|
|
101
|
+
const TOAST_CLOSE_CLASS = 'toast-close';
|
|
102
|
+
/** toast 结构类名(**静态产出器与 helpers 运行时共用同一份**,故提为常量):
|
|
103
|
+
* `body` 包裹 `title-row`(+ 可选 detail),close 在 body 之外 —— 与旧层
|
|
104
|
+
* `.hm-toast-icon + .hm-toast-body(> .hm-toast-title-row + .hm-toast-detail) + .hm-toast-close`
|
|
105
|
+
* **同构**(旧层取证 `.scratch/t76/old-controls.md` §1.1;返修 W1 根因修)。
|
|
106
|
+
* 运行时详情类名沿用 `toast-title-detail`(既有 helpers 产出,不新增类名)。 */
|
|
107
|
+
const TOAST_BODY_CLASS = 'toast-body';
|
|
108
|
+
const TOAST_TITLE_ROW_CLASS = 'toast-title-row';
|
|
109
|
+
const TOAST_DETAIL_CLASS = 'toast-title-detail';
|
|
110
|
+
function positiveInt(value, fallback) {
|
|
111
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
112
|
+
}
|
|
113
|
+
/** 冻结签名:`renderToast(input: ToastInput): string`(`input.msg` 非字符串 → `bad-input`)。 */
|
|
114
|
+
export const renderToast = (input) => {
|
|
115
|
+
assertPlainObject(input, 'renderToast: input');
|
|
116
|
+
const msg = input.msg;
|
|
117
|
+
if (typeof msg !== 'string')
|
|
118
|
+
badInput('renderToast: input.msg 必须是字符串');
|
|
119
|
+
const toast = input;
|
|
120
|
+
const icon = typeof toast.icon === 'string' && toast.icon in TOAST_ICON_GLYPHS ? toast.icon : TOAST_DEFAULTS.defaultIcon;
|
|
121
|
+
const maxStack = positiveInt(toast.maxStack, TOAST_DEFAULTS.maxStack);
|
|
122
|
+
const head = ['<span class="' + STYLE_PREFIX + 'toast-icon" aria-hidden="true">' + TOAST_ICON_GLYPHS[icon] + '</span>'];
|
|
123
|
+
const titleRow = ['<span class="' + STYLE_PREFIX + 'toast-title">' + esc(msg) + '</span>'];
|
|
124
|
+
const badge = toast.badge;
|
|
125
|
+
if (badge !== undefined && badge !== null) {
|
|
126
|
+
assertPlainObject(badge, 'renderToast: input.badge');
|
|
127
|
+
if (typeof badge.text !== 'string')
|
|
128
|
+
badInput('renderToast: input.badge.text 必须是字符串');
|
|
129
|
+
const badgeType = TOAST_BADGE_TYPES.includes(badge.type) ? badge.type : 'ok';
|
|
130
|
+
titleRow.push('<span class="' + STYLE_PREFIX + 'toast-chip ' + STYLE_PREFIX + 'toast-chip-' + badgeType + '">' + esc(badge.text) + '</span>');
|
|
131
|
+
}
|
|
132
|
+
if (typeof toast.count === 'string' && toast.count !== '') {
|
|
133
|
+
titleRow.push('<span class="' + STYLE_PREFIX + 'toast-count">' + esc(toast.count) + '</span>');
|
|
134
|
+
}
|
|
135
|
+
const actions = toast.actions;
|
|
136
|
+
if (actions !== undefined && actions !== null) {
|
|
137
|
+
if (!Array.isArray(actions))
|
|
138
|
+
badInput('renderToast: input.actions 必须是数组');
|
|
139
|
+
actions.forEach((action, i) => {
|
|
140
|
+
const field = 'renderToast: input.actions[' + i + ']';
|
|
141
|
+
assertPlainObject(action, field);
|
|
142
|
+
assertNoInlineHandler(action, field);
|
|
143
|
+
const actionId = assertActionId(action.actionId, field + '.actionId');
|
|
144
|
+
if (typeof action.label !== 'string')
|
|
145
|
+
badInput(field + '.label 必须是字符串');
|
|
146
|
+
titleRow.push('<button type="button" class="' + STYLE_PREFIX + 'toast-act" ' + ACTION_ID_ATTR + '="' + esc(actionId) + '">' + esc(action.label) + '</button>');
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const body = ['<div class="' + STYLE_PREFIX + TOAST_TITLE_ROW_CLASS + '">' + titleRow.join('') + '</div>'];
|
|
150
|
+
if (typeof toast.detail === 'string' && toast.detail !== '') {
|
|
151
|
+
body.push('<div class="' + STYLE_PREFIX + 'toast-detail">' + esc(toast.detail) + '</div>');
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(toast.lines) && toast.lines.length > 0) {
|
|
154
|
+
body.push('<div class="' + STYLE_PREFIX + 'toast-lines">' + toast.lines.map((line) => esc(String(line))).join('<br>') + '</div>');
|
|
155
|
+
}
|
|
156
|
+
if (typeof toast.code === 'string' && toast.code !== '') {
|
|
157
|
+
body.push('<pre class="' + STYLE_PREFIX + 'toast-code">' + esc(toast.code) + '</pre>');
|
|
158
|
+
}
|
|
159
|
+
return ('<div class="' + STYLE_PREFIX + 'toast" role="' + TOAST_DEFAULTS.role + '" aria-live="' + TOAST_DEFAULTS.ariaLive + '" data-max="' + maxStack + '">'
|
|
160
|
+
+ head.join('')
|
|
161
|
+
+ '<div class="' + STYLE_PREFIX + TOAST_BODY_CLASS + '">' + body.join('') + '</div>'
|
|
162
|
+
+ '<button type="button" class="' + STYLE_PREFIX + TOAST_CLOSE_CLASS + '">' + TOAST_CLOSE_LABEL + '</button>'
|
|
163
|
+
+ '</div>');
|
|
164
|
+
};
|
|
165
|
+
/** 冻结签名:`createToastController(port: ToastHostPort): ToastController`。
|
|
166
|
+
*
|
|
167
|
+
* 堆叠口径(旧层 `stackCap()` 同语义):容量 = 栈内各条 `maxStack` 的**最大值**,空栈回落
|
|
168
|
+
* `TOAST_DEFAULTS.maxStack`;超容量 FIFO 挤出最旧;单条独立计时(`timeoutMs` 缺省 4500)。
|
|
169
|
+
* `flush()` 清栈;`dispose()` = `flush()` + 之后 `show()` 变 no-op(幂等)。 */
|
|
170
|
+
export const createToastController = (port) => {
|
|
171
|
+
assertPlainObject(port, 'createToastController: port');
|
|
172
|
+
if (typeof port.mount !== 'function')
|
|
173
|
+
badInput('createToastController: port.mount 必须是函数');
|
|
174
|
+
const entries = [];
|
|
175
|
+
let disposed = false;
|
|
176
|
+
const drop = (entry) => {
|
|
177
|
+
if (entry.timer !== null) {
|
|
178
|
+
clearTimeout(entry.timer);
|
|
179
|
+
entry.timer = null;
|
|
180
|
+
}
|
|
181
|
+
const index = entries.indexOf(entry);
|
|
182
|
+
if (index >= 0)
|
|
183
|
+
entries.splice(index, 1);
|
|
184
|
+
entry.handle.remove();
|
|
185
|
+
};
|
|
186
|
+
const capacity = () => {
|
|
187
|
+
let cap = 0;
|
|
188
|
+
for (const entry of entries)
|
|
189
|
+
if (entry.cap > cap)
|
|
190
|
+
cap = entry.cap;
|
|
191
|
+
return cap > 0 ? cap : TOAST_DEFAULTS.maxStack;
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
show(input) {
|
|
195
|
+
if (disposed)
|
|
196
|
+
return;
|
|
197
|
+
const html = renderToast(input);
|
|
198
|
+
const cap = positiveInt(input === null || input === undefined ? undefined : input.maxStack, TOAST_DEFAULTS.maxStack);
|
|
199
|
+
const timeoutMs = positiveInt(input === null || input === undefined ? undefined : input.timeoutMs, TOAST_DEFAULTS.timeoutMs);
|
|
200
|
+
const entry = { handle: port.mount(html), cap, timer: null };
|
|
201
|
+
entries.push(entry);
|
|
202
|
+
entry.timer = setTimeout(() => drop(entry), timeoutMs);
|
|
203
|
+
while (entries.length > capacity()) {
|
|
204
|
+
const oldest = entries[0];
|
|
205
|
+
if (oldest === undefined)
|
|
206
|
+
break;
|
|
207
|
+
drop(oldest);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
flush() {
|
|
211
|
+
for (const entry of [...entries])
|
|
212
|
+
drop(entry);
|
|
213
|
+
},
|
|
214
|
+
dispose() {
|
|
215
|
+
if (disposed)
|
|
216
|
+
return;
|
|
217
|
+
disposed = true;
|
|
218
|
+
for (const entry of [...entries])
|
|
219
|
+
drop(entry);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
/* ── copyText 双通道(Q13/AC-16①) ──────────────────────────────────── */
|
|
224
|
+
function assertCopyPorts(ports, field) {
|
|
225
|
+
if (ports === null || typeof ports !== 'object')
|
|
226
|
+
badInput(field + ': ports 必填');
|
|
227
|
+
if (typeof ports.fallback !== 'function')
|
|
228
|
+
badInput(field + ': ports.fallback 必须是函数');
|
|
229
|
+
const clipboard = ports.clipboard;
|
|
230
|
+
if (clipboard !== null && clipboard !== undefined && typeof clipboard.writeText !== 'function') {
|
|
231
|
+
badInput(field + ': ports.clipboard.writeText 必须是函数');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function pickCopyText(value, fallback) {
|
|
235
|
+
return typeof value === 'string' && value !== '' ? value : fallback;
|
|
236
|
+
}
|
|
237
|
+
/** 成功反馈:`opts.toast.ok` 逐字段覆盖(`silent` 只静默它,不静默失败徽章)。 */
|
|
238
|
+
function okToastInput(opts) {
|
|
239
|
+
const override = opts.toast === undefined || opts.toast === null ? undefined : opts.toast.ok;
|
|
240
|
+
const input = {
|
|
241
|
+
msg: pickCopyText(override?.msg, COPY_TEXT_DEFAULTS.okMessage),
|
|
242
|
+
detail: typeof override?.detail === 'string' ? override.detail : COPY_TEXT_DEFAULTS.okDetail,
|
|
243
|
+
};
|
|
244
|
+
return typeof override?.icon === 'string' ? { ...input, icon: override.icon } : input;
|
|
245
|
+
}
|
|
246
|
+
/** 失败反馈:徽章恒在(`badge.type` 恒 `danger`,文案取冻结的 `STATUS_DEFAULT_TEXT.danger`)。 */
|
|
247
|
+
function failToastInput(opts) {
|
|
248
|
+
const override = opts.toast === undefined || opts.toast === null ? undefined : opts.toast.fail;
|
|
249
|
+
return {
|
|
250
|
+
msg: pickCopyText(override?.msg, COPY_TEXT_DEFAULTS.failMessage),
|
|
251
|
+
detail: typeof override?.detail === 'string' ? override.detail : COPY_TEXT_DEFAULTS.failDetail,
|
|
252
|
+
icon: typeof override?.icon === 'string' ? override.icon : 'danger',
|
|
253
|
+
badge: { type: 'danger', text: STATUS_DEFAULT_TEXT.danger },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** 冻结签名:`copyText(text: string, ports: CopyPorts, opts?: CopyTextOptions): Promise<CopyTextOutcome>`。 */
|
|
257
|
+
export const copyText = async (text, ports, opts) => {
|
|
258
|
+
assertCopyPorts(ports, 'copyText');
|
|
259
|
+
if (typeof text !== 'string')
|
|
260
|
+
badInput('copyText: text 必须是字符串');
|
|
261
|
+
const options = opts === null || opts === undefined || typeof opts !== 'object' ? {} : opts;
|
|
262
|
+
if (COPY_TEXT_DEFAULTS.emptyTextShortCircuit && text === '') {
|
|
263
|
+
return { ok: false, channel: null, reason: 'empty' };
|
|
264
|
+
}
|
|
265
|
+
const mountFeedback = (input) => {
|
|
266
|
+
const host = ports.toast;
|
|
267
|
+
if (host === null || host === undefined)
|
|
268
|
+
return;
|
|
269
|
+
if (typeof host.mount !== 'function')
|
|
270
|
+
badInput('copyText: ports.toast.mount 必须是函数');
|
|
271
|
+
host.mount(renderToast(input));
|
|
272
|
+
};
|
|
273
|
+
const settle = (ok, channel, reason) => {
|
|
274
|
+
if (ok) {
|
|
275
|
+
if (options.silent !== true)
|
|
276
|
+
mountFeedback(okToastInput(options));
|
|
277
|
+
if (typeof options.onOk === 'function')
|
|
278
|
+
options.onOk(channel);
|
|
279
|
+
return { ok: true, channel };
|
|
280
|
+
}
|
|
281
|
+
if (COPY_TEXT_DEFAULTS.failBadgeAlwaysOn)
|
|
282
|
+
mountFeedback(failToastInput(options));
|
|
283
|
+
if (typeof options.onFail === 'function')
|
|
284
|
+
options.onFail(reason ?? 'unknown');
|
|
285
|
+
return { ok: false, channel: null, reason };
|
|
286
|
+
};
|
|
287
|
+
const clipboard = ports.clipboard ?? null;
|
|
288
|
+
if (clipboard !== null) {
|
|
289
|
+
try {
|
|
290
|
+
await clipboard.writeText(text);
|
|
291
|
+
return settle(true, 'clipboard');
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// 通道 1 失败 → 通道 2(不得只留 execCommand 的单通道,§3.3)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// 通道 2:**同步抛错同样不得逃出**(§3.3「两通道皆失败 → `{ok:false,…}`(不抛错)」+「失败徽章恒在」;
|
|
298
|
+
// 旧层 `_fbCopy` 整段 try/catch、失败返回 false 同口径)。只包住 `ports.fallback` 调用本身,
|
|
299
|
+
// 不包 `settle()`——否则反馈通道抛错会被误判成「通道 2 失败」而重复出徽章。
|
|
300
|
+
let fallbackOk = false;
|
|
301
|
+
try {
|
|
302
|
+
fallbackOk = Boolean(ports.fallback(text));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return settle(false, null, 'fallback-threw');
|
|
306
|
+
}
|
|
307
|
+
return fallbackOk ? settle(true, 'fallback') : settle(false, null, 'fallback-failed');
|
|
308
|
+
};
|
|
309
|
+
/** 冻结签名:`createCopyRuntime(ports: CopyPorts): CopyRuntime`。
|
|
310
|
+
*
|
|
311
|
+
* 除转发 `copyText` 外,本 runtime 记录自己挂载的反馈节点,`dispose()` 一并移除(幂等)。
|
|
312
|
+
* 「dispose 后仍可复制」是文档未规定处的取值:`dispose` 只回收反馈节点,不改端口语义。 */
|
|
313
|
+
export const createCopyRuntime = (ports) => {
|
|
314
|
+
assertCopyPorts(ports, 'createCopyRuntime');
|
|
315
|
+
const handles = new Set();
|
|
316
|
+
const host = ports.toast;
|
|
317
|
+
const wrapped = host === null || host === undefined
|
|
318
|
+
? ports
|
|
319
|
+
: {
|
|
320
|
+
clipboard: ports.clipboard,
|
|
321
|
+
fallback: ports.fallback,
|
|
322
|
+
toast: {
|
|
323
|
+
mount: (html) => {
|
|
324
|
+
const handle = host.mount(html);
|
|
325
|
+
handles.add(handle);
|
|
326
|
+
return {
|
|
327
|
+
remove: () => {
|
|
328
|
+
handles.delete(handle);
|
|
329
|
+
handle.remove();
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
copyText: (text, opts) => copyText(text, wrapped, opts),
|
|
337
|
+
dispose: () => {
|
|
338
|
+
for (const handle of [...handles])
|
|
339
|
+
handle.remove();
|
|
340
|
+
handles.clear();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
/* ── 复制接线(FX-3③/FX-17;#90 仅凭契约即可接线) ──────────────────── */
|
|
345
|
+
/** 冻结签名:`bindCopyAction(port, ports, opts?): { dispose(): void }`。
|
|
346
|
+
*
|
|
347
|
+
* - id 来源**只有** `port.listActionIds()`:不猜 id、不通配前缀、不订阅未列出的 id;
|
|
348
|
+
* - 重复 id 由 binder 自行去重(同一 id 只订阅一次);
|
|
349
|
+
* - 激活时读 `port.readDataText(actionId)`:非字符串(含 `undefined`)→ 跳过(不抛错、不产 toast),
|
|
350
|
+
* 非复制按钮(场景按钮)即由此跳过,不另设白名单;
|
|
351
|
+
* - 反馈一律走 `ports.toast`(`copyText` 内部),binder 不另出反馈;
|
|
352
|
+
* - **激活回调恒不产生未处理拒绝**(FX-76-6):`copyText` 正常路径的失败已在内部 `settle()` 处理,
|
|
353
|
+
* 这里只兜「逃出 `copyText` 的异常」(端口实现抛错/`ports.toast.mount` 抛错/回调抛错)——
|
|
354
|
+
* 契约 §3.3 明写 binder 不另出反馈,故不产第二份 UI 反馈,也不重入 `opts.onFail`(防重复回调);
|
|
355
|
+
* - `dispose()` 解绑全部已订阅 id 且幂等;解绑后激活不再触发复制。 */
|
|
356
|
+
export const bindCopyAction = (port, ports, opts) => {
|
|
357
|
+
assertPlainObject(port, 'bindCopyAction: port');
|
|
358
|
+
if (typeof port.listActionIds !== 'function')
|
|
359
|
+
badInput('bindCopyAction: port.listActionIds 必须是函数');
|
|
360
|
+
if (typeof port.readDataText !== 'function')
|
|
361
|
+
badInput('bindCopyAction: port.readDataText 必须是函数');
|
|
362
|
+
if (typeof port.onActivate !== 'function')
|
|
363
|
+
badInput('bindCopyAction: port.onActivate 必须是函数');
|
|
364
|
+
assertCopyPorts(ports, 'bindCopyAction');
|
|
365
|
+
const unbinds = [];
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
let disposed = false;
|
|
368
|
+
const list = port.listActionIds();
|
|
369
|
+
for (const actionId of Array.isArray(list) ? list : []) {
|
|
370
|
+
if (typeof actionId !== 'string' || actionId === '' || seen.has(actionId))
|
|
371
|
+
continue;
|
|
372
|
+
seen.add(actionId);
|
|
373
|
+
const off = port.onActivate(actionId, () => {
|
|
374
|
+
if (disposed)
|
|
375
|
+
return;
|
|
376
|
+
const text = port.readDataText(actionId);
|
|
377
|
+
if (typeof text !== 'string')
|
|
378
|
+
return;
|
|
379
|
+
// FX-76-6:`void` 丢弃结果,但**必须**接住 rejection(否则端口抛错 → unhandledRejection)。
|
|
380
|
+
// 反馈只经 `ports.toast`(§3.3),故此处不产反馈、不重入 `opts.onFail`。
|
|
381
|
+
void copyText(text, ports, opts).catch(() => { });
|
|
382
|
+
});
|
|
383
|
+
unbinds.push(typeof off === 'function' ? off : () => { });
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
dispose: () => {
|
|
387
|
+
if (disposed)
|
|
388
|
+
return;
|
|
389
|
+
disposed = true;
|
|
390
|
+
for (const off of unbinds)
|
|
391
|
+
off();
|
|
392
|
+
unbinds.length = 0;
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
/* ── 共享 JS 文本的唯一产出者(FX-2③/FX-18;#74 只消费) ─────────────── */
|
|
397
|
+
/** JS 字符串字面量(产出文本里嵌值唯一出口)。 */
|
|
398
|
+
function jsStr(value) {
|
|
399
|
+
return JSON.stringify(value);
|
|
400
|
+
}
|
|
401
|
+
function helpersPrefix(input) {
|
|
402
|
+
const prefix = input === undefined || input === null ? undefined : input.prefix;
|
|
403
|
+
return typeof prefix === 'string' && prefix !== '' ? prefix : STYLE_PREFIX;
|
|
404
|
+
}
|
|
405
|
+
function helpersDataAttr(input) {
|
|
406
|
+
const attr = input === undefined || input === null ? undefined : input.dataAttr;
|
|
407
|
+
return typeof attr === 'string' && attr !== '' ? attr : DEFAULT_DATA_ATTR;
|
|
408
|
+
}
|
|
409
|
+
/** 冻结签名:`buildSharedHelpersJs(input?: SharedHelpersInput): string`。
|
|
410
|
+
*
|
|
411
|
+
* 产出恒为**非空**、**经典 script** 作用域可跑的 IIFE(无 `import`/`export`/顶层 `await`),
|
|
412
|
+
* 逐项满足 `SHARED_HELPERS_JS_RULE`:自包含(不依赖其它脚本或既有全局)/幂等(判据**只落 DOM**:
|
|
413
|
+
* 标记属性 + `querySelector` 早退,**不**用全局哨兵)/允许页面侧 DOM(`document.*` 读取与事件绑定)/
|
|
414
|
+
* 不向 `window.<id>`/`globalThis.<id>` 赋值/不引 `node:`。
|
|
415
|
+
*
|
|
416
|
+
* 功能面(文档无规定,本文记账):① 幂等挂载点标记;② 事件委派 `[ACTION_ID_ATTR]` 点击 →
|
|
417
|
+
* 读 `dataAttr` 文本 → 复制(`navigator.clipboard` → `execCommand` 兜底);③ 反馈块
|
|
418
|
+
* **与 `renderToast` 同构**(`.toast` > `.toast-icon` + `.toast-body` > `.toast-title-row` + 可选
|
|
419
|
+
* `.toast-title-detail`,`.toast-close` 在 body 之外;W1 根因修 + F-c 图标),`prefix` 命名空间类,
|
|
420
|
+
* `maxStack`/`timeoutMs`/移动端收窄取冻结常量;④ 关闭按钮。
|
|
421
|
+
* 产出文本里的文案/数值**全部**取自冻结常量(不产第二份真相)。
|
|
422
|
+
* 注:`execCommand` 兜底用临时 textarea 的两个内联定位属性(`position`/`left`),属临时节点
|
|
423
|
+
* 定位而非控件样式,不构成第二份样式常量。 */
|
|
424
|
+
export const buildSharedHelpersJs = (input) => {
|
|
425
|
+
const prefix = helpersPrefix(input);
|
|
426
|
+
const dataAttr = helpersDataAttr(input);
|
|
427
|
+
const markerAttr = 'data-' + prefix.replace(/-+$/, '') + '-helpers';
|
|
428
|
+
const markerSelector = '[' + markerAttr + '="1"]';
|
|
429
|
+
const lines = [
|
|
430
|
+
'(function () {',
|
|
431
|
+
" 'use strict';",
|
|
432
|
+
' var MARKER_ATTR = ' + jsStr(markerAttr) + ';',
|
|
433
|
+
' var MARKER_SEL = ' + jsStr(markerSelector) + ';',
|
|
434
|
+
' var ACTION_ATTR = ' + jsStr(ACTION_ID_ATTR) + ';',
|
|
435
|
+
' var TEXT_ATTR = ' + jsStr(dataAttr) + ';',
|
|
436
|
+
' var STACK_CLASS = ' + jsStr(prefix + 'toast-stack') + ';',
|
|
437
|
+
' var TOAST_CLASS = ' + jsStr(prefix + 'toast') + ';',
|
|
438
|
+
' var ICON_CLASS = ' + jsStr(prefix + 'toast-icon') + ';',
|
|
439
|
+
' var ICON_GLYPH = ' + jsStr(TOAST_ICON_GLYPHS[TOAST_DEFAULTS.defaultIcon]) + ';',
|
|
440
|
+
' var TITLE_CLASS = ' + jsStr(prefix + 'toast-title') + ';',
|
|
441
|
+
' var BODY_CLASS = ' + jsStr(prefix + TOAST_BODY_CLASS) + ';',
|
|
442
|
+
' var TITLE_ROW_CLASS = ' + jsStr(prefix + TOAST_TITLE_ROW_CLASS) + ';',
|
|
443
|
+
' var DETAIL_CLASS = ' + jsStr(prefix + TOAST_DETAIL_CLASS) + ';',
|
|
444
|
+
' var CLOSE_CLASS = ' + jsStr(prefix + TOAST_CLOSE_CLASS) + ';',
|
|
445
|
+
' var OK_MSG = ' + jsStr(COPY_TEXT_DEFAULTS.okMessage) + ';',
|
|
446
|
+
' var FAIL_MSG = ' + jsStr(COPY_TEXT_DEFAULTS.failMessage) + ';',
|
|
447
|
+
' var FAIL_DETAIL = ' + jsStr(COPY_TEXT_DEFAULTS.failDetail) + ';',
|
|
448
|
+
' var CLOSE_LABEL = ' + jsStr(TOAST_CLOSE_LABEL) + ';',
|
|
449
|
+
' var TIMEOUT_MS = ' + TOAST_DEFAULTS.timeoutMs + ';',
|
|
450
|
+
' var MAX_STACK = ' + TOAST_DEFAULTS.maxStack + ';',
|
|
451
|
+
' var MOBILE_MAX_STACK = ' + TOAST_DEFAULTS.mobileMaxStack + ';',
|
|
452
|
+
' var MOBILE_MAX_PX = ' + TOAST_DEFAULTS.mobileMaxPx + ';',
|
|
453
|
+
'',
|
|
454
|
+
' function boot() {',
|
|
455
|
+
' if (!document.body) return;',
|
|
456
|
+
' if (document.querySelector(MARKER_SEL)) return;',
|
|
457
|
+
' var marker = document.createElement("span");',
|
|
458
|
+
' marker.setAttribute(MARKER_ATTR, "1");',
|
|
459
|
+
' marker.hidden = true;',
|
|
460
|
+
' document.body.appendChild(marker);',
|
|
461
|
+
' document.addEventListener("click", onClick);',
|
|
462
|
+
' }',
|
|
463
|
+
'',
|
|
464
|
+
' function onClick(ev) {',
|
|
465
|
+
' var node = ev.target;',
|
|
466
|
+
' if (!node || typeof node.closest !== "function") return;',
|
|
467
|
+
' var close = node.closest("." + CLOSE_CLASS);',
|
|
468
|
+
' if (close) {',
|
|
469
|
+
' var box = close.parentNode;',
|
|
470
|
+
' if (box && box.parentNode) box.parentNode.removeChild(box);',
|
|
471
|
+
' return;',
|
|
472
|
+
' }',
|
|
473
|
+
' var btn = node.closest("[" + ACTION_ATTR + "]");',
|
|
474
|
+
' if (!btn) return;',
|
|
475
|
+
' var text = btn.getAttribute(TEXT_ATTR);',
|
|
476
|
+
' if (text === null) return;',
|
|
477
|
+
' copy(text);',
|
|
478
|
+
' }',
|
|
479
|
+
'',
|
|
480
|
+
' function copy(text) {',
|
|
481
|
+
' var clip = navigator.clipboard;',
|
|
482
|
+
' if (clip && typeof clip.writeText === "function") {',
|
|
483
|
+
' try {',
|
|
484
|
+
' var done = clip.writeText(text);',
|
|
485
|
+
' if (done && typeof done.then === "function") {',
|
|
486
|
+
' done.then(function () { feedback(OK_MSG, false); }, function () { fallback(text); });',
|
|
487
|
+
' return;',
|
|
488
|
+
' }',
|
|
489
|
+
' feedback(OK_MSG, false);',
|
|
490
|
+
' return;',
|
|
491
|
+
' } catch (err) {',
|
|
492
|
+
' fallback(text);',
|
|
493
|
+
' return;',
|
|
494
|
+
' }',
|
|
495
|
+
' }',
|
|
496
|
+
' fallback(text);',
|
|
497
|
+
' }',
|
|
498
|
+
'',
|
|
499
|
+
' function fallback(text) {',
|
|
500
|
+
' var sink = document.createElement("textarea");',
|
|
501
|
+
' sink.value = text;',
|
|
502
|
+
' sink.setAttribute("readonly", "readonly");',
|
|
503
|
+
' sink.setAttribute("aria-hidden", "true");',
|
|
504
|
+
' sink.style.position = "fixed";',
|
|
505
|
+
' sink.style.left = "-9999px";',
|
|
506
|
+
' document.body.appendChild(sink);',
|
|
507
|
+
' sink.select();',
|
|
508
|
+
' var done = false;',
|
|
509
|
+
' try { done = document.execCommand("copy"); } catch (err) { done = false; }',
|
|
510
|
+
' if (sink.parentNode) sink.parentNode.removeChild(sink);',
|
|
511
|
+
' feedback(done ? OK_MSG : FAIL_MSG, !done);',
|
|
512
|
+
' }',
|
|
513
|
+
'',
|
|
514
|
+
' function feedback(msg, bad) {',
|
|
515
|
+
' var cap = MAX_STACK;',
|
|
516
|
+
' if (window.matchMedia && window.matchMedia("(max-width: " + MOBILE_MAX_PX + "px)").matches) cap = MOBILE_MAX_STACK;',
|
|
517
|
+
' var host = document.querySelector("." + STACK_CLASS);',
|
|
518
|
+
' if (!host) {',
|
|
519
|
+
' host = document.createElement("div");',
|
|
520
|
+
' host.className = STACK_CLASS;',
|
|
521
|
+
' document.body.appendChild(host);',
|
|
522
|
+
' }',
|
|
523
|
+
' var box = document.createElement("div");',
|
|
524
|
+
' box.className = bad ? TOAST_CLASS + " " + TOAST_CLASS + "-danger" : TOAST_CLASS;',
|
|
525
|
+
' box.setAttribute("role", ' + jsStr(TOAST_DEFAULTS.role) + ');',
|
|
526
|
+
' box.setAttribute("aria-live", ' + jsStr(TOAST_DEFAULTS.ariaLive) + ');',
|
|
527
|
+
// W1 根因修(结构对齐)+ F-c 图标:运行时 DOM 与静态产出器/旧层同构——
|
|
528
|
+
// box > .toast-icon + .toast-body(> .toast-title-row(> .toast-title) + 可选 .toast-title-detail) + .toast-close
|
|
529
|
+
// 旧层为 `.hm-toast-icon + .hm-toast-body(> .hm-toast-title-row + .hm-toast-detail) + .hm-toast-close`
|
|
530
|
+
// (`.scratch/t76/old-controls.md` §1.1)。图标字形取 `TOAST_ICON_GLYPHS[TOAST_DEFAULTS.defaultIcon]`
|
|
531
|
+
// (📋,旧层 `base.js:74`/契约 `:141` 缺省;与静态产出器 `renderToast` 同源,不产第二份真相)。
|
|
532
|
+
// 缺 body 包裹时标题/详情/关闭被排进同一 flex 行,
|
|
533
|
+
// 靠 `flex-wrap` + `flex:1 1 100%` 补丁才勉强分层,且把关闭按钮挤到第三行(R1 实测 113px 高)。
|
|
534
|
+
// F-c 自证:新增仅用 `document.createElement/className/setAttribute/textContent/appendChild`
|
|
535
|
+
// (`SHARED_HELPERS_JS_RULE.domAllowed=true` 允许 DOM 读取;未向 `window/globalThis` 赋值、
|
|
536
|
+
// 未引 `node:`、未用 `classList/show`),故不破坏 #76 冻结产出内容契约与 T28 纯 CSS 动效断言。
|
|
537
|
+
' var icon = document.createElement("span");',
|
|
538
|
+
' icon.className = ICON_CLASS;',
|
|
539
|
+
' icon.setAttribute("aria-hidden", "true");',
|
|
540
|
+
' icon.textContent = ICON_GLYPH;',
|
|
541
|
+
' var body = document.createElement("div");',
|
|
542
|
+
' body.className = BODY_CLASS;',
|
|
543
|
+
' var titleRow = document.createElement("div");',
|
|
544
|
+
' titleRow.className = TITLE_ROW_CLASS;',
|
|
545
|
+
' var title = document.createElement("span");',
|
|
546
|
+
' title.className = TITLE_CLASS;',
|
|
547
|
+
' title.textContent = msg;',
|
|
548
|
+
' titleRow.appendChild(title);',
|
|
549
|
+
' body.appendChild(titleRow);',
|
|
550
|
+
' if (bad) {',
|
|
551
|
+
' var detail = document.createElement("div");',
|
|
552
|
+
' detail.className = DETAIL_CLASS;',
|
|
553
|
+
' detail.textContent = FAIL_DETAIL;',
|
|
554
|
+
' body.appendChild(detail);',
|
|
555
|
+
' }',
|
|
556
|
+
' box.appendChild(icon);',
|
|
557
|
+
' box.appendChild(body);',
|
|
558
|
+
' var close = document.createElement("button");',
|
|
559
|
+
' close.type = "button";',
|
|
560
|
+
' close.className = CLOSE_CLASS;',
|
|
561
|
+
' close.textContent = CLOSE_LABEL;',
|
|
562
|
+
' box.appendChild(close);',
|
|
563
|
+
' host.appendChild(box);',
|
|
564
|
+
' while (host.children.length > cap) host.removeChild(host.children[0]);',
|
|
565
|
+
' setTimeout(function () {',
|
|
566
|
+
' if (box.parentNode) box.parentNode.removeChild(box);',
|
|
567
|
+
' }, TIMEOUT_MS);',
|
|
568
|
+
' }',
|
|
569
|
+
'',
|
|
570
|
+
' if (document.body) boot();',
|
|
571
|
+
' else document.addEventListener("DOMContentLoaded", boot);',
|
|
572
|
+
'}());',
|
|
573
|
+
];
|
|
574
|
+
return lines.join(String.fromCharCode(10));
|
|
575
|
+
};
|
|
576
|
+
function normalizeButtons(buttons) {
|
|
577
|
+
if (buttons === undefined || buttons === null)
|
|
578
|
+
return [];
|
|
579
|
+
if (!Array.isArray(buttons))
|
|
580
|
+
badInput('renderActionBar: input.buttons 必须是数组');
|
|
581
|
+
return buttons.map((button, i) => {
|
|
582
|
+
const field = 'renderActionBar: input.buttons[' + i + ']';
|
|
583
|
+
assertPlainObject(button, field);
|
|
584
|
+
assertNoInlineHandler(button, field);
|
|
585
|
+
if (typeof button.label !== 'string' || button.label === '')
|
|
586
|
+
badInput(field + '.label 必须是非空字符串');
|
|
587
|
+
if (!ACTION_BAR_KINDS.includes(button.kind)) {
|
|
588
|
+
badInput(field + '.kind 必须是 ' + ACTION_BAR_KINDS.join('/'));
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
label: button.label,
|
|
592
|
+
kind: button.kind,
|
|
593
|
+
actionId: assertActionId(button.actionId, field + '.actionId'),
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
function normalizeCopyButton(input, fallbackLabel, field) {
|
|
598
|
+
if (input === undefined || input === null)
|
|
599
|
+
return null;
|
|
600
|
+
assertPlainObject(input, 'renderActionBar: input.' + field);
|
|
601
|
+
assertNoInlineHandler(input, 'renderActionBar: input.' + field);
|
|
602
|
+
return {
|
|
603
|
+
label: typeof input.label === 'string' && input.label !== '' ? input.label : fallbackLabel,
|
|
604
|
+
actionId: assertActionId(input.actionId, 'renderActionBar: input.' + field + '.actionId'),
|
|
605
|
+
text: typeof input.text === 'string' ? input.text : undefined,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function sceneButtonHtml(button) {
|
|
609
|
+
return '<button type="button" class="' + STYLE_PREFIX + 'action-btn ' + STYLE_PREFIX + 'action-btn-' + button.kind + '" '
|
|
610
|
+
+ ACTION_ID_ATTR + '="' + esc(button.actionId) + '">' + esc(button.label) + '</button>';
|
|
611
|
+
}
|
|
612
|
+
function copyButtonHtml(button) {
|
|
613
|
+
const textAttr = button.text === undefined ? '' : ' ' + DEFAULT_DATA_ATTR + '="' + esc(button.text) + '"';
|
|
614
|
+
return '<button type="button" class="' + STYLE_PREFIX + 'copy-btn ' + STYLE_PREFIX + 'copy-btn-ghost" '
|
|
615
|
+
+ ACTION_ID_ATTR + '="' + esc(button.actionId) + '"' + textAttr + '>' + esc(button.label) + '</button>';
|
|
616
|
+
}
|
|
617
|
+
/** 冻结签名:`renderActionBar(input: ActionBarInput): string`。
|
|
618
|
+
*
|
|
619
|
+
* 场景按钮(`primary`/`red`/`ghost`)一行;复制数据/复制日志 ghost 按钮**独立一行**
|
|
620
|
+
* (`ACTION_BAR_DEFAULTS.ghostOwnRow`);复制文本渲染期写入 `DEFAULT_DATA_ATTR`、id 写入 `ACTION_ID_ATTR`,
|
|
621
|
+
* 零内联脚本。缺 `actionId`/空串/同次渲染内重复 → `ControlsError` code `bad-input`。
|
|
622
|
+
* 逐区尺寸(`minHeightPx`/`fontSizePx`/`fontWeight`/`ghostBorderAlpha`/`evenRowPairs`)由 #75 的
|
|
623
|
+
* 共享样式区消费(本文件不产样式常量)。 */
|
|
624
|
+
export const renderActionBar = (input) => {
|
|
625
|
+
assertPlainObject(input, 'renderActionBar: input');
|
|
626
|
+
const bar = input;
|
|
627
|
+
const buttons = normalizeButtons(bar.buttons);
|
|
628
|
+
const copyData = normalizeCopyButton(bar.copyData, ACTION_BAR_DEFAULTS.copyDataLabel, 'copyData');
|
|
629
|
+
const copyLog = normalizeCopyButton(bar.copyLog, ACTION_BAR_DEFAULTS.copyLogLabel, 'copyLog');
|
|
630
|
+
const ids = new Set();
|
|
631
|
+
for (const button of buttons) {
|
|
632
|
+
if (ids.has(button.actionId))
|
|
633
|
+
badInput('renderActionBar: actionId 同次渲染内重复:' + button.actionId);
|
|
634
|
+
ids.add(button.actionId);
|
|
635
|
+
}
|
|
636
|
+
for (const copy of [copyData, copyLog]) {
|
|
637
|
+
if (copy === null)
|
|
638
|
+
continue;
|
|
639
|
+
if (ids.has(copy.actionId))
|
|
640
|
+
badInput('renderActionBar: actionId 同次渲染内重复:' + copy.actionId);
|
|
641
|
+
ids.add(copy.actionId);
|
|
642
|
+
}
|
|
643
|
+
const sceneHtml = buttons.map(sceneButtonHtml).join('');
|
|
644
|
+
const ghostHtml = [copyData, copyLog].filter((copy) => copy !== null).map(copyButtonHtml).join('');
|
|
645
|
+
const rowClass = STYLE_PREFIX + 'action-row';
|
|
646
|
+
const ghostRowClass = rowClass + ' ' + STYLE_PREFIX + 'action-row-ghost';
|
|
647
|
+
const rows = [];
|
|
648
|
+
if (sceneHtml !== '' && ghostHtml !== '' && !ACTION_BAR_DEFAULTS.ghostOwnRow) {
|
|
649
|
+
rows.push('<div class="' + rowClass + '">' + sceneHtml + ghostHtml + '</div>');
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
if (sceneHtml !== '')
|
|
653
|
+
rows.push('<div class="' + rowClass + '">' + sceneHtml + '</div>');
|
|
654
|
+
if (ghostHtml !== '')
|
|
655
|
+
rows.push('<div class="' + ghostRowClass + '">' + ghostHtml + '</div>');
|
|
656
|
+
}
|
|
657
|
+
return '<div class="' + STYLE_PREFIX + 'action-bar">' + rows.join('') + '</div>';
|
|
658
|
+
};
|
|
659
|
+
/* ── 状态三控件 ───────────────────────────────────────────────────────── */
|
|
660
|
+
/** 冻结签名:`renderStatusBadge(input: StatusBadgeInput): string`。
|
|
661
|
+
* 非法 `status` 降级 `'empty'`(不抛错,防无样式徽章);`text` 缺省/空串取 `STATUS_DEFAULT_TEXT`。 */
|
|
662
|
+
export const renderStatusBadge = (input) => {
|
|
663
|
+
const badge = input === null || input === undefined || typeof input !== 'object' ? undefined : input;
|
|
664
|
+
const status = badge !== undefined && isStatusKind(badge.status) ? badge.status : 'empty';
|
|
665
|
+
const text = badge === undefined ? undefined : badge.text;
|
|
666
|
+
const label = typeof text === 'string' && text !== '' ? text : STATUS_DEFAULT_TEXT[status];
|
|
667
|
+
return '<span class="' + STYLE_PREFIX + 'status-badge ' + STYLE_PREFIX + 'status-badge-' + status + '">' + esc(label) + '</span>';
|
|
668
|
+
};
|
|
669
|
+
/** 冻结签名:`renderEmptyState(input: EmptyStateInput): string`。
|
|
670
|
+
* `icon`/`text`/`hint` 一律转义;`actionHtml` 受信 HTML 透传(不转义);`text` 缺失/非字符串 → `bad-input`。 */
|
|
671
|
+
export const renderEmptyState = (input) => {
|
|
672
|
+
assertPlainObject(input, 'renderEmptyState: input');
|
|
673
|
+
const state = input;
|
|
674
|
+
if (typeof state.text !== 'string')
|
|
675
|
+
badInput('renderEmptyState: input.text 必须是字符串');
|
|
676
|
+
const parts = [];
|
|
677
|
+
if (typeof state.icon === 'string' && state.icon !== '') {
|
|
678
|
+
parts.push('<div class="' + STYLE_PREFIX + 'empty-icon">' + esc(state.icon) + '</div>');
|
|
679
|
+
}
|
|
680
|
+
parts.push('<div class="' + STYLE_PREFIX + 'empty-text">' + esc(state.text) + '</div>');
|
|
681
|
+
if (typeof state.hint === 'string' && state.hint !== '') {
|
|
682
|
+
parts.push('<div class="' + STYLE_PREFIX + 'empty-hint">' + esc(state.hint) + '</div>');
|
|
683
|
+
}
|
|
684
|
+
if (typeof state.actionHtml === 'string' && state.actionHtml !== '') {
|
|
685
|
+
parts.push('<div class="' + STYLE_PREFIX + 'empty-action">' + state.actionHtml + '</div>');
|
|
686
|
+
}
|
|
687
|
+
return '<div class="' + STYLE_PREFIX + 'empty">' + parts.join('') + '</div>';
|
|
688
|
+
};
|
|
689
|
+
/** 修正重试按钮缺省文案(文档无规定;沿用旧基线)。 */
|
|
690
|
+
const ERROR_RETRY_LABEL = '修正重试';
|
|
691
|
+
/** 错误标题前缀(文档无规定;沿用旧基线 `❌ `)。 */
|
|
692
|
+
const ERROR_TITLE_PREFIX = '❌ ';
|
|
693
|
+
/** 冻结签名:`renderErrorReceipt(input: ErrorReceiptInput): string`。
|
|
694
|
+
* 缺 `dataText`/`logText` → **不渲染**对应复制按钮(容错,不抛错);id 缺省取
|
|
695
|
+
* `COPY_ACTION_IDS.errorReceipt.*`;**不读** `window.__hmPayload` 之类旧全局(AC-7)。 */
|
|
696
|
+
export const renderErrorReceipt = (input) => {
|
|
697
|
+
assertPlainObject(input, 'renderErrorReceipt: input');
|
|
698
|
+
const receipt = input;
|
|
699
|
+
if (typeof receipt.message !== 'string')
|
|
700
|
+
badInput('renderErrorReceipt: input.message 必须是字符串');
|
|
701
|
+
const retryLabel = typeof receipt.retryPrompt === 'string' && receipt.retryPrompt !== '' ? receipt.retryPrompt : ERROR_RETRY_LABEL;
|
|
702
|
+
const actions = [
|
|
703
|
+
'<button type="button" class="' + STYLE_PREFIX + 'copy-btn ' + STYLE_PREFIX + 'copy-btn-primary ' + STYLE_PREFIX + 'copy-btn-wide">'
|
|
704
|
+
+ esc(retryLabel) + '</button>',
|
|
705
|
+
];
|
|
706
|
+
const ids = new Set();
|
|
707
|
+
const pushCopy = (text, rawId, fallbackId, label, field) => {
|
|
708
|
+
if (typeof text !== 'string')
|
|
709
|
+
return;
|
|
710
|
+
const actionId = rawId === undefined ? fallbackId : assertActionId(rawId, 'renderErrorReceipt: input.' + field);
|
|
711
|
+
if (ids.has(actionId))
|
|
712
|
+
badInput('renderErrorReceipt: actionId 同次渲染内重复:' + actionId);
|
|
713
|
+
ids.add(actionId);
|
|
714
|
+
actions.push('<button type="button" class="' + STYLE_PREFIX + 'copy-btn ' + STYLE_PREFIX + 'copy-btn-ghost" '
|
|
715
|
+
+ ACTION_ID_ATTR + '="' + esc(actionId) + '" ' + DEFAULT_DATA_ATTR + '="' + esc(text) + '">' + esc(label) + '</button>');
|
|
716
|
+
};
|
|
717
|
+
pushCopy(receipt.dataText, receipt.dataActionId, COPY_ACTION_IDS.errorReceipt.copyData, ACTION_BAR_DEFAULTS.copyDataLabel, 'dataActionId');
|
|
718
|
+
pushCopy(receipt.logText, receipt.logActionId, COPY_ACTION_IDS.errorReceipt.copyLog, ACTION_BAR_DEFAULTS.copyLogLabel, 'logActionId');
|
|
719
|
+
return '<div class="' + STYLE_PREFIX + 'error">'
|
|
720
|
+
+ '<div class="' + STYLE_PREFIX + 'error-title">' + esc(ERROR_TITLE_PREFIX + receipt.message) + '</div>'
|
|
721
|
+
+ '<div class="' + STYLE_PREFIX + 'error-actions">' + actions.join('') + '</div>'
|
|
722
|
+
+ '</div>';
|
|
723
|
+
};
|
|
724
|
+
//# sourceMappingURL=controls.js.map
|