finch-session-board 0.0.0-stage → 0.1.2
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/README.md +48 -2
- package/dist/canvas.js +1083 -0
- package/dist/index.js +1134 -0
- package/finch.json +41 -0
- package/i18n/zh-CN.json +37 -0
- package/icon.png +0 -0
- package/package.json +21 -5
- package/skills/session-board/SKILL.md +76 -0
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
// finchapp/shared/utils/env.js
|
|
3
|
+
/** 开发档为 "yes"(build.js --dev 与 dev.js 注入),正式档为 "no";字符串而非布尔,注入缺失时能一眼看出没钉上。 */
|
|
4
|
+
function isDev() {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
// finchapp/shared/utils/time.js
|
|
8
|
+
const MONTH_SECONDS = 2592e3;
|
|
9
|
+
/**
|
|
10
|
+
* 毫秒时长只说最大那个单位,不做「1分钟-5秒」这种叠加。
|
|
11
|
+
* 一行放得下比精确更要紧,秒级的零头在列表上看不出意义。
|
|
12
|
+
* 单位用口语完整的写法(分钟、小时),不缩成单字。
|
|
13
|
+
* @param {number} ms 时长毫秒;非数字与负数都当 0
|
|
14
|
+
* @returns {string} 例如 "1天"、"3小时"、"15分钟"
|
|
15
|
+
*/
|
|
16
|
+
function formatDuration(ms) {
|
|
17
|
+
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1e3));
|
|
18
|
+
const months = Math.floor(total / MONTH_SECONDS);
|
|
19
|
+
if (months > 0) return `${months}月`;
|
|
20
|
+
const days = Math.floor(total / 86400);
|
|
21
|
+
if (days > 0) return `${days}天`;
|
|
22
|
+
const hours = Math.floor(total / 3600);
|
|
23
|
+
if (hours > 0) return `${hours}小时`;
|
|
24
|
+
const minutes = Math.floor(total / 60);
|
|
25
|
+
if (minutes > 0) return `${minutes}分钟`;
|
|
26
|
+
return `${total}秒`;
|
|
27
|
+
}
|
|
28
|
+
// finchapp/sessionBoard/src/icons.js
|
|
29
|
+
/**
|
|
30
|
+
* 色调→颜色。色调只表达"要不要紧":
|
|
31
|
+
* 蓝=只读工作,紫=会改文件,玫红=会动系统,橙=等你动手,黄绿=系统在自己处理,绿红=两种收场。
|
|
32
|
+
* 橙与黄绿差 60 度:等你确认要抢视线,重试和打断不该跟它抢。
|
|
33
|
+
*/
|
|
34
|
+
const TONE_COLORS = {
|
|
35
|
+
gray: "#6b7280",
|
|
36
|
+
blue: "#3b82f6",
|
|
37
|
+
purple: "#8b5cf6",
|
|
38
|
+
rose: "#ec4899",
|
|
39
|
+
orange: "#f97316",
|
|
40
|
+
lime: "#84cc16",
|
|
41
|
+
green: "#22c55e",
|
|
42
|
+
red: "#ef4444"
|
|
43
|
+
};
|
|
44
|
+
/** 圆角矩形路径,已在当前路径上追加,调用方自己决定 fill 还是 stroke。 */
|
|
45
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
46
|
+
const radius = Math.min(r, w / 2, h / 2);
|
|
47
|
+
ctx.beginPath();
|
|
48
|
+
ctx.moveTo(x + radius, y);
|
|
49
|
+
ctx.lineTo(x + w - radius, y);
|
|
50
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
|
51
|
+
ctx.lineTo(x + w, y + h - radius);
|
|
52
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
|
53
|
+
ctx.lineTo(x + radius, y + h);
|
|
54
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
|
55
|
+
ctx.lineTo(x, y + radius);
|
|
56
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
57
|
+
ctx.closePath();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 按图标名画一个图标,中心落在 (cx, cy)。
|
|
61
|
+
* 每个图标的坐标都按 14px 的框手写死,不随调用方缩放——这样所有图标的视觉重量才一致。
|
|
62
|
+
* 每个图标的画法都贴着"一眼能认",而不是"像素级还原"——同一行右侧还有文字兜底。
|
|
63
|
+
* 线条色与填充色都用调用方传进来的 ctx.strokeStyle / fillStyle。
|
|
64
|
+
* @param {CanvasRenderingContext2D} ctx 画布上下文
|
|
65
|
+
* @param {string} name 图标名,见 src/sessions.js 的 PHASE_ICON 与 TOOL_RULES
|
|
66
|
+
* @param {number} cx 中心横坐标
|
|
67
|
+
* @param {number} cy 中心纵坐标
|
|
68
|
+
*/
|
|
69
|
+
function paintIcon(ctx, name, cx, cy) {
|
|
70
|
+
ctx.beginPath();
|
|
71
|
+
ctx.lineWidth = 2;
|
|
72
|
+
ctx.lineCap = "round";
|
|
73
|
+
ctx.lineJoin = "round";
|
|
74
|
+
switch (name) {
|
|
75
|
+
case "hourglass":
|
|
76
|
+
ctx.moveTo(cx - 4, cy - 5);
|
|
77
|
+
ctx.lineTo(cx + 4, cy - 5);
|
|
78
|
+
ctx.lineTo(cx, cy - .5);
|
|
79
|
+
ctx.closePath();
|
|
80
|
+
ctx.fill();
|
|
81
|
+
ctx.beginPath();
|
|
82
|
+
ctx.moveTo(cx - 4, cy + 5);
|
|
83
|
+
ctx.lineTo(cx + 4, cy + 5);
|
|
84
|
+
ctx.lineTo(cx, cy + .5);
|
|
85
|
+
ctx.closePath();
|
|
86
|
+
ctx.fill();
|
|
87
|
+
return;
|
|
88
|
+
case "bubble":
|
|
89
|
+
ctx.lineWidth = 2;
|
|
90
|
+
roundRect(ctx, cx - 5.2, cy - 4.6, 10.4, 8.2, 2.4);
|
|
91
|
+
ctx.stroke();
|
|
92
|
+
ctx.beginPath();
|
|
93
|
+
ctx.moveTo(cx - 2.6, cy + 3.4);
|
|
94
|
+
ctx.lineTo(cx - 3.8, cy + 6);
|
|
95
|
+
ctx.lineTo(cx - .4, cy + 3.4);
|
|
96
|
+
ctx.stroke();
|
|
97
|
+
return;
|
|
98
|
+
case "file":
|
|
99
|
+
ctx.moveTo(cx - 4, cy - 5);
|
|
100
|
+
ctx.lineTo(cx + 1.4, cy - 5);
|
|
101
|
+
ctx.lineTo(cx + 4, cy - 2.6);
|
|
102
|
+
ctx.lineTo(cx + 4, cy + 5);
|
|
103
|
+
ctx.lineTo(cx - 4, cy + 5);
|
|
104
|
+
ctx.closePath();
|
|
105
|
+
ctx.stroke();
|
|
106
|
+
return;
|
|
107
|
+
case "magnifier":
|
|
108
|
+
ctx.arc(cx - .8, cy - 1.4, 3.9, 0, Math.PI * 2);
|
|
109
|
+
ctx.stroke();
|
|
110
|
+
ctx.beginPath();
|
|
111
|
+
ctx.moveTo(cx + 2, cy + 1.6);
|
|
112
|
+
ctx.lineTo(cx + 5.4, cy + 5);
|
|
113
|
+
ctx.stroke();
|
|
114
|
+
return;
|
|
115
|
+
case "globe":
|
|
116
|
+
ctx.arc(cx, cy, 5.2, 0, Math.PI * 2);
|
|
117
|
+
ctx.stroke();
|
|
118
|
+
ctx.beginPath();
|
|
119
|
+
ctx.moveTo(cx - 5.2, cy);
|
|
120
|
+
ctx.lineTo(cx + 5.2, cy);
|
|
121
|
+
ctx.stroke();
|
|
122
|
+
ctx.beginPath();
|
|
123
|
+
ctx.ellipse(cx, cy, 2.2, 5.2, 0, 0, Math.PI * 2);
|
|
124
|
+
ctx.stroke();
|
|
125
|
+
return;
|
|
126
|
+
case "pencil":
|
|
127
|
+
ctx.moveTo(cx - 6, cy + 5.5);
|
|
128
|
+
ctx.lineTo(cx - 3.6, cy + 1);
|
|
129
|
+
ctx.lineTo(cx + 2.4, cy - 5);
|
|
130
|
+
ctx.lineTo(cx + 5.2, cy - 2.2);
|
|
131
|
+
ctx.lineTo(cx - .9, cy + 3.9);
|
|
132
|
+
ctx.closePath();
|
|
133
|
+
ctx.fill();
|
|
134
|
+
return;
|
|
135
|
+
case "terminal":
|
|
136
|
+
ctx.moveTo(cx - 5, cy - 3.5);
|
|
137
|
+
ctx.lineTo(cx - 1.5, cy);
|
|
138
|
+
ctx.lineTo(cx - 5, cy + 3.5);
|
|
139
|
+
ctx.stroke();
|
|
140
|
+
ctx.beginPath();
|
|
141
|
+
ctx.moveTo(cx - .5, cy + 3.5);
|
|
142
|
+
ctx.lineTo(cx + 5, cy + 3.5);
|
|
143
|
+
ctx.stroke();
|
|
144
|
+
return;
|
|
145
|
+
case "branch":
|
|
146
|
+
ctx.beginPath();
|
|
147
|
+
ctx.moveTo(cx - 2.5, cy + 4);
|
|
148
|
+
ctx.lineTo(cx - 2.5, cy - 1.5);
|
|
149
|
+
ctx.stroke();
|
|
150
|
+
ctx.beginPath();
|
|
151
|
+
ctx.moveTo(cx - 2.5, cy + .8);
|
|
152
|
+
ctx.quadraticCurveTo(cx - 2.5, cy - 4, cx + 2.6, cy - 4);
|
|
153
|
+
ctx.stroke();
|
|
154
|
+
ctx.beginPath();
|
|
155
|
+
ctx.arc(cx - 2.5, cy + 4.6, 1.6, 0, Math.PI * 2);
|
|
156
|
+
ctx.fill();
|
|
157
|
+
ctx.beginPath();
|
|
158
|
+
ctx.arc(cx + 2.6, cy - 4, 1.6, 0, Math.PI * 2);
|
|
159
|
+
ctx.fill();
|
|
160
|
+
return;
|
|
161
|
+
case "check":
|
|
162
|
+
ctx.lineWidth = 2.4;
|
|
163
|
+
ctx.beginPath();
|
|
164
|
+
ctx.moveTo(cx - 4.4, cy + .2);
|
|
165
|
+
ctx.lineTo(cx - 1.4, cy + 3.6);
|
|
166
|
+
ctx.lineTo(cx + 4.6, cy - 3.8);
|
|
167
|
+
ctx.stroke();
|
|
168
|
+
return;
|
|
169
|
+
case "cross":
|
|
170
|
+
ctx.lineWidth = 2.4;
|
|
171
|
+
ctx.beginPath();
|
|
172
|
+
ctx.moveTo(cx - 4.2, cy - 4.2);
|
|
173
|
+
ctx.lineTo(cx + 4.2, cy + 4.2);
|
|
174
|
+
ctx.moveTo(cx + 4.2, cy - 4.2);
|
|
175
|
+
ctx.lineTo(cx - 4.2, cy + 4.2);
|
|
176
|
+
ctx.stroke();
|
|
177
|
+
return;
|
|
178
|
+
case "bang":
|
|
179
|
+
ctx.lineWidth = 2.4;
|
|
180
|
+
ctx.beginPath();
|
|
181
|
+
ctx.moveTo(cx, cy - 5);
|
|
182
|
+
ctx.lineTo(cx, cy + 1.3);
|
|
183
|
+
ctx.stroke();
|
|
184
|
+
ctx.beginPath();
|
|
185
|
+
ctx.arc(cx, cy + 4.4, 1.3, 0, Math.PI * 2);
|
|
186
|
+
ctx.fill();
|
|
187
|
+
return;
|
|
188
|
+
case "loop":
|
|
189
|
+
ctx.lineWidth = 2.2;
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
ctx.arc(cx, cy, 4.6, Math.PI * .35, Math.PI * 1.85);
|
|
192
|
+
ctx.stroke();
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
ctx.moveTo(cx + 4.2, cy - 4);
|
|
195
|
+
ctx.lineTo(cx + 1.2, cy - 3.3);
|
|
196
|
+
ctx.lineTo(cx + 3.3, cy - .9);
|
|
197
|
+
ctx.closePath();
|
|
198
|
+
ctx.fill();
|
|
199
|
+
return;
|
|
200
|
+
case "stop":
|
|
201
|
+
roundRect(ctx, cx - 4.8, cy - 4.8, 9.6, 9.6, 2.2);
|
|
202
|
+
ctx.fill();
|
|
203
|
+
return;
|
|
204
|
+
case "slashbox":
|
|
205
|
+
ctx.lineWidth = 2;
|
|
206
|
+
roundRect(ctx, cx - 5, cy - 5, 10, 10, 2);
|
|
207
|
+
ctx.stroke();
|
|
208
|
+
ctx.beginPath();
|
|
209
|
+
ctx.moveTo(cx - 3.6, cy + 3.6);
|
|
210
|
+
ctx.lineTo(cx + 3.6, cy - 3.6);
|
|
211
|
+
ctx.stroke();
|
|
212
|
+
return;
|
|
213
|
+
case "collapse":
|
|
214
|
+
ctx.lineWidth = 2;
|
|
215
|
+
ctx.beginPath();
|
|
216
|
+
ctx.moveTo(cx - 4.6, cy - 3.4);
|
|
217
|
+
ctx.lineTo(cx - 2, cy);
|
|
218
|
+
ctx.lineTo(cx - 4.6, cy + 3.4);
|
|
219
|
+
ctx.stroke();
|
|
220
|
+
ctx.beginPath();
|
|
221
|
+
ctx.moveTo(cx + 4.6, cy - 3.4);
|
|
222
|
+
ctx.lineTo(cx + 2, cy);
|
|
223
|
+
ctx.lineTo(cx + 4.6, cy + 3.4);
|
|
224
|
+
ctx.stroke();
|
|
225
|
+
return;
|
|
226
|
+
default:
|
|
227
|
+
ctx.lineWidth = 2;
|
|
228
|
+
ctx.beginPath();
|
|
229
|
+
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
230
|
+
ctx.stroke();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 图标目录:预览面板按这个顺序铺图标墙。
|
|
235
|
+
* 每一项的色调就是它在列表里会用的颜色,所以预览看到的是真实配色。
|
|
236
|
+
*/
|
|
237
|
+
const ICON_CATALOG = [
|
|
238
|
+
{
|
|
239
|
+
name: "dot",
|
|
240
|
+
label: "空闲",
|
|
241
|
+
tone: "gray"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "hourglass",
|
|
245
|
+
label: "思考中",
|
|
246
|
+
tone: "blue"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "bubble",
|
|
250
|
+
label: "回答中",
|
|
251
|
+
tone: "blue"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "file",
|
|
255
|
+
label: "读取",
|
|
256
|
+
tone: "blue"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: "magnifier",
|
|
260
|
+
label: "查找",
|
|
261
|
+
tone: "blue"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "globe",
|
|
265
|
+
label: "联网",
|
|
266
|
+
tone: "blue"
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "branch",
|
|
270
|
+
label: "调度",
|
|
271
|
+
tone: "blue"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "collapse",
|
|
275
|
+
label: "压缩",
|
|
276
|
+
tone: "blue"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "pencil",
|
|
280
|
+
label: "改写",
|
|
281
|
+
tone: "purple"
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "terminal",
|
|
285
|
+
label: "执行命令",
|
|
286
|
+
tone: "rose"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "bang",
|
|
290
|
+
label: "等你确认",
|
|
291
|
+
tone: "orange"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "loop",
|
|
295
|
+
label: "重试中",
|
|
296
|
+
tone: "lime"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "stop",
|
|
300
|
+
label: "正在打断",
|
|
301
|
+
tone: "lime"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "slashbox",
|
|
305
|
+
label: "已打断",
|
|
306
|
+
tone: "lime"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "check",
|
|
310
|
+
label: "已完成",
|
|
311
|
+
tone: "green"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: "cross",
|
|
315
|
+
label: "出错",
|
|
316
|
+
tone: "red"
|
|
317
|
+
}
|
|
318
|
+
];
|
|
319
|
+
/** 色卡:预览底部一排色块,标注每个色调代表什么。 */
|
|
320
|
+
const TONE_CATALOG = [
|
|
321
|
+
{
|
|
322
|
+
tone: "blue",
|
|
323
|
+
label: "只读"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
tone: "purple",
|
|
327
|
+
label: "改文件"
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
tone: "rose",
|
|
331
|
+
label: "跑命令"
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
tone: "orange",
|
|
335
|
+
label: "等你"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
tone: "lime",
|
|
339
|
+
label: "中断"
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
tone: "green",
|
|
343
|
+
label: "完成"
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
tone: "red",
|
|
347
|
+
label: "出错"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
tone: "gray",
|
|
351
|
+
label: "空闲"
|
|
352
|
+
}
|
|
353
|
+
];
|
|
354
|
+
/**
|
|
355
|
+
* 由记录数算出窗口尺寸。
|
|
356
|
+
* 高度跟着条数走:一条都没有时只剩一条标题带,不留空行。
|
|
357
|
+
* 全篇只有 PAD 一个间隙值——标题带上下各一份、行间一份、列表底部一份,四处看起来才一样齐。
|
|
358
|
+
* @param {number} count 记录条数
|
|
359
|
+
* @returns {{ count: number, rows: number, rowH: number, width: number, height: number, overflow: number }}
|
|
360
|
+
*/
|
|
361
|
+
function layoutOf(count = 0) {
|
|
362
|
+
const total = Math.max(0, Math.floor(count) || 0);
|
|
363
|
+
const rows = Math.min(total, 8);
|
|
364
|
+
return {
|
|
365
|
+
count: total,
|
|
366
|
+
rows,
|
|
367
|
+
rowH: 22,
|
|
368
|
+
width: 240,
|
|
369
|
+
height: rows === 0 ? 32 : 42 + rows * 22,
|
|
370
|
+
overflow: total - rows
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** 预览墙每格的高与列数:三列铺 16 个图标,正好六行。 */
|
|
374
|
+
const PREVIEW_COLUMNS = 3;
|
|
375
|
+
const PREVIEW_CELL_H = 26;
|
|
376
|
+
/** 色卡每格的高与列数:八种色调铺两行。 */
|
|
377
|
+
const TONE_CELL_H = 24;
|
|
378
|
+
const TONE_COLUMNS = 4;
|
|
379
|
+
/**
|
|
380
|
+
* 图标预览模式的窗口尺寸。
|
|
381
|
+
* 高度由图标墙加色卡推出,和真实列表一样只有 PAD 一个间隙值。
|
|
382
|
+
* @returns {{ width: number, height: number }}
|
|
383
|
+
*/
|
|
384
|
+
function previewOf() {
|
|
385
|
+
return {
|
|
386
|
+
width: 240,
|
|
387
|
+
height: 266
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** 预览墙的格子尺寸,渲染层按它排图标。 */
|
|
391
|
+
const PREVIEW = {
|
|
392
|
+
columns: PREVIEW_COLUMNS,
|
|
393
|
+
cellH: PREVIEW_CELL_H,
|
|
394
|
+
toneCellH: TONE_CELL_H,
|
|
395
|
+
toneColumns: TONE_COLUMNS
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* 第 index 行的矩形。
|
|
399
|
+
* @param {{ rows: number, rowH: number }} layout 布局
|
|
400
|
+
* @param {number} index 行号
|
|
401
|
+
* @param {number} [top] 列表区顶边,渲染层按窗口实际高度算好传进来
|
|
402
|
+
* @returns {{ x: number, y: number, w: number, h: number }}
|
|
403
|
+
*/
|
|
404
|
+
function rowRect(layout, index, top = 32) {
|
|
405
|
+
return {
|
|
406
|
+
x: 10,
|
|
407
|
+
y: top + index * layout.rowH,
|
|
408
|
+
w: layout.width - 20,
|
|
409
|
+
h: layout.rowH
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* 命中测试:坐标落在第几行,没命中返回 -1。
|
|
414
|
+
* @param {{ rows: number, rowH: number, width: number }} layout 布局
|
|
415
|
+
* @param {number} x 画布内横坐标
|
|
416
|
+
* @param {number} y 画布内纵坐标
|
|
417
|
+
* @param {number} [top] 列表区顶边,与 rowRect 用同一个值
|
|
418
|
+
*/
|
|
419
|
+
function rowAt(layout, x, y, top = 32) {
|
|
420
|
+
if (x < 10 || x > layout.width - 10) return -1;
|
|
421
|
+
if (y < top || y >= top + layout.rows * layout.rowH) return -1;
|
|
422
|
+
return Math.floor((y - top) / layout.rowH);
|
|
423
|
+
}
|
|
424
|
+
// finchapp/sessionBoard/src/canvas.js
|
|
425
|
+
/** 画布 2D 上下文,init 时拿到;frame 每帧都用它重画。 */
|
|
426
|
+
let ctx2d = null;
|
|
427
|
+
/** 画布元素,只用来换鼠标指针形状。 */
|
|
428
|
+
let canvasEl = null;
|
|
429
|
+
/** 本画布实例的短码。同一时刻桌面上有几个看板窗口,靠这个字段数得出来。 */
|
|
430
|
+
const INSTANCE = Math.random().toString(36).slice(2, 8);
|
|
431
|
+
/**
|
|
432
|
+
* 色调→颜色、图标画法、圆角矩形都在 src/icons.js 里:
|
|
433
|
+
* 图标预览和真列表用同一份实现,改一处两边同时生效。
|
|
434
|
+
*/
|
|
435
|
+
/** 底板与文字的基础配色。底板不透明:底下是桌面,透出来会让字发灰、图标看不清。 */
|
|
436
|
+
const SURFACE = "#101116";
|
|
437
|
+
const SURFACE_BORDER = "rgba(255, 255, 255, 0.1)";
|
|
438
|
+
const TEXT_MAIN = "rgba(255, 255, 255, 0.92)";
|
|
439
|
+
const TEXT_DIM = "rgba(255, 255, 255, 0.45)";
|
|
440
|
+
const FONT_STACK = "\"Microsoft YaHei\", \"PingFang SC\", system-ui, sans-serif";
|
|
441
|
+
/** 会话标题最多显示几个字,超出直接截断加省略号。 */
|
|
442
|
+
const LABEL_MAX_CHARS = 8;
|
|
443
|
+
/** 状态变化时格子的脉冲时长。 */
|
|
444
|
+
const PULSE_MS = 220;
|
|
445
|
+
/** 当前快照。{ total, waiting, cells: [{ id, label, status, hint, phase, icon, tone, startedAt, endedAt }] } */
|
|
446
|
+
let board = {
|
|
447
|
+
total: 0,
|
|
448
|
+
waiting: 0,
|
|
449
|
+
cells: []
|
|
450
|
+
};
|
|
451
|
+
/** 当前布局,随记录条数变化。 */
|
|
452
|
+
let layout = layoutOf(0);
|
|
453
|
+
/** sessionId → 脉冲结束时刻。 */
|
|
454
|
+
const pulses = /* @__PURE__ */ new Map();
|
|
455
|
+
/** 画布尺寸,resize 时更新。 */
|
|
456
|
+
let viewWidth = layout.width;
|
|
457
|
+
let viewHeight = layout.height;
|
|
458
|
+
/** 上一次收到的画布尺寸,用来只在尺寸真变化时记日志。 */
|
|
459
|
+
let lastViewWidth = 0;
|
|
460
|
+
let lastViewHeight = 0;
|
|
461
|
+
/** 鼠标当前压在哪个标题栏按钮上,null 表示没有。只影响图标亮度。 */
|
|
462
|
+
let hoverButton = null;
|
|
463
|
+
/** 鼠标当前压在哪一行记录上,-1 表示没有。行底色靠它。 */
|
|
464
|
+
let hoverRow = -1;
|
|
465
|
+
/**
|
|
466
|
+
* 内容相对窗口的横向偏移。
|
|
467
|
+
* 窗口实际比布局宽几像素(系统给透明窗口加的边框),平分到左右,右边才不会空出一条。
|
|
468
|
+
*/
|
|
469
|
+
function contentOffset() {
|
|
470
|
+
return { x: Math.round((viewWidth - layout.width) / 2) };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* 列表区顶边。
|
|
474
|
+
* 窗口实际高度会比布局多出几个像素(透明窗口的系统边框),
|
|
475
|
+
* 把行块放进「标题带以下的剩余空间」居中,上下的空白就都等于 PAD。
|
|
476
|
+
*/
|
|
477
|
+
function rowTopOf() {
|
|
478
|
+
if (!layout.rows) return 32;
|
|
479
|
+
return 32 + Math.max(0, (viewHeight - 32 - layout.rows * layout.rowH) / 2);
|
|
480
|
+
}
|
|
481
|
+
/** 上一帧的绘制时间,用于空闲降频。 */
|
|
482
|
+
let lastPaint = 0;
|
|
483
|
+
/** 首帧是否已经报过启动信息。init 阶段消息通道还没就绪,日志发不出去,只能等第一帧。 */
|
|
484
|
+
let booted = false;
|
|
485
|
+
/** 状态有变化、下一帧必须重画。 */
|
|
486
|
+
let dirty = true;
|
|
487
|
+
/** 按下时的位置与命中的格子,用来区分"点击"和"拖动"。 */
|
|
488
|
+
let pointerDown = null;
|
|
489
|
+
/** 拖动状态:按下那一刻的鼠标屏幕坐标与窗口屏幕坐标,之后的位移都相对它算。 */
|
|
490
|
+
let dragging = null;
|
|
491
|
+
/** 文案。由 Host 通过 locale 消息下发,默认值保证独立打开也不算空。 */
|
|
492
|
+
let strings = {
|
|
493
|
+
total: "会话",
|
|
494
|
+
waiting: "等待",
|
|
495
|
+
empty: "没有运行中的会话",
|
|
496
|
+
preview: "图标预览"
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* 图标预览模式:整窗铺一张图标墙加色卡,用来看 14px 下的实际观感。
|
|
500
|
+
* 只是显示开关,不进快照、不改任何会话数据。
|
|
501
|
+
*/
|
|
502
|
+
let preview = false;
|
|
503
|
+
/** 预览模式下窗口该多大。 */
|
|
504
|
+
const previewSize = () => ({
|
|
505
|
+
...layoutOf(0),
|
|
506
|
+
...previewOf()
|
|
507
|
+
});
|
|
508
|
+
/** 把文字压到指定宽度,超了截断加省略号。 */
|
|
509
|
+
function fitText(ctx, text, maxWidth) {
|
|
510
|
+
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
511
|
+
const chars = Array.from(text);
|
|
512
|
+
while (chars.length > 1) {
|
|
513
|
+
chars.pop();
|
|
514
|
+
const candidate = `${chars.join("")}…`;
|
|
515
|
+
if (ctx.measureText(candidate).width <= maxWidth) return candidate;
|
|
516
|
+
}
|
|
517
|
+
return "…";
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* 按字数截断文本,超出部分换成省略号。
|
|
521
|
+
* 用字数不用像素:标题长度参差不齐时,固定字数比固定宽度更好对齐。
|
|
522
|
+
* @param {string} text 原文
|
|
523
|
+
* @param {number} maxChars 最多留几个字
|
|
524
|
+
* @returns {string} 截断后的文本
|
|
525
|
+
*/
|
|
526
|
+
function clipText(text, maxChars) {
|
|
527
|
+
const chars = Array.from(String(text ?? ""));
|
|
528
|
+
if (chars.length <= maxChars) return chars.join("");
|
|
529
|
+
return `${chars.slice(0, maxChars).join("")}…`;
|
|
530
|
+
}
|
|
531
|
+
/** 把窗口尺寸钉死在这块画布的大小上:min 和 max 相等,系统就没有边缘拉伸的余地。 */
|
|
532
|
+
function lockSize(width, height) {
|
|
533
|
+
finch.window.setResizable(false);
|
|
534
|
+
finch.window.setMinimumSize(width, height);
|
|
535
|
+
finch.window.setMaximumSize(width, height);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 把画布内的状态回传给 Host。
|
|
539
|
+
* 这段脚本跑在浏览器外壳里,宿主进程看不到它的异常和尺寸变化,只能靠消息桥送出去。
|
|
540
|
+
*/
|
|
541
|
+
function report(level, message, data) {
|
|
542
|
+
try {
|
|
543
|
+
finch.postMessage({
|
|
544
|
+
type: "log",
|
|
545
|
+
level,
|
|
546
|
+
message,
|
|
547
|
+
data
|
|
548
|
+
});
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
/** 取鼠标的屏幕坐标;外壳没给 screenX/Y 时用窗口原点加窗口内坐标兜底。 */
|
|
552
|
+
function screenPointOf(event) {
|
|
553
|
+
return {
|
|
554
|
+
x: typeof event.screenX === "number" ? event.screenX : window.screenX + event.x,
|
|
555
|
+
y: typeof event.screenY === "number" ? event.screenY : window.screenY + event.y
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** 画右上角的点阵把手,暗示这里可以拖动窗口。 */
|
|
559
|
+
function paintGrip(ctx, x, cy) {
|
|
560
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
|
|
561
|
+
for (let row = 0; row < 3; row += 1) for (let column = 0; column < 2; column += 1) {
|
|
562
|
+
ctx.beginPath();
|
|
563
|
+
ctx.arc(x + column * 4, cy - 4 + row * 4, 1, 0, Math.PI * 2);
|
|
564
|
+
ctx.fill();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* 画一条记录,单行三段:最左状态图标,中间一句话,最右会话标题。
|
|
569
|
+
* 中间那句话由归并器给出:有动作念动作,没动作念阶段名,一行只有一处文字。
|
|
570
|
+
*/
|
|
571
|
+
function paintRow(ctx, rect, item, now) {
|
|
572
|
+
const color = TONE_COLORS[item.tone] ?? TONE_COLORS.gray;
|
|
573
|
+
const pulseUntil = pulses.get(item.id) ?? 0;
|
|
574
|
+
const pulse = pulseUntil > now ? 1 - (pulseUntil - now) / PULSE_MS : 0;
|
|
575
|
+
const centerY = rect.y + rect.h / 2;
|
|
576
|
+
const iconX = rect.x + 11;
|
|
577
|
+
ctx.save();
|
|
578
|
+
if (pulse > 0) {
|
|
579
|
+
const scale = 1 + .18 * pulse;
|
|
580
|
+
ctx.translate(iconX, centerY);
|
|
581
|
+
ctx.scale(scale, scale);
|
|
582
|
+
ctx.translate(-iconX, -centerY);
|
|
583
|
+
}
|
|
584
|
+
ctx.fillStyle = color;
|
|
585
|
+
ctx.strokeStyle = color;
|
|
586
|
+
paintIcon(ctx, item.icon, iconX, centerY);
|
|
587
|
+
ctx.restore();
|
|
588
|
+
const contentLeft = rect.x + 26;
|
|
589
|
+
const contentRight = rect.x + rect.w - 2;
|
|
590
|
+
ctx.textBaseline = "middle";
|
|
591
|
+
const title = clipText(item.label ?? "", LABEL_MAX_CHARS);
|
|
592
|
+
ctx.textAlign = "right";
|
|
593
|
+
ctx.font = `400 10px ${FONT_STACK}`;
|
|
594
|
+
const titleWidth = title ? ctx.measureText(title).width : 0;
|
|
595
|
+
if (title) {
|
|
596
|
+
ctx.fillStyle = TEXT_DIM;
|
|
597
|
+
ctx.fillText(title, contentRight, centerY);
|
|
598
|
+
}
|
|
599
|
+
ctx.textAlign = "left";
|
|
600
|
+
ctx.font = `500 12px ${FONT_STACK}`;
|
|
601
|
+
ctx.fillStyle = TEXT_MAIN;
|
|
602
|
+
const usable = Math.max(30, contentRight - contentLeft - (titleWidth ? titleWidth + 12 : 0));
|
|
603
|
+
const text = fitText(ctx, item.text || item.status, usable);
|
|
604
|
+
ctx.fillText(text, contentLeft, centerY);
|
|
605
|
+
const elapsed = formatDuration((item.endedAt || Date.now()) - item.startedAt);
|
|
606
|
+
const textWidth = ctx.measureText(text).width;
|
|
607
|
+
const elapsedWidth = usable - textWidth - 8;
|
|
608
|
+
if (elapsedWidth >= 20) {
|
|
609
|
+
ctx.font = `400 10px ${FONT_STACK}`;
|
|
610
|
+
ctx.fillStyle = TEXT_DIM;
|
|
611
|
+
ctx.fillText(fitText(ctx, elapsed, elapsedWidth), contentLeft + textWidth + 8, centerY);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* 画图标预览墙:每个图标一行排三列,下面接一条色卡。
|
|
616
|
+
* 用的就是列表里那份 paintIcon 和 TONE_COLORS,所以这里看到多大、多亮,列表里就是什么样。
|
|
617
|
+
* @param {CanvasRenderingContext2D} ctx 画布上下文
|
|
618
|
+
*/
|
|
619
|
+
function paintPreview(ctx) {
|
|
620
|
+
ctx.textBaseline = "middle";
|
|
621
|
+
ctx.textAlign = "left";
|
|
622
|
+
ctx.font = `600 12px ${FONT_STACK}`;
|
|
623
|
+
ctx.fillStyle = TEXT_MAIN;
|
|
624
|
+
ctx.fillText(strings.preview, 10, 16);
|
|
625
|
+
const columnWidth = (layout.width - 20) / PREVIEW.columns;
|
|
626
|
+
for (const [index, item] of ICON_CATALOG.entries()) {
|
|
627
|
+
const column = index % PREVIEW.columns;
|
|
628
|
+
const row = Math.floor(index / PREVIEW.columns);
|
|
629
|
+
const left = 10 + column * columnWidth;
|
|
630
|
+
const centerY = 42 + row * PREVIEW.cellH + PREVIEW.cellH / 2;
|
|
631
|
+
const color = TONE_COLORS[item.tone] ?? TONE_COLORS.gray;
|
|
632
|
+
ctx.fillStyle = color;
|
|
633
|
+
ctx.strokeStyle = color;
|
|
634
|
+
paintIcon(ctx, item.name, left + 7, centerY);
|
|
635
|
+
ctx.font = `400 12px ${FONT_STACK}`;
|
|
636
|
+
ctx.fillStyle = TEXT_MAIN;
|
|
637
|
+
ctx.fillText(item.label, left + 14 + 8, centerY);
|
|
638
|
+
}
|
|
639
|
+
const toneTop = 42 + Math.ceil(ICON_CATALOG.length / PREVIEW.columns) * PREVIEW.cellH + 10;
|
|
640
|
+
const toneWidth = (layout.width - 20) / PREVIEW.toneColumns;
|
|
641
|
+
for (const [index, item] of TONE_CATALOG.entries()) {
|
|
642
|
+
const column = index % PREVIEW.toneColumns;
|
|
643
|
+
const row = Math.floor(index / PREVIEW.toneColumns);
|
|
644
|
+
const left = 10 + column * toneWidth;
|
|
645
|
+
const centerY = toneTop + row * PREVIEW.toneCellH + PREVIEW.toneCellH / 2;
|
|
646
|
+
ctx.fillStyle = TONE_COLORS[item.tone] ?? TONE_COLORS.gray;
|
|
647
|
+
roundRect(ctx, left, centerY - 5, 10, 10, 2);
|
|
648
|
+
ctx.fill();
|
|
649
|
+
ctx.font = `400 11px ${FONT_STACK}`;
|
|
650
|
+
ctx.fillStyle = TEXT_DIM;
|
|
651
|
+
ctx.fillText(item.label, left + 16, centerY);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** 已完成的条数,用来决定清除按钮要不要置灰。 */
|
|
655
|
+
function finishedCount() {
|
|
656
|
+
return board.cells.filter((cell) => cell.endedAt).length;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* 标题栏右侧两个按钮的位置。
|
|
660
|
+
* 从右往左先让开拖动手柄,清除贴着手柄,眼睛在它左边。
|
|
661
|
+
* @returns {Array<{ id: string, x: number, y: number }>}
|
|
662
|
+
*/
|
|
663
|
+
function headerButtons() {
|
|
664
|
+
const base = layout.width - 10 - 24;
|
|
665
|
+
return [{
|
|
666
|
+
id: "hide",
|
|
667
|
+
x: base - 20,
|
|
668
|
+
y: 16
|
|
669
|
+
}, {
|
|
670
|
+
id: "clear",
|
|
671
|
+
x: base,
|
|
672
|
+
y: 16
|
|
673
|
+
}];
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* 命中了哪个标题栏按钮。
|
|
677
|
+
* @param {number} x 画布内横坐标
|
|
678
|
+
* @param {number} y 画布内纵坐标
|
|
679
|
+
* @returns {string | null} 按钮 id,没命中返回 null
|
|
680
|
+
*/
|
|
681
|
+
function hitHeaderButton(x, y) {
|
|
682
|
+
for (const button of headerButtons()) if (Math.abs(x - button.x) <= 10 && Math.abs(y - button.y) <= 10) return button.id;
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
/** 画眼睛:椭圆加瞳孔,尺寸跟右上角的拖动手柄一个量级。 */
|
|
686
|
+
function paintEye(ctx, cx, cy) {
|
|
687
|
+
ctx.beginPath();
|
|
688
|
+
ctx.ellipse(cx, cy, 4.6, 2.8, 0, 0, Math.PI * 2);
|
|
689
|
+
ctx.stroke();
|
|
690
|
+
ctx.beginPath();
|
|
691
|
+
ctx.arc(cx, cy, 1.2, 0, Math.PI * 2);
|
|
692
|
+
ctx.fill();
|
|
693
|
+
}
|
|
694
|
+
/** 画垃圾桶:盖、提手、桶身三笔。 */
|
|
695
|
+
function paintTrash(ctx, cx, cy) {
|
|
696
|
+
ctx.beginPath();
|
|
697
|
+
ctx.moveTo(cx - 4, cy - 3);
|
|
698
|
+
ctx.lineTo(cx + 4, cy - 3);
|
|
699
|
+
ctx.stroke();
|
|
700
|
+
ctx.beginPath();
|
|
701
|
+
ctx.moveTo(cx - 1.5, cy - 3);
|
|
702
|
+
ctx.lineTo(cx - 1.5, cy - 4.5);
|
|
703
|
+
ctx.lineTo(cx + 1.5, cy - 4.5);
|
|
704
|
+
ctx.lineTo(cx + 1.5, cy - 3);
|
|
705
|
+
ctx.stroke();
|
|
706
|
+
ctx.beginPath();
|
|
707
|
+
ctx.moveTo(cx - 2.8, cy - 3);
|
|
708
|
+
ctx.lineTo(cx - 2.2, cy + 4);
|
|
709
|
+
ctx.lineTo(cx + 2.2, cy + 4);
|
|
710
|
+
ctx.lineTo(cx + 2.8, cy - 3);
|
|
711
|
+
ctx.stroke();
|
|
712
|
+
}
|
|
713
|
+
/** 标题栏右侧的图标按钮:浅色方块垫底,图标居中,视觉重心才和左边文字齐。 */
|
|
714
|
+
function paintHeaderButtons(ctx) {
|
|
715
|
+
ctx.save();
|
|
716
|
+
ctx.lineWidth = 1.1;
|
|
717
|
+
ctx.lineCap = "round";
|
|
718
|
+
ctx.lineJoin = "round";
|
|
719
|
+
for (const button of headerButtons()) {
|
|
720
|
+
const disabled = button.id === "clear" && finishedCount() === 0;
|
|
721
|
+
const lit = hoverButton === button.id && !disabled;
|
|
722
|
+
const side = 16;
|
|
723
|
+
roundRect(ctx, button.x - side / 2, button.y - side / 2, side, side, 4);
|
|
724
|
+
ctx.fillStyle = lit ? "rgba(255, 255, 255, 0.14)" : "rgba(255, 255, 255, 0.07)";
|
|
725
|
+
ctx.fill();
|
|
726
|
+
const color = lit ? TEXT_MAIN : disabled ? "rgba(255, 255, 255, 0.3)" : TEXT_DIM;
|
|
727
|
+
ctx.strokeStyle = color;
|
|
728
|
+
ctx.fillStyle = color;
|
|
729
|
+
if (button.id === "hide") paintEye(ctx, button.x, button.y);
|
|
730
|
+
else paintTrash(ctx, button.x, button.y);
|
|
731
|
+
}
|
|
732
|
+
ctx.restore();
|
|
733
|
+
}
|
|
734
|
+
/** 整屏重画。 */
|
|
735
|
+
function paint(ctx) {
|
|
736
|
+
const now = performance.now();
|
|
737
|
+
ctx.clearRect(0, 0, viewWidth, viewHeight);
|
|
738
|
+
roundRect(ctx, .5, .5, viewWidth - 1, viewHeight - 1, 14);
|
|
739
|
+
ctx.fillStyle = SURFACE;
|
|
740
|
+
ctx.fill();
|
|
741
|
+
ctx.strokeStyle = SURFACE_BORDER;
|
|
742
|
+
ctx.lineWidth = 1;
|
|
743
|
+
ctx.stroke();
|
|
744
|
+
const shift = contentOffset();
|
|
745
|
+
const rowTop = rowTopOf();
|
|
746
|
+
ctx.save();
|
|
747
|
+
ctx.translate(shift.x, 0);
|
|
748
|
+
if (preview) {
|
|
749
|
+
paintPreview(ctx);
|
|
750
|
+
ctx.restore();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const headerY = layout.rows > 0 ? 16 : Math.min(viewHeight, 32) / 2;
|
|
754
|
+
ctx.textBaseline = "middle";
|
|
755
|
+
ctx.textAlign = "left";
|
|
756
|
+
ctx.font = `600 12px ${FONT_STACK}`;
|
|
757
|
+
ctx.fillStyle = TEXT_MAIN;
|
|
758
|
+
const totalText = `${board.total} ${strings.total}`;
|
|
759
|
+
ctx.fillText(totalText, 10, headerY);
|
|
760
|
+
let cursor = 10 + ctx.measureText(totalText).width;
|
|
761
|
+
if (board.waiting > 0) {
|
|
762
|
+
const waitingText = `· ${board.waiting} ${strings.waiting}`;
|
|
763
|
+
cursor += 6;
|
|
764
|
+
ctx.fillStyle = TONE_COLORS.orange;
|
|
765
|
+
ctx.fillText(waitingText, cursor, headerY);
|
|
766
|
+
cursor += ctx.measureText(waitingText).width;
|
|
767
|
+
}
|
|
768
|
+
if (layout.overflow > 0) {
|
|
769
|
+
cursor += 6;
|
|
770
|
+
ctx.font = `400 9px ${FONT_STACK}`;
|
|
771
|
+
ctx.fillStyle = TEXT_DIM;
|
|
772
|
+
ctx.fillText(`+${layout.overflow}`, cursor, headerY);
|
|
773
|
+
}
|
|
774
|
+
paintHeaderButtons(ctx);
|
|
775
|
+
paintGrip(ctx, layout.width - 10 - 5, headerY);
|
|
776
|
+
if (layout.rows > 0) {
|
|
777
|
+
ctx.beginPath();
|
|
778
|
+
ctx.moveTo(10, 31.5);
|
|
779
|
+
ctx.lineTo(layout.width - 10, 31.5);
|
|
780
|
+
ctx.strokeStyle = SURFACE_BORDER;
|
|
781
|
+
ctx.lineWidth = 1;
|
|
782
|
+
ctx.stroke();
|
|
783
|
+
}
|
|
784
|
+
for (let index = 0; index < layout.rows; index += 1) {
|
|
785
|
+
const item = board.cells[index];
|
|
786
|
+
if (!item) break;
|
|
787
|
+
const rect = rowRect(layout, index, rowTop);
|
|
788
|
+
if (index === hoverRow) {
|
|
789
|
+
roundRect(ctx, rect.x - 4, rect.y, rect.w + 8, rect.h, 4);
|
|
790
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.07)";
|
|
791
|
+
ctx.fill();
|
|
792
|
+
}
|
|
793
|
+
paintRow(ctx, rect, item, now);
|
|
794
|
+
}
|
|
795
|
+
ctx.restore();
|
|
796
|
+
}
|
|
797
|
+
/** 有脉冲在跑时每帧都画,否则降到 4fps 省电。 */
|
|
798
|
+
function animating(now) {
|
|
799
|
+
for (const until of pulses.values()) if (until > now) return true;
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
/** 收到新快照:对比阶段变化,给变化的格子打一次脉冲。 */
|
|
803
|
+
function applyBoard(next) {
|
|
804
|
+
const previous = new Map(board.cells.map((cell) => [cell.id, cell.phase]));
|
|
805
|
+
board = next;
|
|
806
|
+
if (!preview) {
|
|
807
|
+
const before = layout.rows;
|
|
808
|
+
layout = layoutOf(next.cells.length);
|
|
809
|
+
if (layout.rows !== next.cells.length) report("warn", "行数与内容不一致", {
|
|
810
|
+
rows: layout.rows,
|
|
811
|
+
cells: next.cells.length,
|
|
812
|
+
layoutHeight: layout.height,
|
|
813
|
+
viewHeight
|
|
814
|
+
});
|
|
815
|
+
if (before !== layout.rows) report("debug", "画布行数变化", {
|
|
816
|
+
instance: INSTANCE,
|
|
817
|
+
from: before,
|
|
818
|
+
to: layout.rows,
|
|
819
|
+
height: layout.height,
|
|
820
|
+
viewHeight
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const now = performance.now();
|
|
824
|
+
for (const cell of next.cells) {
|
|
825
|
+
const phase = previous.get(cell.id);
|
|
826
|
+
if (phase !== void 0 && phase !== cell.phase) pulses.set(cell.id, now + PULSE_MS);
|
|
827
|
+
if (phase === void 0) pulses.set(cell.id, now + PULSE_MS);
|
|
828
|
+
}
|
|
829
|
+
for (const id of pulses.keys()) if (!next.cells.some((cell) => cell.id === id)) pulses.delete(id);
|
|
830
|
+
dirty = true;
|
|
831
|
+
}
|
|
832
|
+
finch.canvas.define({
|
|
833
|
+
init({ ctx2d: context, canvas, width, height, initialData }) {
|
|
834
|
+
try {
|
|
835
|
+
ctx2d = context;
|
|
836
|
+
canvasEl = canvas;
|
|
837
|
+
preview = initialData?.preview === true;
|
|
838
|
+
layout = preview ? previewSize() : layoutOf(initialData?.count ?? 0);
|
|
839
|
+
viewWidth = width;
|
|
840
|
+
viewHeight = height;
|
|
841
|
+
if (initialData?.strings) strings = initialData.strings;
|
|
842
|
+
if (initialData?.board) applyBoard(initialData.board);
|
|
843
|
+
ctx2d.textBaseline = "middle";
|
|
844
|
+
lockSize(layout.width, layout.height);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
report("error", "画布初始化失败", {
|
|
847
|
+
reason: String(error?.message ?? error),
|
|
848
|
+
stack: error?.stack
|
|
849
|
+
});
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
resize(width, height) {
|
|
854
|
+
viewWidth = width;
|
|
855
|
+
viewHeight = height;
|
|
856
|
+
if (width !== lastViewWidth || height !== lastViewHeight) {
|
|
857
|
+
lastViewWidth = width;
|
|
858
|
+
lastViewHeight = height;
|
|
859
|
+
report("debug", "画布尺寸变化", {
|
|
860
|
+
instance: INSTANCE,
|
|
861
|
+
width,
|
|
862
|
+
height,
|
|
863
|
+
expect: `${layout.width}x${layout.height}`,
|
|
864
|
+
rows: layout.rows,
|
|
865
|
+
cells: board.cells.length,
|
|
866
|
+
dragging: dragging !== null,
|
|
867
|
+
inner: `${window.innerWidth}x${window.innerHeight}`,
|
|
868
|
+
outer: `${window.outerWidth}x${window.outerHeight}`,
|
|
869
|
+
dpr: window.devicePixelRatio
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
if (dragging) {
|
|
873
|
+
dirty = true;
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (Math.abs(width - layout.width) > 6 || Math.abs(height - layout.height) > 6) {
|
|
877
|
+
report("warn", "画布尺寸被外部改动,已重新锁定", {
|
|
878
|
+
instance: INSTANCE,
|
|
879
|
+
width,
|
|
880
|
+
height,
|
|
881
|
+
expect: `${layout.width}x${layout.height}`
|
|
882
|
+
});
|
|
883
|
+
lockSize(layout.width, layout.height);
|
|
884
|
+
finch.window.setSize(layout.width, layout.height);
|
|
885
|
+
}
|
|
886
|
+
dirty = true;
|
|
887
|
+
},
|
|
888
|
+
frame() {
|
|
889
|
+
const now = performance.now();
|
|
890
|
+
const busy = animating(now);
|
|
891
|
+
if (!dirty && !busy && now - lastPaint < 250) return;
|
|
892
|
+
dirty = false;
|
|
893
|
+
lastPaint = now;
|
|
894
|
+
if (!booted) {
|
|
895
|
+
booted = true;
|
|
896
|
+
report("info", "画布首帧", {
|
|
897
|
+
instance: INSTANCE,
|
|
898
|
+
cells: board.cells.length,
|
|
899
|
+
rows: layout.rows,
|
|
900
|
+
layout: `${layout.width}x${layout.height}`,
|
|
901
|
+
view: `${viewWidth}x${viewHeight}`,
|
|
902
|
+
dpr: window.devicePixelRatio
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
paint(ctx2d);
|
|
906
|
+
},
|
|
907
|
+
onPointer(event) {
|
|
908
|
+
if (preview) return;
|
|
909
|
+
if (dragging) {
|
|
910
|
+
if (event.type === "move") {
|
|
911
|
+
const point = screenPointOf(event);
|
|
912
|
+
const dx = point.x - dragging.lastScreenX;
|
|
913
|
+
const dy = point.y - dragging.lastScreenY;
|
|
914
|
+
if (dx === 0 && dy === 0) return;
|
|
915
|
+
dragging.lastScreenX = point.x;
|
|
916
|
+
dragging.lastScreenY = point.y;
|
|
917
|
+
dragging.winX = Math.round(dragging.winX + dx);
|
|
918
|
+
dragging.winY = Math.round(dragging.winY + dy);
|
|
919
|
+
finch.postMessage({
|
|
920
|
+
type: "move",
|
|
921
|
+
x: dragging.winX,
|
|
922
|
+
y: dragging.winY
|
|
923
|
+
});
|
|
924
|
+
dragging.frames += 1;
|
|
925
|
+
if (isDev() && dragging.frames % 20 === 0) report("debug", "拖动中", {
|
|
926
|
+
frames: dragging.frames,
|
|
927
|
+
moveX: dragging.winX - dragging.startWinX,
|
|
928
|
+
moveY: dragging.winY - dragging.startWinY,
|
|
929
|
+
winX: dragging.winX,
|
|
930
|
+
winY: dragging.winY,
|
|
931
|
+
inner: `${window.innerWidth}x${window.innerHeight}`,
|
|
932
|
+
outer: `${window.outerWidth}x${window.outerHeight}`
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (event.type === "up" || event.type === "cancel") {
|
|
937
|
+
const { winX, winY, frames, startWinX, startWinY } = dragging;
|
|
938
|
+
dragging = null;
|
|
939
|
+
report("debug", "拖动结束", {
|
|
940
|
+
type: event.type,
|
|
941
|
+
frames,
|
|
942
|
+
moveX: winX - startWinX,
|
|
943
|
+
moveY: winY - startWinY,
|
|
944
|
+
winX,
|
|
945
|
+
winY,
|
|
946
|
+
inner: `${window.innerWidth}x${window.innerHeight}`,
|
|
947
|
+
outer: `${window.outerWidth}x${window.outerHeight}`
|
|
948
|
+
});
|
|
949
|
+
finch.postMessage({
|
|
950
|
+
type: "dragEnd",
|
|
951
|
+
x: winX,
|
|
952
|
+
y: winY
|
|
953
|
+
});
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (event.type === "move") {
|
|
958
|
+
const hit = hitHeaderButton(event.x, event.y);
|
|
959
|
+
const nextButton = hit === "clear" && finishedCount() === 0 ? null : hit;
|
|
960
|
+
const nextRow = nextButton ? -1 : rowAt(layout, event.x - contentOffset().x, event.y, rowTopOf());
|
|
961
|
+
if (nextButton !== hoverButton || nextRow !== hoverRow) {
|
|
962
|
+
hoverButton = nextButton;
|
|
963
|
+
hoverRow = nextRow;
|
|
964
|
+
if (canvasEl) canvasEl.style.cursor = nextButton || nextRow >= 0 ? "pointer" : "default";
|
|
965
|
+
dirty = true;
|
|
966
|
+
}
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (event.type === "down" && event.button === 0) {
|
|
970
|
+
const button = hitHeaderButton(event.x, event.y);
|
|
971
|
+
if (button) {
|
|
972
|
+
if (button === "clear" && finishedCount() === 0) return;
|
|
973
|
+
pointerDown = {
|
|
974
|
+
button,
|
|
975
|
+
x: event.x,
|
|
976
|
+
y: event.y
|
|
977
|
+
};
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const index = rowAt(layout, event.x - contentOffset().x, event.y, rowTopOf());
|
|
981
|
+
if (index < 0) {
|
|
982
|
+
const point = screenPointOf(event);
|
|
983
|
+
dragging = {
|
|
984
|
+
lastScreenX: point.x,
|
|
985
|
+
lastScreenY: point.y,
|
|
986
|
+
winX: window.screenX,
|
|
987
|
+
winY: window.screenY,
|
|
988
|
+
startWinX: window.screenX,
|
|
989
|
+
startWinY: window.screenY,
|
|
990
|
+
frames: 0
|
|
991
|
+
};
|
|
992
|
+
report("debug", "拖动开始", {
|
|
993
|
+
screenX: point.x,
|
|
994
|
+
screenY: point.y,
|
|
995
|
+
winX: dragging.winX,
|
|
996
|
+
winY: dragging.winY,
|
|
997
|
+
screen: `${window.screenX},${window.screenY}`,
|
|
998
|
+
inner: `${window.innerWidth}x${window.innerHeight}`,
|
|
999
|
+
outer: `${window.outerWidth}x${window.outerHeight}`,
|
|
1000
|
+
expect: `${layout.width}x${layout.height}`
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
pointerDown = {
|
|
1005
|
+
index,
|
|
1006
|
+
x: event.x,
|
|
1007
|
+
y: event.y
|
|
1008
|
+
};
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (event.type === "up" && pointerDown) {
|
|
1012
|
+
const moved = Math.hypot(event.x - pointerDown.x, event.y - pointerDown.y);
|
|
1013
|
+
if (pointerDown.button) {
|
|
1014
|
+
if (moved <= 4 && hitHeaderButton(event.x, event.y) === pointerDown.button) finch.postMessage({ type: pointerDown.button === "hide" ? "hide" : "clear-finished" });
|
|
1015
|
+
pointerDown = null;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const index = rowAt(layout, event.x - contentOffset().x, event.y, rowTopOf());
|
|
1019
|
+
if (moved <= 4 && index === pointerDown.index) {
|
|
1020
|
+
const item = board.cells[index];
|
|
1021
|
+
if (item) finch.postMessage({
|
|
1022
|
+
type: "focus",
|
|
1023
|
+
sessionId: item.id
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
pointerDown = null;
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (event.type === "cancel") pointerDown = null;
|
|
1030
|
+
},
|
|
1031
|
+
onMessage(message) {
|
|
1032
|
+
if (!message || typeof message !== "object") return;
|
|
1033
|
+
if (message.type === "board") {
|
|
1034
|
+
applyBoard(message.board);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (message.type === "config") {
|
|
1038
|
+
if (message.strings) strings = message.strings;
|
|
1039
|
+
if (typeof message.preview === "boolean" && message.preview !== preview) {
|
|
1040
|
+
preview = message.preview;
|
|
1041
|
+
layout = preview ? previewSize() : layoutOf(board.cells.length);
|
|
1042
|
+
report("info", "切换图标预览", {
|
|
1043
|
+
instance: INSTANCE,
|
|
1044
|
+
preview,
|
|
1045
|
+
layoutHeight: layout.height
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
dirty = true;
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (message.type === "size") {
|
|
1052
|
+
layout = preview ? previewSize() : layoutOf(message.count ?? board.cells.length);
|
|
1053
|
+
report("debug", "收到尺寸消息", {
|
|
1054
|
+
instance: INSTANCE,
|
|
1055
|
+
count: message.count,
|
|
1056
|
+
preview,
|
|
1057
|
+
rows: layout.rows,
|
|
1058
|
+
layoutHeight: layout.height,
|
|
1059
|
+
viewHeight
|
|
1060
|
+
});
|
|
1061
|
+
lockSize(layout.width, layout.height);
|
|
1062
|
+
dirty = true;
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
dispose() {
|
|
1066
|
+
report("info", "画布销毁", {
|
|
1067
|
+
instance: INSTANCE,
|
|
1068
|
+
cells: board.cells.length
|
|
1069
|
+
});
|
|
1070
|
+
pulses.clear();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
window.addEventListener("error", (event) => {
|
|
1074
|
+
report("error", event.message || "未捕获错误", {
|
|
1075
|
+
source: event.filename,
|
|
1076
|
+
line: event.lineno,
|
|
1077
|
+
column: event.colno
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
1081
|
+
report("error", "未处理的 Promise 拒绝", { reason: String(event.reason?.message ?? event.reason) });
|
|
1082
|
+
});
|
|
1083
|
+
})();
|