modelstage 0.5.28 → 0.6.1
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/CHANGELOG.md +83 -0
- package/README.md +2 -63
- package/config/models/live2d.assetbundls.json +1 -1
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_bundle.json +1 -1
- package/config/models/live2d.azurlane_bundle.jsonl +2 -0
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.bd2.json +1 -1
- package/config/models/live2d.bd2.jsonl +2 -0
- package/config/models/live2d.bluearchive.json +1 -1
- package/config/models/live2d.nikke.json +1 -1
- package/config/models/live2d.nikke.jsonl +2 -0
- package/config/models/live2d.nikke_2.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.assetbundls.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_bundle.json +1 -1
- package/config/models/spine.azurlane_bundle.jsonl +2 -0
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.bd2.json +1 -1
- package/config/models/spine.bd2.jsonl +433 -0
- package/config/models/spine.bluearchive.json +1 -1
- package/config/models/spine.nikke.json +1 -1
- package/config/models/spine.nikke_2.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +2 -1
- package/src/server/assets/probe.mjs +1 -1
- package/src/server/core/config.mjs +2 -2
- package/src/server/core/log.mjs +1 -1
- package/src/server/data/catalog.mjs +11 -14
- package/src/server/data/live2d.mjs +139 -40
- package/src/server/data/manifest-cache.mjs +53 -10
- package/src/server/data/manifest-jsonl.mjs +264 -0
- package/src/server/data/manifest-stamp.mjs +52 -2
- package/src/server/data/scan-progress.mjs +305 -0
- package/src/server/data/scan-queue.mjs +99 -0
- package/src/server/data/spine.mjs +324 -106
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +9 -4
- package/src/server/unpack/plugin-store.mjs +1 -1
- package/src/server/unpack/runtime/exec.mjs +37 -8
- package/src/server/unpack/worker-client.mjs +23 -8
- package/src/shared/scan-limits.mjs +23 -0
- package/src/web/app/anim-list.js +1 -1
- package/src/web/app/backgrounds.js +4 -8
- package/src/web/app/dir-picker.js +3 -5
- package/src/web/app/memory-budget.js +0 -1
- package/src/web/app/memory-guard.js +0 -1
- package/src/web/app/model-actions.js +18 -15
- package/src/web/app/root-manager.js +2 -4
- package/src/web/app/transport-actions.js +1 -1
- package/src/web/app/ui-state.js +1 -1
- package/src/web/components.css +90 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/infra/api.js +10 -19
- package/src/web/infra/document.js +3 -3
- package/src/web/infra/plugin-host-unpack.mjs +81 -9
- package/src/web/infra/ready.js +177 -39
- package/src/web/live2d/bridge.js +1 -1
- package/src/web/live2d/runtime-adapter.js +2 -4
- package/src/web/panel/panel-settings.js +88 -3
- package/src/web/roster/events.js +159 -0
- package/src/web/roster/filter.js +4 -4
- package/src/web/roster/list.js +3 -3
- package/src/web/roster/load.js +440 -0
- package/src/web/roster/persist.js +46 -37
- package/src/web/roster/render.js +284 -0
- package/src/web/roster/reveal.js +4 -9
- package/src/web/roster/rows.js +35 -13
- package/src/web/roster/scan-stream.js +346 -0
- package/src/web/roster/snapshot.js +14 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +5 -5
- package/src/web/roster/view.js +147 -634
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
package/src/web/infra/ready.js
CHANGED
|
@@ -38,6 +38,11 @@ import { fetchWithRetry } from './api.js';
|
|
|
38
38
|
// 又不会让就绪信号明显滞后。
|
|
39
39
|
const FLUSH_DEBOUNCE_MS = 400;
|
|
40
40
|
|
|
41
|
+
/** 轮询超时记录的保留条数(环形):只留最近若干条,长时间运行不堆积 */
|
|
42
|
+
const TRACK_STOP_KEEP = 20;
|
|
43
|
+
/** 同一阶段同一结局的告警限频(ms):换模型可能反复超时,不能让同一句话刷屏 */
|
|
44
|
+
const TRACK_WARN_WINDOW_MS = 10000;
|
|
45
|
+
|
|
41
46
|
const st = {
|
|
42
47
|
stages: {}, // stage → { state, at, detail?, reason? }
|
|
43
48
|
rev: 0, // 本地 rev(服务端才是权威;这里供 __WEB__.ready() 立即可读)
|
|
@@ -50,6 +55,11 @@ const st = {
|
|
|
50
55
|
fails: 0, // 上报失败次数(限频记日志,不刷屏)
|
|
51
56
|
lastFailAt: 0,
|
|
52
57
|
lastFail: null, // {at,name,msg,attempts}:一次失败就能看出是网络层还是超时(排障唯一途径)
|
|
58
|
+
// 轮询跟踪的"结束记录"(环形,最近 TRACK_STOP_KEEP 条):
|
|
59
|
+
// 超时不再静默——每条都留下 阶段 / 等多久 / 最后观测到什么 / 属于哪种结局,
|
|
60
|
+
// 由 stats().trackStops 暴露(与 lastFail 同一条诊断面,不新造日志体系)。
|
|
61
|
+
trackStops: [],
|
|
62
|
+
trackWarnAt: new Map(), // `${stage}:${outcome}` → 上次告警时间(限频用)
|
|
53
63
|
};
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -140,7 +150,12 @@ export function resetStages(names, opts) {
|
|
|
140
150
|
* 上报次数 / 待发条数 / 本地 rev / 因 in-flight 被跳过的次数 / 失败次数 / 是否在飞
|
|
141
151
|
*/
|
|
142
152
|
export function stats() {
|
|
143
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
flushes: st.flushes, pending: st.pending.size, rev: st.rev, dropped: st.dropped, fails: st.fails,
|
|
155
|
+
inflight: st.inflight, lastFail: st.lastFail,
|
|
156
|
+
// 轮询跟踪的结束记录(含超时与外部取消):排障时先看这里,再看阶段本身
|
|
157
|
+
trackStops: st.trackStops.slice()
|
|
158
|
+
};
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
/** 页面内直读快照(契约 §5.1):ready() 全量,ready('model') 单阶段 */
|
|
@@ -210,8 +225,9 @@ function flush(opts) {
|
|
|
210
225
|
};
|
|
211
226
|
try {
|
|
212
227
|
if (keepalive) {
|
|
213
|
-
// 卸载前:**不重试**(页面已经在走,没时间退避),keepalive
|
|
214
|
-
|
|
228
|
+
// 卸载前:**不重试**(页面已经在走,没时间退避),keepalive 保证尽力送达。
|
|
229
|
+
// 不设时限:页面卸载时浏览器自己会掐断。
|
|
230
|
+
fetch('/api/ready', { method: 'POST', headers, body: payload, keepalive: true })
|
|
215
231
|
.catch((e) => onFail(e, 1))
|
|
216
232
|
.finally(() => { st.inflight = false; });
|
|
217
233
|
} else {
|
|
@@ -250,6 +266,41 @@ export function flushNow() {
|
|
|
250
266
|
flush({ keepalive: true });
|
|
251
267
|
}
|
|
252
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 记录一次"跟踪结束"(超时 / 外部取消),并做限频告警。
|
|
271
|
+
*
|
|
272
|
+
* 为什么必须分三种结局(混成一句"超时"会把排错方向带偏):
|
|
273
|
+
* · 'no-observation' —— 全程**没有任何观测**(未挂载、帧数恒 0、元素始终不存在)⇒ 这才是真的"没来"
|
|
274
|
+
* · 'not-interactive' —— 有观测但没到 interactive 门槛(已 painted/settled、动作数 0 等)⇒ "到了但不够"
|
|
275
|
+
* · 'cancelled' —— 被调用方主动 stop() 取消 ⇒ **不是超时**,不告警、绝不判失败
|
|
276
|
+
*
|
|
277
|
+
* 落点选择:本模块与 diag 分离是既定契约(见文件头),所以这里不发明新日志,
|
|
278
|
+
* 而是把记录挂到**既有诊断面** stats() 上(页面侧 `__WEB__.readyStats()` 可读),
|
|
279
|
+
* 并配一条限频 console.warn 给看控制台的人。
|
|
280
|
+
*
|
|
281
|
+
* @param {{stage:string, outcome:'no-observation'|'not-interactive'|'cancelled',
|
|
282
|
+
* waitedMs:number, timeoutMs:number, state:string|null, last:object}} rec
|
|
283
|
+
*/
|
|
284
|
+
function recordTrackStop(rec) {
|
|
285
|
+
const entry = { at: Date.now(), ...rec };
|
|
286
|
+
st.trackStops.push(entry);
|
|
287
|
+
if (st.trackStops.length > TRACK_STOP_KEEP) st.trackStops.shift();
|
|
288
|
+
if (entry.outcome === 'cancelled') return entry; // 外部取消:不是异常,保持安静
|
|
289
|
+
const key = `${entry.stage}:${entry.outcome}`;
|
|
290
|
+
const lastAt = st.trackWarnAt.get(key) || 0;
|
|
291
|
+
const now = entry.at;
|
|
292
|
+
if (now - lastAt > TRACK_WARN_WINDOW_MS) {
|
|
293
|
+
st.trackWarnAt.set(key, now);
|
|
294
|
+
try {
|
|
295
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
296
|
+
console.warn(`[ready] ${entry.stage} 阶段 ${entry.waitedMs}ms 未达 interactive(${entry.outcome})`
|
|
297
|
+
+ `:当前 ${entry.state || 'pending'},最后观测 ${JSON.stringify(entry.last || {})}`);
|
|
298
|
+
}
|
|
299
|
+
} catch { /* 忽略 */ }
|
|
300
|
+
}
|
|
301
|
+
return entry;
|
|
302
|
+
}
|
|
303
|
+
|
|
253
304
|
/**
|
|
254
305
|
* 跟踪「模型」阶段的三级状态(契约 §2.2)。
|
|
255
306
|
*
|
|
@@ -265,57 +316,57 @@ export function trackModelStage(opts) {
|
|
|
265
316
|
const { kind, id, canvas, getFrames, getActions } = opts || {};
|
|
266
317
|
const timeoutMs = Number(opts && opts.timeoutMs) || 8000;
|
|
267
318
|
const t0 = Date.now();
|
|
268
|
-
let stopped = false;
|
|
269
319
|
const base = { kind: kind || null, id: id || null };
|
|
270
320
|
// 换模型(或第一次装载)时重新起算 model 阶段:
|
|
271
321
|
// 否则上一轮的 interactive 会被新模型继承,"等 painted"在新模型上永远不会重新发生。
|
|
272
322
|
const prevId = st.stages.model && st.stages.model.detail ? st.stages.model.detail.id : null;
|
|
273
323
|
if (prevId !== base.id) resetStage('model');
|
|
274
324
|
|
|
275
|
-
const mountedNow = () =>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const w = Math.max(el.width || 0, el.clientWidth || 0);
|
|
279
|
-
const h = Math.max(el.height || 0, el.clientHeight || 0);
|
|
280
|
-
return w > 0 && h > 0;
|
|
281
|
-
};
|
|
282
|
-
const frames = () => {
|
|
283
|
-
try { return Number(getFrames && getFrames()) || 0; } catch { return 0; }
|
|
284
|
-
};
|
|
285
|
-
const actions = () => {
|
|
286
|
-
try { return Number(getActions && getActions()) || 0; } catch { return 0; }
|
|
287
|
-
};
|
|
325
|
+
const mountedNow = () => canvasMounted(canvas);
|
|
326
|
+
const frames = () => safeCount(getFrames);
|
|
327
|
+
const actions = () => safeCount(getActions);
|
|
288
328
|
|
|
289
329
|
// 严格按 mounted → painted → interactive 推进(契约 §2.2):
|
|
290
330
|
// interactive 不得越过 painted —— 动作清单在装载完成瞬间就可能非空,
|
|
291
331
|
// 若不拦住,会在"一帧都还没画"时报 interactive,等于把分级做成了摆设。
|
|
292
332
|
let painted = false;
|
|
333
|
+
// 最后一次观测到的值:超时时它是"卡在哪一步"的唯一证据(帧数/动作数/是否挂载)
|
|
334
|
+
const lastSeen = { mounted: false, frames: 0, actions: 0 };
|
|
335
|
+
// 收尾:done=正常达标 / timeout=等到了上限 / cancelled=被调用方主动取消。
|
|
336
|
+
// 判失败的条件 = 全无观测 **且** 阶段还没到 painted(模型画布不该缺席,所以允许判失败)。
|
|
337
|
+
const finisher = createTrackFinisher({
|
|
338
|
+
stage: 'model', t0, timeoutMs,
|
|
339
|
+
last: () => ({ ...base, ...lastSeen, painted }),
|
|
340
|
+
currentState: () => (st.stages.model ? st.stages.model.state : null),
|
|
341
|
+
allowFail: () => (RANK[st.stages.model ? st.stages.model.state : null] || 0) < RANK.painted,
|
|
342
|
+
failDetail: () => ({ ...base, ...lastSeen }),
|
|
343
|
+
failReason: (waitedMs) => `模型 ${timeoutMs}ms 内无任何观测(画布未挂载 / 已绘帧数 0;已等 ${waitedMs}ms)`,
|
|
344
|
+
});
|
|
345
|
+
const finish = finisher.finish;
|
|
346
|
+
const isStopped = finisher.isStopped;
|
|
293
347
|
const tick = () => {
|
|
294
|
-
if (
|
|
295
|
-
|
|
348
|
+
if (isStopped()) return;
|
|
349
|
+
lastSeen.mounted = mountedNow();
|
|
350
|
+
if (lastSeen.mounted) markStage('model', 'mounted', { ...base });
|
|
296
351
|
const f = frames();
|
|
352
|
+
lastSeen.frames = f;
|
|
297
353
|
if (!painted && f >= 1) {
|
|
298
354
|
painted = true;
|
|
299
355
|
markStage('model', 'painted', { ...base, frames: f });
|
|
300
356
|
}
|
|
301
357
|
const a = actions();
|
|
358
|
+
lastSeen.actions = a;
|
|
302
359
|
if (painted && a > 0) {
|
|
303
360
|
markStage('model', 'interactive', { ...base, frames: f, actions: a });
|
|
304
|
-
|
|
361
|
+
finish('done');
|
|
305
362
|
return;
|
|
306
363
|
}
|
|
307
|
-
if (Date.now() - t0 > timeoutMs) {
|
|
364
|
+
if (Date.now() - t0 > timeoutMs) { finish('timeout'); return; }
|
|
308
365
|
setTimeout(tick, MODEL_POLL_MS);
|
|
309
366
|
};
|
|
310
367
|
|
|
311
|
-
function stop() {
|
|
312
|
-
if (stopped) return;
|
|
313
|
-
stopped = true;
|
|
314
|
-
scheduleFlush(); // 走合并窗口:装载完成会连标多个阶段,立刻发会撞上模型文件的连接占用
|
|
315
|
-
}
|
|
316
|
-
|
|
317
368
|
tick();
|
|
318
|
-
return
|
|
369
|
+
return () => finish('cancelled');
|
|
319
370
|
}
|
|
320
371
|
|
|
321
372
|
/**
|
|
@@ -342,6 +393,52 @@ function safeProbe(fn) {
|
|
|
342
393
|
}
|
|
343
394
|
}
|
|
344
395
|
|
|
396
|
+
/**
|
|
397
|
+
* 造一个"跟踪收尾器":三个 track* 共用的结局记录逻辑。
|
|
398
|
+
*
|
|
399
|
+
* 抽出来的原因:三段代码的收尾几乎一样(记录 → 是否判失败 → 合并上报),
|
|
400
|
+
* 分开写会让每个 track 函数都超出单函数代码行上限,而且三处判断容易走偏。
|
|
401
|
+
*
|
|
402
|
+
* @param {{stage:string, t0:number, timeoutMs:number, last:()=>object,
|
|
403
|
+
* currentState:()=>string|null, allowFail:()=>boolean,
|
|
404
|
+
* failDetail:()=>object, failReason:(waitedMs:number)=>string}} spec
|
|
405
|
+
* @returns {{finish:(kind:'done'|'timeout'|'cancelled')=>void, isStopped:()=>boolean}}
|
|
406
|
+
*/
|
|
407
|
+
function createTrackFinisher(spec) {
|
|
408
|
+
let stopped = false;
|
|
409
|
+
const finish = (kind) => {
|
|
410
|
+
if (stopped) return;
|
|
411
|
+
stopped = true;
|
|
412
|
+
if (kind !== 'done') {
|
|
413
|
+
const waitedMs = Date.now() - spec.t0;
|
|
414
|
+
const stateNow = spec.currentState();
|
|
415
|
+
const last = spec.last();
|
|
416
|
+
const observed = last && (last.connected === true || last.mounted === true
|
|
417
|
+
|| Number(last.frames) > 0 || Number(last.actions) > 0 || last.seen === true);
|
|
418
|
+
const outcome = kind === 'cancelled' ? 'cancelled' : (observed ? 'not-interactive' : 'no-observation');
|
|
419
|
+
// 只记录,不判死:这里等的是"画布自己走到 interactive",没有任何事件可以订阅,
|
|
420
|
+
// 所以只能轮询;但轮询的结论只能是"观测到多少",不能是"失败"——
|
|
421
|
+
// 慢(机器忙、资源大)不等于坏。真要判失败只有一种情形:调用方明确要求(allowFail)。
|
|
422
|
+
recordTrackStop({ stage: spec.stage, outcome, waitedMs, timeoutMs: spec.timeoutMs, state: stateNow, last });
|
|
423
|
+
}
|
|
424
|
+
scheduleFlush();
|
|
425
|
+
};
|
|
426
|
+
return { finish, isStopped: () => stopped };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 画布是否已挂载且有非零尺寸(模型阶段 mounted 的判据:detached/0 尺寸都不算挂上) */
|
|
430
|
+
function canvasMounted(canvas) {
|
|
431
|
+
if (!canvas || canvas.isConnected !== true) return false;
|
|
432
|
+
const w = Math.max(canvas.width || 0, canvas.clientWidth || 0);
|
|
433
|
+
const h = Math.max(canvas.height || 0, canvas.clientHeight || 0);
|
|
434
|
+
return w > 0 && h > 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 读一个计数型探针(抛错按 0 处理:探针异常不能把跟踪器带崩) */
|
|
438
|
+
function safeCount(read) {
|
|
439
|
+
try { return Number(read && read()) || 0; } catch { return 0; }
|
|
440
|
+
}
|
|
441
|
+
|
|
345
442
|
/** 元素当前包围盒的紧凑表示(两次采样相等 ⇒ 布局稳定) */
|
|
346
443
|
function boxOf(node) {
|
|
347
444
|
try {
|
|
@@ -371,21 +468,36 @@ export function trackElementStage(opts) {
|
|
|
371
468
|
const { name, el } = opts || {};
|
|
372
469
|
const timeoutMs = Number(opts && opts.timeoutMs) || 8000;
|
|
373
470
|
const t0 = Date.now();
|
|
374
|
-
let stopped = false;
|
|
375
471
|
let prevBox = null;
|
|
376
472
|
let stableHits = 0;
|
|
473
|
+
// 最后观测:元素是否在文档里、包围盒、是否有动画、是否被挡住、探针结论
|
|
474
|
+
const lastSeen = { connected: false, box: null, animating: null, blocked: null, probeOk: null };
|
|
377
475
|
|
|
378
|
-
|
|
476
|
+
// DOM 阶段默认**不判失败**:元素不存在可能是正常配置(如未开调试日志时的调试面板),
|
|
477
|
+
// 把它标成失败是另一种撒谎;只留可归因记录。调用方要"缺席即失败"时显式传 failOnNoObservation。
|
|
478
|
+
const finisher = createTrackFinisher({
|
|
479
|
+
stage: name, t0, timeoutMs,
|
|
480
|
+
last: () => ({ ...(opts && opts.detail ? opts.detail : {}), ...lastSeen }),
|
|
481
|
+
currentState: () => (st.stages[name] ? st.stages[name].state : null),
|
|
482
|
+
allowFail: () => opts && opts.failOnNoObservation === true
|
|
483
|
+
&& (RANK[st.stages[name] ? st.stages[name].state : null] || 0) < RANK.mounted,
|
|
484
|
+
failDetail: () => ({ ...(opts && opts.detail ? opts.detail : {}), ...lastSeen }),
|
|
485
|
+
failReason: (waitedMs) => `${timeoutMs}ms 内元素未挂载(${name};已等 ${waitedMs}ms)`,
|
|
486
|
+
});
|
|
487
|
+
const { finish, isStopped } = finisher;
|
|
379
488
|
|
|
380
489
|
const tick = () => {
|
|
381
|
-
if (
|
|
490
|
+
if (isStopped()) return;
|
|
491
|
+
lastSeen.connected = !!(el && el.isConnected === true);
|
|
382
492
|
if (!el || el.isConnected !== true) {
|
|
383
|
-
if (Date.now() - t0 > timeoutMs)
|
|
493
|
+
if (Date.now() - t0 > timeoutMs) finish('timeout'); else setTimeout(tick, 80);
|
|
384
494
|
return;
|
|
385
495
|
}
|
|
386
496
|
markStage(name, 'mounted', opts && opts.detail ? { ...opts.detail } : null);
|
|
387
497
|
const box = boxOf(el);
|
|
388
498
|
const busy = animating(el);
|
|
499
|
+
lastSeen.box = box;
|
|
500
|
+
lastSeen.animating = busy;
|
|
389
501
|
if (!busy && box && box === prevBox) stableHits += 1; else stableHits = 0;
|
|
390
502
|
prevBox = box;
|
|
391
503
|
// 连续两次采样都稳定且无动画 ⇒ 视为布局/动画稳定
|
|
@@ -394,17 +506,19 @@ export function trackElementStage(opts) {
|
|
|
394
506
|
// interactive 可在通用判据之外再加该阶段自己的条件(如"声明内的控件都构建了"):
|
|
395
507
|
// 探针返回 {ok, detail};不 ok 就继续等,绝不为了"报绿"放宽。
|
|
396
508
|
const probe = opts && typeof opts.interactiveProbe === 'function' ? safeProbe(opts.interactiveProbe) : { ok: true, detail: {} };
|
|
397
|
-
|
|
509
|
+
lastSeen.probeOk = probe.ok;
|
|
510
|
+
lastSeen.blocked = blocked(el);
|
|
511
|
+
if (!lastSeen.blocked && probe.ok) {
|
|
398
512
|
markStage(name, 'interactive', { ...(opts && opts.detail ? opts.detail : {}), ...(probe.detail || {}), box });
|
|
399
|
-
|
|
513
|
+
finish('done');
|
|
400
514
|
return;
|
|
401
515
|
}
|
|
402
516
|
}
|
|
403
|
-
if (Date.now() - t0 > timeoutMs) {
|
|
517
|
+
if (Date.now() - t0 > timeoutMs) { finish('timeout'); return; }
|
|
404
518
|
setTimeout(tick, ELEMENT_POLL_MS);
|
|
405
519
|
};
|
|
406
520
|
tick();
|
|
407
|
-
return
|
|
521
|
+
return () => finish('cancelled');
|
|
408
522
|
}
|
|
409
523
|
|
|
410
524
|
/**
|
|
@@ -418,15 +532,39 @@ export function trackElementWhenReady(opts) {
|
|
|
418
532
|
const t0 = Date.now();
|
|
419
533
|
let inner = null;
|
|
420
534
|
let stopped = false;
|
|
535
|
+
// 元素是否曾经出现过:没出现过 = 'no-observation'(连跟踪都没开始),
|
|
536
|
+
// 出现过则由内层 trackElementStage 自己记录(它才知道卡在哪一步)。
|
|
537
|
+
let seen = false;
|
|
538
|
+
|
|
539
|
+
const finish = (kind) => {
|
|
540
|
+
if (stopped) return;
|
|
541
|
+
stopped = true;
|
|
542
|
+
if (inner) { inner(); return; } // 交给内层收尾(它会记录自己那份)
|
|
543
|
+
if (kind !== 'done') {
|
|
544
|
+
const stateNow = st.stages[name] ? st.stages[name].state : null;
|
|
545
|
+
const outcome = kind === 'cancelled' ? 'cancelled' : (seen ? 'not-interactive' : 'no-observation');
|
|
546
|
+
recordTrackStop({
|
|
547
|
+
stage: name, outcome, waitedMs: Date.now() - t0, timeoutMs, state: stateNow,
|
|
548
|
+
last: { selector, seen },
|
|
549
|
+
});
|
|
550
|
+
if (outcome === 'no-observation' && opts && opts.failOnNoObservation === true
|
|
551
|
+
&& (RANK[stateNow] || 0) < RANK.mounted) {
|
|
552
|
+
markStage(name, 'failed', { ...(opts.detail || {}), selector, waitedMs: Date.now() - t0 },
|
|
553
|
+
`${timeoutMs}ms 内未找到元素 ${selector}(${name})`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
scheduleFlush();
|
|
557
|
+
};
|
|
558
|
+
|
|
421
559
|
const tryStart = () => {
|
|
422
560
|
if (stopped || inner) return;
|
|
423
561
|
const el = document.querySelector(selector);
|
|
424
|
-
if (el) { inner = trackElementStage({ ...opts, name, el }); return; }
|
|
425
|
-
if (Date.now() - t0 > timeoutMs) {
|
|
562
|
+
if (el) { seen = true; inner = trackElementStage({ ...opts, name, el }); return; }
|
|
563
|
+
if (Date.now() - t0 > timeoutMs) { finish('timeout'); return; }
|
|
426
564
|
setTimeout(tryStart, MODEL_POLL_MS);
|
|
427
565
|
};
|
|
428
566
|
tryStart();
|
|
429
|
-
return () => { stopped = true;
|
|
567
|
+
return () => { if (inner) { stopped = true; inner(); return; } finish('cancelled'); };
|
|
430
568
|
}
|
|
431
569
|
|
|
432
570
|
/** 标记模型阶段失败(带可读原因,测试可立即报错而不必等超时) */
|
package/src/web/live2d/bridge.js
CHANGED
|
@@ -229,7 +229,7 @@ function createLoadGroup(ctx, st, note) {
|
|
|
229
229
|
// 换模型 = 换内容:清掉上一个模型的"当前动作"标签。
|
|
230
230
|
// 为什么必须清:loadForEngine 的返回值会作为 info.motion 回传给应用层,
|
|
231
231
|
// 而 fillTransportOptions 会把它当成"新模型要播的动作"。旧 label 若恰好同名
|
|
232
|
-
//
|
|
232
|
+
// ⇒ 新模型真的会去播上一个模型选的那条。
|
|
233
233
|
st.currentLabel = '';
|
|
234
234
|
} else {
|
|
235
235
|
note(`Live2D 复用已加载模型:${model.name}`);
|
|
@@ -86,10 +86,8 @@ function pixiLib() {
|
|
|
86
86
|
*/
|
|
87
87
|
async function readServerError(url) {
|
|
88
88
|
try {
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
// 5s 依据:服务端这条是纯本地 JSON(服务端已实测毫秒级返回),5s 只作兜底。
|
|
92
|
-
const r = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(5000) });
|
|
89
|
+
// 不设时限。调用方要放弃就传自己的 signal。
|
|
90
|
+
const r = await fetch(url, { cache: 'no-store' });
|
|
93
91
|
const text = await r.text();
|
|
94
92
|
try {
|
|
95
93
|
const j = JSON.parse(text);
|
|
@@ -261,7 +261,13 @@ const slotDeclaredDefaultById = new Map();
|
|
|
261
261
|
|
|
262
262
|
const docFilesCache = new Map();
|
|
263
263
|
const docFilesPending = new Set();
|
|
264
|
-
const docFilesStats = { lists: 0, repaints: 0 };
|
|
264
|
+
const docFilesStats = { lists: 0, repaints: 0, failures: 0 };
|
|
265
|
+
/**
|
|
266
|
+
* 失败记录:key → { reason, at, timedOut }。
|
|
267
|
+
* 为什么单独存一份而不是只加计数:提示行要显示"哪一条、因为什么失败",
|
|
268
|
+
* 而且重试成功后必须能把它清掉(只留计数会永远显示"失败过")。
|
|
269
|
+
*/
|
|
270
|
+
const docFilesFailures = new Map();
|
|
265
271
|
|
|
266
272
|
/**
|
|
267
273
|
* 目录清单请求(/api/catalog)的超时上限(ms)。
|
|
@@ -277,7 +283,11 @@ const DOC_FILES_TIMEOUT_MS = 6000;
|
|
|
277
283
|
function publishDocFilesStats() {
|
|
278
284
|
try {
|
|
279
285
|
if (typeof window === 'undefined') return;
|
|
280
|
-
window.__DOC_FILES__ = {
|
|
286
|
+
window.__DOC_FILES__ = {
|
|
287
|
+
lists: docFilesStats.lists, repaints: docFilesStats.repaints, cached: docFilesCache.size,
|
|
288
|
+
// 失败次数(只增):用来回答"到底请求了多少次失败"这个排障问题;前三个键语义不变
|
|
289
|
+
failures: docFilesStats.failures,
|
|
290
|
+
};
|
|
281
291
|
} catch { /* 无 window 忽略 */ }
|
|
282
292
|
}
|
|
283
293
|
|
|
@@ -318,6 +328,8 @@ function docFilesKeyOf(model) {
|
|
|
318
328
|
* @returns {string[]|null} 未就绪返回 null
|
|
319
329
|
*/
|
|
320
330
|
function docFilesOf(model) {
|
|
331
|
+
// 每次读取都同步一次提示行:换文档、成功后、失败后三种状态下提示都不会残留/迟到
|
|
332
|
+
renderDocFilesHint(model);
|
|
321
333
|
if (!model) return null;
|
|
322
334
|
const key = docFilesKeyOf(model);
|
|
323
335
|
if (docFilesCache.has(key)) return docFilesCache.get(key).slice();
|
|
@@ -358,6 +370,65 @@ function safeVfsList(prefix) {
|
|
|
358
370
|
* 而 document 是 getter、必须同步出值 —— 所以这里"先记 pending、拿到再回填 + 重建一次"。
|
|
359
371
|
* @param {object} model 条目 @param {string} key 缓存键
|
|
360
372
|
*/
|
|
373
|
+
/**
|
|
374
|
+
* 清单失败时的**内联提示**(宿主自己的 UI,不弹 toast / 不弹 modal)。
|
|
375
|
+
*
|
|
376
|
+
* 位置与呈现:绝对定位叠加在右栏顶部(.panel 本身是 position:fixed,绝对定位相对它),
|
|
377
|
+
* **不参与布局** ⇒ 提示出现/消失都不会挤动下方控件(这是刻意的:插一行会把整列控件往下推)。
|
|
378
|
+
* 文案区分"超时"与"其它失败":前者用户知道"再点一次可能就好",后者往往是服务端/网络问题。
|
|
379
|
+
*/
|
|
380
|
+
const DOC_FILES_HINT_ID = 'doc-files-retry';
|
|
381
|
+
|
|
382
|
+
/** 失败原因 → 人话(超时/取消都是"没等到",其余给通用文案,具体原因仍在日志里) */
|
|
383
|
+
function docFilesFailText(reason) {
|
|
384
|
+
const text = String(reason || '');
|
|
385
|
+
if (/超时|Timeout|Abort/i.test(text)) return '清单获取失败(超时)';
|
|
386
|
+
return '清单获取失败';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 画/刷/藏 那一行提示(幂等:没有失败就移除,有失败就复用同一个节点)。
|
|
391
|
+
* @param {object|null} model 当前文档(用于重试时定位条目)
|
|
392
|
+
*/
|
|
393
|
+
function renderDocFilesHint(model) {
|
|
394
|
+
try {
|
|
395
|
+
const host = dom.panelBody && dom.panelBody.parentElement; // 右栏 <aside class="panel">
|
|
396
|
+
if (!host) return;
|
|
397
|
+
const key = model ? docFilesKeyOf(model) : '';
|
|
398
|
+
const failure = key ? docFilesFailures.get(key) : null;
|
|
399
|
+
let node = document.getElementById(DOC_FILES_HINT_ID);
|
|
400
|
+
if (!failure) { if (node) node.remove(); return; }
|
|
401
|
+
if (!node) {
|
|
402
|
+
node = document.createElement('button');
|
|
403
|
+
node.id = DOC_FILES_HINT_ID;
|
|
404
|
+
node.type = 'button';
|
|
405
|
+
node.className = 'doc-files-hint';
|
|
406
|
+
host.appendChild(node);
|
|
407
|
+
}
|
|
408
|
+
node.textContent = `${docFilesFailText(failure.reason)} · 点击重试`;
|
|
409
|
+
node.title = `原因:${failure.reason}(不会再自动重试)`;
|
|
410
|
+
// 点击重试:清缓存/pending/失败记录后**复用同一条请求路径**(不新开请求链路)
|
|
411
|
+
node.onclick = () => { retryDocFiles(model); };
|
|
412
|
+
} catch { /* 提示是增强:任何一步失败都不影响面板 */ }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 点击重试:清掉该条目的缓存与 pending,再走 requestDocFiles。
|
|
417
|
+
*
|
|
418
|
+
* 为什么重试也要清缓存:失败路径本来就不写缓存,但"半截成功后又被判失败"这类情况
|
|
419
|
+
* 可能留下旧值;清掉能保证重试拿到的就是服务端当前结果。
|
|
420
|
+
* @param {object} model
|
|
421
|
+
*/
|
|
422
|
+
function retryDocFiles(model) {
|
|
423
|
+
if (!model) return;
|
|
424
|
+
const key = docFilesKeyOf(model);
|
|
425
|
+
docFilesFailures.delete(key);
|
|
426
|
+
docFilesCache.delete(key);
|
|
427
|
+
docFilesPending.delete(key);
|
|
428
|
+
renderDocFilesHint(model);
|
|
429
|
+
requestDocFiles(model, key);
|
|
430
|
+
}
|
|
431
|
+
|
|
361
432
|
function requestDocFiles(model, key) {
|
|
362
433
|
if (docFilesPending.has(key)) return;
|
|
363
434
|
docFilesPending.add(key);
|
|
@@ -385,7 +456,9 @@ function requestDocFiles(model, key) {
|
|
|
385
456
|
if (!names.length) return;
|
|
386
457
|
docFilesStats.lists += 1;
|
|
387
458
|
docFilesCache.set(key, names);
|
|
459
|
+
docFilesFailures.delete(key); // 成功即清失败态:提示行随之消失
|
|
388
460
|
publishDocFilesStats();
|
|
461
|
+
renderDocFilesHint(model);
|
|
389
462
|
// 清单就绪 ⇒ 面板重建一次,让插件的 render 拿到真实清单(与插件装载走同一条重建链)
|
|
390
463
|
if (state.panel) {
|
|
391
464
|
docFilesStats.repaints += 1;
|
|
@@ -402,6 +475,12 @@ function requestDocFiles(model, key) {
|
|
|
402
475
|
stageLog('warn', `目录清单获取失败:${String(model.name || model.id || '')}(${reason})——该条目文件列表保持为空,可再次触发重试`, {
|
|
403
476
|
rootId: rawRootId, url, reason, timeoutMs: DOC_FILES_TIMEOUT_MS,
|
|
404
477
|
});
|
|
478
|
+
// 用户可见性:把"失败了、可以再试"直接摆在文件区(面板顶部叠加一行),
|
|
479
|
+
// **不做自动重试** —— 自动重试会把"服务端持续挂"伪装成"慢一点但能出来"。
|
|
480
|
+
docFilesFailures.set(key, { reason, at: Date.now(), timedOut });
|
|
481
|
+
docFilesStats.failures += 1;
|
|
482
|
+
publishDocFilesStats();
|
|
483
|
+
renderDocFilesHint(model);
|
|
405
484
|
} finally {
|
|
406
485
|
docFilesPending.delete(key);
|
|
407
486
|
}
|
|
@@ -446,11 +525,17 @@ function slotPropsFor(entry) {
|
|
|
446
525
|
get document() {
|
|
447
526
|
const m = state.selectedModel;
|
|
448
527
|
if (!m) return null;
|
|
528
|
+
const files = docFilesOf(m);
|
|
529
|
+
// filesError / retryFiles 与 files 同批给出:插件既能显示"为什么空",也能自己接一个重试入口,
|
|
530
|
+
// 而不必猜宿主内部状态(两者都只是**读**与**复用**宿主已有请求路径,不新开链路)。
|
|
531
|
+
const failure = docFilesFailures.get(docFilesKeyOf(m)) || null;
|
|
449
532
|
return {
|
|
450
533
|
rootId: m.rootId ? String(m.rootId) : null,
|
|
451
534
|
rel: m.rel ? String(m.rel) : null,
|
|
452
535
|
kind: m.kind ? String(m.kind) : (m.container ? 'bundle' : null),
|
|
453
|
-
files
|
|
536
|
+
files,
|
|
537
|
+
filesError: failure ? String(failure.reason) : null,
|
|
538
|
+
retryFiles: () => retryDocFiles(m),
|
|
454
539
|
};
|
|
455
540
|
},
|
|
456
541
|
/** 插件写设置也走面板同一条通道(校验 → 生效 → 落盘) */
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// roster/events.js —— 名册的 DOM 事件层(从 view.js 原样搬出:监听目标、分派规则、文案一字不改)
|
|
2
|
+
//
|
|
3
|
+
// 负责什么:
|
|
4
|
+
// · 树行的事件委托(点击 + 键盘 Enter/空格,两者共用同一个 activate 分派);
|
|
5
|
+
// · 类型胶囊(点击 / 清除)、游戏行「加载失败·重试」、扫描状态行〔重试〕;
|
|
6
|
+
// · 搜索框输入(含"首次输入补全量")、「收起」按钮、用户滚动信号挂载。
|
|
7
|
+
//
|
|
8
|
+
// 不负责什么:DOM 结构与文案(render.js)、加载编排(load.js)、装配(view.js)。
|
|
9
|
+
// 依赖方向(单向):store ← load ← events ← view。
|
|
10
|
+
// load 提供"点开要加载"的动作,render 提供"改完重画",本文件只做分派。
|
|
11
|
+
|
|
12
|
+
import { saveState, saveLastModel } from './persist.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps 全部由 view 注入
|
|
16
|
+
* @param {object} deps.store 名册 store(状态读写一律走它的动作)
|
|
17
|
+
* @param {HTMLElement} deps.listEl 名册列表容器(树行的委托挂在这里)
|
|
18
|
+
* @param {HTMLElement} deps.searchEl 搜索框
|
|
19
|
+
* @param {HTMLElement} deps.collapseBtn 「收起」按钮
|
|
20
|
+
* @param {HTMLElement|null} deps.typeFilterEl 类型胶囊行容器(null ⇒ 本功能整体不生效)
|
|
21
|
+
* @param {object} deps.reveal 定位控制器(挂用户滚动信号 + 选中后定位)
|
|
22
|
+
* @param {() => void} deps.render 立即整棵重绘
|
|
23
|
+
* @param {object} deps.load 加载编排(loadGroup / retryScan / ensureGroupLoaded / ensureAllLoaded)
|
|
24
|
+
* @param {Map} deps.modelById modelId → { model, rootId }(选中后回调 onSelect 用)
|
|
25
|
+
* @param {Function} [deps.onSelect] 选中变体后的回调(装载由调用方负责)
|
|
26
|
+
*/
|
|
27
|
+
export function createRosterEvents({
|
|
28
|
+
store, listEl, searchEl, collapseBtn, typeFilterEl, reveal, render, load, modelById, onSelect,
|
|
29
|
+
}) {
|
|
30
|
+
const state = store.state;
|
|
31
|
+
|
|
32
|
+
/** 点一级:折叠/展开游戏(首次展开触发该游戏所有根的数据加载) */
|
|
33
|
+
async function onGroupClick(groupKey) {
|
|
34
|
+
if (state.expandedGroups.has(groupKey)) {
|
|
35
|
+
store.toggleGroup(groupKey);
|
|
36
|
+
render();
|
|
37
|
+
saveState(state);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
store.expandGroup(groupKey);
|
|
41
|
+
// 先渲染一次:已加载过的游戏直接出角色行(ensureGroupLoaded 在无待加载根时不会单独渲染)
|
|
42
|
+
render();
|
|
43
|
+
const group = state.groups.find((g) => g.key === groupKey);
|
|
44
|
+
if (group) await load.ensureGroupLoaded(group);
|
|
45
|
+
saveState(state);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 点二级:折叠/展开角色(变体已在内存,不发请求) */
|
|
49
|
+
function onCharClick(charKey) {
|
|
50
|
+
store.toggleChar(charKey);
|
|
51
|
+
render();
|
|
52
|
+
saveState(state);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 点三级:选中变体(高亮 + 记录状态,M4 用它去渲染) */
|
|
56
|
+
function onVariantClick(modelId, groupKey, charKey) {
|
|
57
|
+
const group = state.groups.find((g) => g.key === groupKey);
|
|
58
|
+
store.selectModel({ modelId, groupKey, groupName: group ? group.name : null, charKey });
|
|
59
|
+
store.setActiveGroup(group ? group.name : state.activeGroup);
|
|
60
|
+
// 记「上次所用模型」指针(rootId/kind/modelId):下次冷启动只加载它所在的那一个根
|
|
61
|
+
try { saveLastModel((modelById.get(modelId) || {}).model || null); } catch { /* 指针只是加速启动 */ }
|
|
62
|
+
render();
|
|
63
|
+
store.setLastReveal(reveal.revealActive()); // 选中后把这一行滚到可视区(已可见/用户正在滚都会跳过)
|
|
64
|
+
saveState(state);
|
|
65
|
+
if (typeof onSelect === 'function') {
|
|
66
|
+
const hit = modelById.get(modelId);
|
|
67
|
+
// 把装载的 promise 交回调用方:冷启动要能等"首个模型真的出来"再放行后面的加载队列,
|
|
68
|
+
// 否则队列的整根扫描会和首个模型的资源请求抢同一份磁盘 IO。
|
|
69
|
+
return onSelect(hit ? hit.model : null);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 事件委托:树行是动态生成的,统一在容器上监听一次 */
|
|
74
|
+
function bindEvents() {
|
|
75
|
+
/** 按行的类型分派到对应动作(鼠标与键盘共用,避免两套逻辑走偏) */
|
|
76
|
+
function activate(row) {
|
|
77
|
+
if (row.dataset.modelId) {
|
|
78
|
+
onVariantClick(row.dataset.modelId, row.dataset.groupKey, row.dataset.charKey);
|
|
79
|
+
} else if (row.dataset.charKey) {
|
|
80
|
+
onCharClick(row.dataset.charKey);
|
|
81
|
+
} else if (row.dataset.groupKey) {
|
|
82
|
+
onGroupClick(row.dataset.groupKey);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 用户滚动信号由 reveal 控制器统一挂(滚轮 / 触摸 / 指针 / 滚动条)
|
|
87
|
+
reveal.attachUserScrollListeners();
|
|
88
|
+
|
|
89
|
+
listEl.addEventListener('click', (e) => {
|
|
90
|
+
const row = e.target.closest('[data-model-id], [data-char-key], [data-group-key]');
|
|
91
|
+
if (row) activate(row);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 键盘可达:树行带 tabindex="0",Enter / 空格与点击等价
|
|
95
|
+
listEl.addEventListener('keydown', (e) => {
|
|
96
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
97
|
+
const row = e.target.closest('[data-model-id], [data-char-key], [data-group-key]');
|
|
98
|
+
if (!row) return;
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
activate(row);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// 类型胶囊:事件委托(胶囊每次重渲染都会换新节点,逐个绑定会漏)
|
|
104
|
+
if (typeFilterEl) {
|
|
105
|
+
typeFilterEl.addEventListener('click', (event) => {
|
|
106
|
+
const button = event.target && event.target.closest ? event.target.closest('button') : null;
|
|
107
|
+
if (!button || !typeFilterEl.contains(button)) return;
|
|
108
|
+
if (button.dataset.role === 'clear-type-filter') {
|
|
109
|
+
store.clearTypeFilter();
|
|
110
|
+
} else {
|
|
111
|
+
const typeKey = button.dataset.typeKey;
|
|
112
|
+
if (!typeKey) return;
|
|
113
|
+
store.toggleTypeFilter(typeKey);
|
|
114
|
+
}
|
|
115
|
+
// 不写 localStorage:筛选只在内存里生效(刷新后回到不筛选),
|
|
116
|
+
// 否则旧的筛选会在下次启动时把上次所用的模型挡住。
|
|
117
|
+
render();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 游戏行的「加载失败 · 重试」:事件委托,且不能顺带折叠/展开这一行。
|
|
122
|
+
// 「未加载 · 点击加载」按钮已经取消(改由加载队列自动推进),所以这里只处理重试。
|
|
123
|
+
listEl.addEventListener('click', (event) => {
|
|
124
|
+
const target = event.target && event.target.closest ? event.target.closest('[data-group-retry]') : null;
|
|
125
|
+
if (!target) return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
load.loadGroup(target.dataset.groupRetry, { reset: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// 扫描状态行上的〔重试〕:事件委托(行会被重建,逐个绑会漏)
|
|
132
|
+
listEl.addEventListener('click', (event) => {
|
|
133
|
+
const button = event.target && event.target.closest ? event.target.closest('[data-scan-retry]') : null;
|
|
134
|
+
if (!button) return;
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
event.stopPropagation(); // 别让点重试顺带把游戏行折叠/展开
|
|
137
|
+
load.retryScan(button.dataset.scanRetry);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
searchEl.addEventListener('input', () => {
|
|
141
|
+
store.setKeyword(searchEl.value);
|
|
142
|
+
// 搜索要全局数据:首次输入时把所有游戏补上(之后都走缓存,不再请求)
|
|
143
|
+
if (state.keyword && [...state.groupChars.keys()].length < state.groups.length) {
|
|
144
|
+
load.ensureAllLoaded();
|
|
145
|
+
} else {
|
|
146
|
+
render();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 「收起」:只清空两级展开状态,面板保留
|
|
151
|
+
collapseBtn.addEventListener('click', () => {
|
|
152
|
+
store.collapseAll();
|
|
153
|
+
render();
|
|
154
|
+
saveState(state);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { bindEvents, onGroupClick, onCharClick, onVariantClick };
|
|
159
|
+
}
|