@ash-qw/dsh-theme-prts 1.1.1 → 1.2.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web",
6
+ "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
7
7
  "keywords": [
8
8
  "deepseek-harness",
9
9
  "dsh",
@@ -1,10 +1,101 @@
1
1
  import { mountEmblemReorder } from './emblem-reorder.js'
2
2
  import { PARTICLE_DENSITY_PROFILES } from './particle-field-adapter.js'
3
3
 
4
+ // Smooth, detect and thin luminance edges before snapping them to each density's grid.
5
+ // Transparent pixels are composited on white so hidden RGB cannot introduce contours.
6
+ export function extractEmblemLines(rgba, size, threshold, source = 'edges') {
7
+ const count = size * size, luminance = new Float32Array(count), smooth = new Float32Array(count)
8
+ for (let i = 0; i < count; i++) {
9
+ const j = i * 4, opacity = rgba[j + 3] / 255
10
+ luminance[i] = (rgba[j] * .2126 + rgba[j + 1] * .7152 + rgba[j + 2] * .0722) * opacity + 255 * (1 - opacity)
11
+ }
12
+ // Existing ink strokes must be sampled through their centers, not both borders.
13
+ if (source === 'ink') return Uint8Array.from(luminance, value => 255 - value >= threshold / 4 ? 1 : 0)
14
+ const sample = (values, x, y) => values[Math.max(0, Math.min(size - 1, y)) * size + Math.max(0, Math.min(size - 1, x))]
15
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
16
+ let sum = 0
17
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
18
+ sum += sample(luminance, x + dx, y + dy) * (dx === 0 ? 2 : 1) * (dy === 0 ? 2 : 1)
19
+ }
20
+ smooth[y * size + x] = sum / 16
21
+ }
22
+ const magnitude = new Float32Array(count), direction = new Uint8Array(count)
23
+ for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) {
24
+ const i = y * size + x
25
+ const gx = -smooth[i-size-1] + smooth[i-size+1] - 2*smooth[i-1] + 2*smooth[i+1] - smooth[i+size-1] + smooth[i+size+1]
26
+ const gy = -smooth[i-size-1] - 2*smooth[i-size] - smooth[i-size+1] + smooth[i+size-1] + 2*smooth[i+size] + smooth[i+size+1]
27
+ magnitude[i] = Math.hypot(gx, gy) / 4
28
+ const angle = (Math.atan2(gy, gx) * 180 / Math.PI + 180) % 180
29
+ direction[i] = angle < 22.5 || angle >= 157.5 ? 0 : angle < 67.5 ? 1 : angle < 112.5 ? 2 : 3
30
+ }
31
+ const edges = new Uint8Array(count), weak = new Uint8Array(count), pending = []
32
+ const high = threshold / 4, low = high * .4, offsets = [1, size + 1, size, size - 1]
33
+ for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) {
34
+ const i = y * size + x, strength = magnitude[i], offset = offsets[direction[i]]
35
+ if (strength < low || strength < magnitude[i-offset] || strength <= magnitude[i+offset]) continue
36
+ weak[i] = 1
37
+ if (strength >= high) { edges[i] = 1; pending.push(i) }
38
+ }
39
+ // Hysteresis keeps faint continuations of strong lines without retaining isolated noise.
40
+ while (pending.length) {
41
+ const i = pending.pop()
42
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
43
+ const next = i + dy * size + dx
44
+ if (weak[next] && !edges[next]) { edges[next] = 1; pending.push(next) }
45
+ }
46
+ }
47
+ return edges
48
+ }
49
+
50
+ // Zhang–Suen thinning preserves connected strokes and loops on the output grid.
51
+ export function simplifyEmblemLines(cells, size, cleanup = 2) {
52
+ const mask = new Uint8Array(size * size)
53
+ for (const i of cells) mask[i] = 1
54
+ const around = [-size, -size+1, 1, size+1, size, size-1, -1, -size-1]
55
+ let changed = true
56
+ while (changed) {
57
+ changed = false
58
+ for (let pass = 0; pass < 2; pass++) {
59
+ const remove = []
60
+ for (let y = 1; y < size-1; y++) for (let x = 1; x < size-1; x++) {
61
+ const i = y*size+x
62
+ if (!mask[i]) continue
63
+ const p = around.map(offset => mask[i+offset]), count = p.reduce((a,b)=>a+b,0)
64
+ if (count < 2 || count > 6) continue
65
+ let transitions = 0
66
+ for (let k = 0; k < 8; k++) if (!p[k] && p[(k+1)%8]) transitions++
67
+ if (transitions !== 1) continue
68
+ if (pass === 0 ? p[0]*p[2]*p[4] || p[2]*p[4]*p[6] : p[0]*p[2]*p[6] || p[0]*p[4]*p[6]) continue
69
+ remove.push(i)
70
+ }
71
+ for (const i of remove) mask[i] = 0
72
+ if (remove.length) changed = true
73
+ }
74
+ }
75
+ if (cleanup > 0) {
76
+ const seen = new Uint8Array(mask.length)
77
+ for (let i = 0; i < mask.length; i++) {
78
+ if (!mask[i] || seen[i]) continue
79
+ const component = [i]; seen[i] = 1
80
+ for (let j = 0; j < component.length; j++) {
81
+ const n = component[j], x = n % size, y = Math.floor(n / size)
82
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
83
+ if (x+dx<0 || x+dx>=size || y+dy<0 || y+dy>=size) continue
84
+ const next = n + dy*size+dx
85
+ if (mask[next] && !seen[next]) { seen[next]=1; component.push(next) }
86
+ }
87
+ }
88
+ if (component.length <= cleanup) for (const n of component) mask[n] = 0
89
+ }
90
+ }
91
+ return new Set(Array.from(mask, (value,i)=>value?i:-1).filter(i=>i>=0))
92
+ }
93
+
4
94
  // Shared with the image worker; inject the same density profiles into its scope.
5
- export function processCustomEmblemPixels(rgba, size, mode = 'auto', threshold = 128, profiles = PARTICLE_DENSITY_PROFILES) {
6
- if (size !== 320 || rgba.length !== size * size * 4) throw new Error('图片数据无效')
95
+ export function processCustomEmblemPixels(rgba, size, mode = 'auto', threshold = 128, profiles = PARTICLE_DENSITY_PROFILES, lineOptions = {}) {
96
+ if ((!Number.isInteger(size) || size < 320 || size > 640) || rgba.length !== size * size * 4) throw new Error('图片数据无效')
7
97
  const alpha = new Uint8Array(size * size)
98
+ const lines = mode === 'lineart' ? extractEmblemLines(rgba, size, threshold, lineOptions.lineSource) : null
8
99
  let transparent = false
9
100
  for (let i = 3; i < rgba.length; i += 4) if (rgba[i] < 128) { transparent = true; break }
10
101
  const method = mode === 'auto' ? (transparent ? 'alpha' : 'dark') : mode
@@ -18,10 +109,18 @@ export function processCustomEmblemPixels(rgba, size, mode = 'auto', threshold =
18
109
  const targets = {}
19
110
  for (const [density, { pitch }] of Object.entries(profiles)) {
20
111
  const points = []
112
+ // Map every contour pixel to its containing lattice cell. Unlike point sampling,
113
+ // this retains thin strokes between lattice centers at every density and crop.
114
+ const gridSize = Math.ceil(size / pitch) + 1
115
+ let lineCells = new Set()
116
+ if (lines) for (let i = 0; i < lines.length; i++) if (lines[i]) {
117
+ lineCells.add(Math.floor(Math.floor(i / size) / pitch) * gridSize + Math.floor((i % size) / pitch))
118
+ }
119
+ if (lines) lineCells = simplifyEmblemLines(lineCells, gridSize, lineOptions.cleanup ?? 2)
21
120
  for (let y = pitch / 2, row = 0; y < size; y += pitch, row++) {
22
121
  for (let x = pitch / 2, column = 0; x < size; x += pitch, column++) {
23
- if (!at(x, y)) continue
24
- const edge = !at(x - pitch, y) || !at(x + pitch, y) || !at(x, y - pitch) || !at(x, y + pitch)
122
+ if (lines ? !lineCells.has(row * gridSize + column) : !at(x, y)) continue
123
+ const edge = Boolean(lines) || !at(x - pitch, y) || !at(x + pitch, y) || !at(x, y - pitch) || !at(x, y + pitch)
25
124
  points.push({ x: (x - size / 2) / size, y: (y - size / 2) / size,
26
125
  kind: edge ? 'skeleton' : 'fill', latticeColumn: column, latticeRow: row, component: 0 })
27
126
  }
@@ -67,6 +166,25 @@ export function createCustomEmblemStore({ window, emblems = [], preview = () =>
67
166
  if (!response.ok) throw new Error(result.error || '无法连接徽记服务')
68
167
  return result
69
168
  }
169
+ // Draft IDs are immutable. Bound the cache and share in-flight reads.
170
+ const drafts = new Map()
171
+ const remember = result => {
172
+ if (!disposed) {
173
+ drafts.delete(result.draftId); drafts.set(result.draftId,Promise.resolve(result))
174
+ while (drafts.size > 24) drafts.delete(drafts.keys().next().value)
175
+ }
176
+ return result
177
+ }
178
+ const getDraft = id => {
179
+ if (drafts.has(id)) return drafts.get(id)
180
+ const pending = request(`/draft/${encodeURIComponent(id)}`).catch(error => {
181
+ if (drafts.get(id) === pending) drafts.delete(id)
182
+ throw error
183
+ })
184
+ drafts.set(id,pending)
185
+ while (drafts.size > 24) drafts.delete(drafts.keys().next().value)
186
+ return pending
187
+ }
70
188
  const accept = state => {
71
189
  if (disposed || state.revision <= revision) return
72
190
  if (!validEmblemConfiguration(state.configuration,emblems)) throw new Error('徽记配置无效')
@@ -108,8 +226,8 @@ export function createCustomEmblemStore({ window, emblems = [], preview = () =>
108
226
  catch(error) { await refresh().catch(()=>{}); throw error }
109
227
  },
110
228
  exportAsset: id => request(`/draft/${encodeURIComponent(id)}/export`),
111
- importAsset: (asset,signal) => request('/import',asset,signal),
112
- getDraft: id => request(`/draft/${encodeURIComponent(id)}`),
229
+ importAsset: (asset,signal) => request('/import',asset,signal).then(remember),
230
+ getDraft,
113
231
  async process({file,draftId,params},signal) {
114
232
  let image
115
233
  if (file) image = await new Promise((resolve,reject)=>{
@@ -117,9 +235,9 @@ export function createCustomEmblemStore({ window, emblems = [], preview = () =>
117
235
  reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=()=>reject(new Error('图片读取失败'))
118
236
  reader.readAsDataURL(file)
119
237
  })
120
- return request('/preview',{image,draftId,params,label:file?.name},signal)
238
+ return request('/preview',{image,draftId,params,label:file?.name},signal).then(remember)
121
239
  },
122
- dispose() { disposed=true; controller.abort(); window.clearTimeout(reconnectTimer);events?.close();subscribers.clear();window.removeEventListener('focus',focus) },
240
+ dispose() { disposed=true; drafts.clear(); controller.abort(); window.clearTimeout(reconnectTimer);events?.close();subscribers.clear();window.removeEventListener('focus',focus) },
123
241
  }
124
242
  }
125
243
 
@@ -131,22 +249,49 @@ export function customEmblemMarkup() {
131
249
  <ol data-prts-emblem-list></ol>
132
250
  <div data-prts-emblem-actions><button type="button" data-prts-emblem-add>新增徽记</button><button type="button" data-prts-emblem-reset-list>恢复内置列表</button></div>
133
251
  </div>
134
- <div data-prts-emblem-workspace hidden>
252
+ <dialog data-prts-emblem-reset-confirm hidden aria-label="确认恢复内置徽记">
253
+ <strong data-prts-emblem-reset-title></strong>
254
+ <p>是否先导出自定义徽记?恢复内置后,以下徽记将从此处移除;未导出的素材将无法从设置中找回。请确认下载文件已保存,再恢复内置。</p>
255
+ <div data-prts-emblem-reset-exports></div>
256
+ <p data-prts-emblem-reset-status role="status"></p>
257
+ <div data-prts-emblem-actions>
258
+ <button type="button" data-prts-emblem-reset-cancel autofocus>取消</button>
259
+ <button type="button" data-prts-emblem-reset-proceed>确认恢复内置</button>
260
+ </div>
261
+ </dialog>
262
+ <dialog data-prts-emblem-discard hidden aria-label="放弃未应用的修改">
263
+ <strong>放弃未应用的修改?</strong><p>当前预览和参数尚未应用,关闭后需要重新编辑。</p>
264
+ <div data-prts-emblem-actions><button type="button" data-prts-emblem-keep autofocus>继续编辑</button><button type="button" data-prts-emblem-discard-apply>放弃修改</button></div>
265
+ </dialog>
266
+ <dialog data-prts-emblem-workspace hidden aria-label="编辑粒子徽记">
267
+ <div data-prts-emblem-edit-header>
135
268
  <strong data-prts-emblem-target></strong>
136
- <label>上传替换图片<input type="file" accept="image/png,image/jpeg,image/webp" data-prts-emblem-file></label>
137
- <small>支持 PNG、WebP、JPEG,最大 8 MB / 1600 万像素。透明底标志和黑白剪影效果更好。</small>
138
- <label>或导入徽记素材包<input type="file" accept=".prts-emblem.json,application/json" data-prts-emblem-import></label>
139
- <small>导入 .prts-emblem.json 可恢复原图和调整参数,预览后再应用。</small>
269
+ <button type="button" data-prts-emblem-cancel aria-label="关闭徽记编辑">×</button></div>
270
+ <div data-prts-emblem-edit-body>
271
+ <aside data-prts-emblem-edit-preview><canvas width="280" height="280" data-prts-emblem-preview aria-label="拖动调整徽记位置,也可使用方向键微调" tabindex="0" hidden></canvas><p data-prts-emblem-empty>选择图片后在此预览粒子效果</p></aside>
272
+ <div data-prts-emblem-edit-controls>
273
+ <label>素材来源<select data-prts-emblem-source><option value="image">上传图片</option><option value="package">导入素材包</option></select></label>
274
+ <label data-prts-emblem-source-kind="image">选择图片<input type="file" accept="image/png,image/jpeg,image/webp" data-prts-emblem-file></label>
275
+ <small data-prts-emblem-source-kind="image">支持 PNG、WebP、JPEG,最大 8 MB / 1600 万像素。透明底标志和黑白剪影效果更好。</small>
276
+ <label data-prts-emblem-source-kind="package" hidden>选择素材包<input type="file" accept=".prts-emblem.json,application/json" data-prts-emblem-import></label>
277
+ <small data-prts-emblem-source-kind="package" hidden>导入 .prts-emblem.json 可恢复原图和调整参数,预览后再应用。</small>
140
278
  <div data-prts-emblem-editor hidden>
141
- <label>提取方式<select data-prts-emblem-mode><option value="auto">自动识别</option><option value="alpha">透明轮廓</option><option value="dark">保留暗部</option><option value="light">保留亮部</option></select></label>
279
+ <label>提取方式<select data-prts-emblem-mode><option value="auto">自动识别</option><option value="alpha">透明轮廓</option><option value="dark">保留暗部</option><option value="light">保留亮部</option><option value="lineart">线稿轮廓</option></select></label>
280
+ <small>线稿模式提取轮廓;阈值越高,保留的细节越少。彩色原图建议先由 AI 简化背景与纹理。</small>
281
+ <div data-prts-emblem-line-options hidden>
282
+ <label>线稿来源<select data-prts-emblem-lineSource><option value="edges">彩色图提轮廓</option><option value="ink">纯线稿提笔画</option></select></label>
283
+ <label>去除碎线<input type="range" min="0" max="12" step="1" value="2" data-prts-emblem-cleanup></label>
284
+ <small>数值越大,去掉的孤立碎线越多;五官小细节丢失时可降到 0。</small>
285
+ </div>
142
286
  <label>阈值<input type="range" min="1" max="254" value="128" data-prts-emblem-threshold></label>
143
287
  <label>缩放与留白<input type="range" min="0.5" max="2" step="0.05" value="0.85" data-prts-emblem-zoom></label>
144
- <label>水平裁剪<input type="range" min="-0.5" max="0.5" step="0.025" value="0" data-prts-emblem-x></label>
145
- <label>垂直裁剪<input type="range" min="-0.5" max="0.5" step="0.025" value="0" data-prts-emblem-y></label>
288
+ <input type="hidden" value="0" data-prts-emblem-x><input type="hidden" value="0" data-prts-emblem-y>
289
+ <div data-prts-emblem-actions><button type="button" data-prts-emblem-center>居中</button></div>
290
+ <small>拖动取景框内的图案调整位置;方向键微调,Shift 加快。松手后生成准确预览。</small>
146
291
  </div>
147
- <canvas width="280" height="280" data-prts-emblem-preview aria-label="徽记粒子预览" hidden></canvas>
148
- <div data-prts-emblem-actions><button type="button" data-prts-emblem-apply disabled>应用到此处</button><button type="button" data-prts-emblem-cancel>取消编辑</button></div>
149
- </div>
292
+ </div></div>
293
+ <footer data-prts-emblem-edit-bar><div data-prts-emblem-edit-status></div><div data-prts-emblem-actions><button type="button" data-prts-emblem-cancel>取消编辑</button><button type="button" data-prts-emblem-apply disabled>应用到此处</button></div></footer>
294
+ </dialog>
150
295
  <p role="status" aria-live="polite" data-prts-emblem-status>正在读取徽记设置…</p>
151
296
  </section>`
152
297
  }
@@ -157,47 +302,71 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
157
302
  const get = suffix => root.querySelector(`[data-prts-emblem-${suffix}]`)
158
303
  const fileInput = get('file'), canvas = get('preview'), status = get('status')
159
304
  let file, draft, processingController, sourceDraftId, timer, generation = 0, disposed = false, saving = false
160
- let destination = null, previewValue = null, loaded = false, reorder
161
- const draw = (value, pointer) => {
305
+ let destination = null, previewValue = null, loaded = false, reorder, pendingReset = null, dirty = false, editTrigger = null
306
+ let drag = null, dragFrame = null, rendered = null, processing = false, pendingPosition = false
307
+ const draw = (value, offset) => {
308
+ if (!offset) rendered = value ? {value,x:Number(get('x').value),y:Number(get('y').value)} : null
162
309
  canvas.hidden = !value
310
+ get('empty').hidden = Boolean(value)
163
311
  const ctx = canvas.getContext('2d')
164
312
  if (!ctx) return
165
313
  ctx.clearRect(0, 0, 280, 280)
166
314
  ctx.fillStyle = '#0d1114'; ctx.fillRect(0, 0, 280, 280)
315
+ canvas.setAttribute('aria-disabled', String(!file && !sourceDraftId))
316
+ if(!drag)canvas.style.cursor=file || sourceDraftId?'grab':'default'
317
+ ctx.save();ctx.beginPath();ctx.rect(15,15,250,250);ctx.clip()
167
318
  ctx.fillStyle = '#edf1f2'
168
- for (const point of value?.targets.standard ?? []) {
169
- let x = 140 + point.x * 250, y = 140 + point.y * 250
170
- if (pointer) {
171
- const dx = x - pointer.x, dy = y - pointer.y, distance = Math.hypot(dx, dy)
172
- if (distance > 0 && distance < 48) { const shift = (48 - distance) * .35; x += dx / distance * shift; y += dy / distance * shift }
173
- }
319
+ for (const point of value?.previewPoints ?? value?.targets.standard ?? []) {
320
+ const x = 140 + (point.x + (offset?.x || 0)) * 250, y = 140 + (point.y + (offset?.y || 0)) * 250
174
321
  ctx.fillRect(x, y, point.kind === 'skeleton' ? 2 : 1.5, point.kind === 'skeleton' ? 2 : 1.5)
175
322
  }
323
+ ctx.restore();ctx.strokeStyle='#68747a';ctx.strokeRect(15.5,15.5,249,249)
176
324
  }
177
- const stop = () => { processingController?.abort(); processingController = undefined }
325
+
326
+ let rerunRequested = false, lastEditParams = ''
327
+ const readParams = () => ({mode:get('mode').value,lineSource:get('lineSource').value,cleanup:Number(get('cleanup').value),threshold:Number(get('threshold').value),zoom:Number(get('zoom').value),x:Number(get('x').value),y:Number(get('y').value)})
328
+ const stop = () => { processingController?.abort(); processingController = undefined; processing = false; rerunRequested = false }
329
+
178
330
  const process = async () => {
331
+ window.clearTimeout(timer);pendingPosition=false
332
+ if (processing) { rerunRequested = true; return }
333
+ get('line-options').hidden = get('mode').value !== 'lineart'
179
334
  if ((!file && !sourceDraftId) || disposed) return
180
335
  const token = ++generation
181
336
  stop(); draft = null; get('apply').disabled = true
337
+ processing = true
182
338
  status.textContent = '正在处理图片…'
183
- processingController = new window.AbortController()
339
+ const controller = processingController = new window.AbortController()
340
+ const params = readParams(), sourceFile = file
341
+ lastEditParams = JSON.stringify(params)
184
342
  try {
185
- const result = await store.process({file,draftId:file?undefined:sourceDraftId,params:{mode:get('mode').value,threshold:Number(get('threshold').value),zoom:Number(get('zoom').value),x:Number(get('x').value),y:Number(get('y').value)}},processingController.signal)
343
+ const result = await store.process({file:sourceFile,draftId:sourceFile?undefined:sourceDraftId,params},controller.signal)
344
+ if (!disposed && processingController === controller && !controller.signal.aborted) {
345
+ // Reuse the uploaded source even when a newer parameter value is waiting.
346
+ sourceDraftId = result.draftId; file = null
347
+ }
186
348
  if (disposed || token !== generation) return
187
- draft = {...result.value,draftId:result.draftId,label:result.label}
349
+ draft = {...result.value,previewPoints:result.previewPoints,draftId:result.draftId,label:result.label}
188
350
  draw(draft); get('apply').disabled = saving
189
- status.textContent = `标准密度预览:${draft.targets.standard.length} 个粒子 · 尚未应用,可移动鼠标查看扰动`
351
+ status.textContent = `标准密度预览:${draft.targets.standard.length} 个粒子 · 尚未应用,可拖动取景框调整位置`
190
352
  } catch(error) {
191
353
  if (disposed || token !== generation) return
192
354
  status.textContent = error.message || '图片处理失败';draw(null)
355
+ } finally {
356
+ if (processingController === controller) {
357
+ processing = false; processingController = undefined
358
+ if (rerunRequested && !disposed) { rerunRequested = false; void process() }
359
+ }
193
360
  }
194
361
  }
195
362
  const importAsset = async input => {
196
363
  const selected=input.files?.[0]
197
364
  if (!selected) return
198
365
  input.value=''
366
+ dirty = true
199
367
  const token=++generation
200
368
  stop();window.clearTimeout(timer);draft=null;get('apply').disabled=true
369
+ processing=true
201
370
  status.textContent='正在导入徽记素材…'
202
371
  processingController=new window.AbortController()
203
372
  const signal=processingController.signal
@@ -209,14 +378,21 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
209
378
  const result=await store.importAsset(asset,signal)
210
379
  if(disposed || token!==generation)return
211
380
  file=null;fileInput.value='';sourceDraftId=result.draftId
212
- draft={...result.value,draftId:result.draftId,label:result.label}
213
- for(const [key,value] of Object.entries(result.params))get(key).value=String(value)
381
+ draft={...result.value,previewPoints:result.previewPoints,draftId:result.draftId,label:result.label}
382
+ for(const [key,value] of Object.entries({lineSource:'edges',cleanup:2,...result.params}))get(key).value=String(value)
383
+ get('line-options').hidden = get('mode').value !== 'lineart'
214
384
  get('editor').hidden=false;draw(draft);get('apply').disabled=saving
215
385
  status.textContent='素材已导入预览,可继续调整;点击“应用到此处”才会保存'
216
386
  }catch(error){if(!disposed && token===generation)status.textContent=error.message}
387
+ finally{if(token===generation)processing=false}
388
+ }
389
+ const selectSource = value => {
390
+ get('source').value = value
391
+ for (const element of root.querySelectorAll('[data-prts-emblem-source-kind]')) element.hidden = element.dataset.prtsEmblemSourceKind !== value
217
392
  }
218
393
  const change = event => {
219
394
  if (saving) return
395
+ if(event.target===get('source')) { selectSource(event.target.value); return }
220
396
  if(event.target===get('import')) {
221
397
  if(event.type==='change')void importAsset(event.target)
222
398
  return
@@ -230,14 +406,59 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
230
406
  get('apply').disabled = true; get('editor').hidden = true; draw(previewValue)
231
407
  status.textContent = '请选择不超过 8 MB 的 PNG、WebP 或 JPEG 图片'; fileInput.value = ''; return
232
408
  }
233
- file = next; get('editor').hidden = false
409
+ stop(); dirty = true; file = next; get('editor').hidden = false
234
410
  process()
235
411
  } else if (event.target.closest('[data-prts-emblem-editor]')) {
236
- window.clearTimeout(timer); ++generation; stop(); draft = null; get('apply').disabled = true
237
- timer = window.setTimeout(process, 140)
412
+ const signature = JSON.stringify(readParams())
413
+ if (signature === lastEditParams) return
414
+ lastEditParams = signature
415
+ dirty = true
416
+ window.clearTimeout(timer); ++generation; draft = null; get('apply').disabled = true
417
+ pendingPosition=true;timer = window.setTimeout(process, 140)
418
+ }
419
+ }
420
+ const closeReset = (focus = false) => {
421
+ const trigger = pendingReset?.trigger
422
+ pendingReset = null; get('reset-confirm').close?.(); get('reset-confirm').hidden = true
423
+ get('reset-exports').replaceChildren()
424
+ if (focus && trigger?.isConnected) trigger.focus({preventScroll:true})
425
+ }
426
+ const requestReset = async (scope, trigger) => {
427
+ const state = store.get()
428
+ const entries = (scope === 'hero' ? [state.hero] : state.conversation).filter(e=>e?.kind==='custom')
429
+ if (!entries.length) {
430
+ await persist(scope==='hero'?{...state,hero:null}:{...state,conversation:store.defaults().conversation})
431
+ return
238
432
  }
433
+ closeReset()
434
+ pendingReset = {scope,trigger,entries}
435
+ get('reset-title').textContent = scope==='hero' ? '恢复新会话内置徽记' : '恢复正文内置徽记列表'
436
+ for (const [index,entry] of entries.entries()) {
437
+ const row = window.document.createElement('div')
438
+ row.setAttribute('data-prts-emblem-reset-asset','')
439
+ const label = window.document.createElement('span'); label.textContent = entry.label
440
+ const exportButton = button('导出素材','export-reset',index)
441
+ const icon = window.document.createElement('span'); icon.textContent = '↓'; icon.setAttribute('aria-hidden','true')
442
+ exportButton.prepend(icon)
443
+ row.append(thumbnail(entry),label,exportButton)
444
+ get('reset-exports').append(row)
445
+ }
446
+ get('reset-confirm').hidden = false
447
+ get('reset-status').textContent = ''
448
+ get('reset-confirm').showModal()
449
+ get('reset-cancel').focus({preventScroll:true})
450
+ }
451
+ const closeDiscard = () => { get('discard').close?.(); get('discard').hidden = true }
452
+ const requestClose = () => {
453
+ if (saving) return
454
+ if (!dirty) { cancel(); return }
455
+ get('discard').hidden = false; get('discard').showModal()
239
456
  }
240
457
  const cancel = () => {
458
+ endDrag(false); processing=false;pendingPosition=false;lastEditParams=''
459
+ closeReset(); closeDiscard(); dirty = false
460
+ get('workspace').close?.(); root.append(status)
461
+ editTrigger?.isConnected && editTrigger.focus({preventScroll:true})
241
462
  ++generation; stop(); window.clearTimeout(timer)
242
463
  file = null; draft = null; sourceDraftId = null; previewValue = null; destination = null; fileInput.value = ''
243
464
  get('import').value = ''
@@ -285,33 +506,65 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
285
506
  get('add').disabled = saving || !loaded || state.conversation.length >= 12
286
507
  get('reset-list').disabled = saving || !loaded
287
508
  }
509
+ let prefetchEnabled = false
510
+ const prefetch = async () => {
511
+ prefetchEnabled = true
512
+ await store.ready
513
+ if (disposed) return
514
+ const state = store.get()
515
+ const ids = new Set([state.hero,...state.conversation].map(e=>e?.draftId).filter(Boolean))
516
+ // Keep background reads sequential; opening an entry shares its pending request.
517
+ for (const id of ids) {
518
+ if (disposed) return
519
+ await store.getDraft(id).catch(()=>{})
520
+ }
521
+ }
522
+ const setLoading = loading => {
523
+ get('edit-controls').style.visibility = loading ? 'hidden' : ''
524
+ get('edit-controls').inert = loading
525
+ get('workspace').setAttribute('aria-busy',String(loading))
526
+ }
288
527
  const startEdit = async target => {
289
- cancel(); destination = target
528
+ const trigger = window.document.activeElement
529
+ cancel(); editTrigger = trigger; destination = target
290
530
  const state = store.get()
291
531
  const entry = target === 'hero' ? state.hero : target === 'new' ? null : state.conversation[target]
292
532
  previewValue = target === 'new' ? null : entryPreview(entry)
293
533
  get('target').textContent = target === 'hero' ? '编辑新会话徽记' : target === 'new' ? '新增正文轮换徽记' : `替换正文第 ${target+1} 张徽记`
534
+ selectSource('image')
294
535
  get('workspace').hidden = false
295
- for (const [key,value] of Object.entries({mode:'auto',threshold:'128',zoom:'0.85',x:'0',y:'0'})) get(key).value = value
536
+ get('edit-body').style.height = entry?.draftId ? '60dvh' : ''
537
+ setLoading(Boolean(entry?.draftId))
538
+ for (const [key,value] of Object.entries({mode:'auto',lineSource:'edges',cleanup:'2',threshold:'128',zoom:'0.85',x:'0',y:'0'})) get(key).value = value
539
+ get('line-options').hidden = true
296
540
  draw(previewValue)
297
541
  status.textContent = '上传图片或导入徽记素材包后可调整并预览,点击“应用到此处”才会保存'
298
- get('workspace').scrollIntoView?.({block:'nearest'})
542
+ if (entry?.draftId) status.textContent = '正在读取徽记设置…'
543
+ get('edit-status').append(status)
544
+ get('workspace').showModal()
299
545
  if (entry?.draftId) {
300
546
  const token = generation
301
547
  try {
302
548
  const saved = await store.getDraft(entry.draftId)
303
549
  if (disposed || token !== generation) return
304
550
  sourceDraftId = saved.draftId
305
- for (const [key,value] of Object.entries(saved.params)) get(key).value = String(value)
551
+ for (const [key,value] of Object.entries({lineSource:'edges',cleanup:2,...saved.params})) get(key).value = String(value)
552
+ get('line-options').hidden = get('mode').value !== 'lineart'
306
553
  get('editor').hidden = false
554
+ draw({...saved.value,previewPoints:saved.previewPoints})
307
555
  status.textContent = '可继续调整原图参数,或上传替换图片;应用后才会保存'
308
- } catch(error) { if (!disposed && token === generation) status.textContent = error.message }
556
+ setLoading(false)
557
+ if (!saved.previewPoints) await process()
558
+ } catch(error) { if (!disposed && token === generation) status.textContent = `${error.message};可关闭后重试,或选择替换素材` }
559
+ finally { if (!disposed && token === generation) setLoading(false) }
309
560
  }
310
561
 
311
562
  }
312
563
  const unsubscribe = store.subscribe(() => {
564
+ closeReset()
313
565
  if (destination !== null && !saving) { cancel();status.textContent='徽记设置已在其他页面或会话更新,请重新选择要编辑的徽记' }
314
566
  renderList()
567
+ if (prefetchEnabled) void prefetch()
315
568
  })
316
569
  store.ready.then(() => { if (!disposed) { loaded = true; renderList(); status.textContent = '新会话与正文徽记独立保存;正文至少保留一张' } })
317
570
  const persist = async value => {
@@ -333,10 +586,23 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
333
586
  const click = async event => {
334
587
  const target = event.target.closest('button')
335
588
  if (!target || target.disabled || saving || !loaded || reorder?.active) return
336
- if (target.hasAttribute('data-prts-emblem-cancel')) { cancel(); status.textContent = '已取消编辑,当前徽记保持不变'; return }
589
+ if (target.hasAttribute('data-prts-emblem-center')) {
590
+ if ((!file && !sourceDraftId) || processing) return
591
+ get('x').value='0';get('y').value='0';dirty=true;await process();return
592
+ }
593
+ if (target.hasAttribute('data-prts-emblem-keep')) { closeDiscard(); return }
594
+ if (target.hasAttribute('data-prts-emblem-discard-apply')) { cancel(); status.textContent='已放弃未应用的修改'; return }
595
+ if (target.hasAttribute('data-prts-emblem-reset-cancel')) { closeReset(true); return }
596
+ if (target.hasAttribute('data-prts-emblem-reset-proceed')) {
597
+ if (!pendingReset) return
598
+ const {scope} = pendingReset, state = store.get()
599
+ await persist(scope==='hero'?{...state,hero:null}:{...state,conversation:store.defaults().conversation})
600
+ return
601
+ }
602
+ if (target.hasAttribute('data-prts-emblem-cancel')) { requestClose(); return }
337
603
  if (target.hasAttribute('data-prts-emblem-add')) { startEdit('new'); return }
338
604
  const state = store.get()
339
- if (target.hasAttribute('data-prts-emblem-reset-list')) { await persist({...state,conversation:store.defaults().conversation}); return }
605
+ if (target.hasAttribute('data-prts-emblem-reset-list')) { await requestReset('conversation',target); return }
340
606
  if (target.hasAttribute('data-prts-emblem-apply')) {
341
607
  if (!draft || destination === null) return
342
608
  const value = {...draft,kind:'custom',key:window.crypto.randomUUID(),label:(file?.name || draft.label || '自定义徽记').slice(0,100)}
@@ -350,8 +616,8 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
350
616
  return
351
617
  }
352
618
  const action = target.dataset.emblemAction, index = Number(target.dataset.emblemIndex)
353
- if(action==='export-hero'||action==='export') {
354
- const entry=action==='export-hero'?state.hero:state.conversation[index]
619
+ if(action==='export-hero'||action==='export'||action==='export-reset') {
620
+ const entry=action==='export-reset'?pendingReset?.entries[index]:action==='export-hero'?state.hero:state.conversation[index]
355
621
  if(!entry?.draftId)return
356
622
  target.disabled=true
357
623
  try {
@@ -363,12 +629,13 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
363
629
  link.hidden=true;root.append(link);link.click();link.remove()
364
630
  window.setTimeout(()=>window.URL.revokeObjectURL(url),1000)
365
631
  status.textContent='已导出徽记素材包,包含原图和调整参数,可在其他 DSH 中导入'
366
- }catch(error){if(!disposed)status.textContent=`导出失败:${error.message}`}
632
+ if(action==='export-reset' && pendingReset)get('reset-status').textContent='已发起下载,请确认素材包已保存,再恢复内置。'
633
+ }catch(error){if(!disposed){status.textContent=`导出失败:${error.message}`;if(action==='export-reset' && pendingReset)get('reset-status').textContent=status.textContent}}
367
634
  finally{if(!disposed)target.disabled=saving}
368
635
  return
369
636
  }
370
637
  if (action === 'hero') { startEdit('hero'); return }
371
- if (action === 'reset-hero') { await persist({...state,hero:null}); return }
638
+ if (action === 'reset-hero') { await requestReset('hero',target); return }
372
639
  if (!Number.isInteger(index) || index < 0 || index >= state.conversation.length) return
373
640
  if (action === 'edit') { startEdit(index); return }
374
641
  const conversation = [...state.conversation]
@@ -386,19 +653,87 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
386
653
  moved?.querySelector('[data-prts-emblem-reorder]')?.focus({preventScroll:true})
387
654
  },
388
655
  })
656
+ const resetKey = event => {
657
+ if (event.key==='Escape' && destination !== null) {
658
+ event.preventDefault(); event.stopPropagation()
659
+ if (get('discard').open) closeDiscard(); else requestClose()
660
+ return
661
+ }
662
+ if (event.key==='Escape' && pendingReset) { event.preventDefault(); event.stopPropagation(); if(!saving)closeReset(true) }
663
+ }
664
+ const resetCancel = event => { event.preventDefault(); event.stopPropagation(); if(!saving)closeReset(true) }
665
+ const editorCancel = event => {
666
+ if (event.target !== get('workspace') && event.target !== get('discard')) return
667
+ event.preventDefault(); event.stopPropagation()
668
+ if (event.target===get('discard')) closeDiscard(); else requestClose()
669
+ }
670
+ get('workspace').addEventListener('cancel',editorCancel)
671
+ get('discard').addEventListener('cancel',editorCancel)
672
+ get('reset-confirm').addEventListener('cancel',resetCancel)
673
+ root.addEventListener('keydown',resetKey)
674
+ const clampPosition = value => Math.max(-.5,Math.min(.5,value))
675
+ const renderPosition = () => {
676
+ if (rendered) draw(rendered.value,{x:Number(get('x').value)-rendered.x,y:Number(get('y').value)-rendered.y})
677
+ }
678
+ const beginDrag = event => {
679
+ if (event.button!==0 || !event.isPrimary || saving || processing || pendingPosition || (!file && !sourceDraftId) || !rendered || drag) return
680
+ event.preventDefault();canvas.focus({preventScroll:true})
681
+ window.clearTimeout(timer)
682
+ drag={id:event.pointerId,startX:event.clientX,startY:event.clientY,x:Number(get('x').value),y:Number(get('y').value),draft,dirty}
683
+ canvas.setPointerCapture(event.pointerId);canvas.style.cursor='grabbing'
684
+ }
389
685
  const move = event => {
390
- const box = canvas.getBoundingClientRect()
391
- draw(draft || previewValue, { x: (event.clientX-box.left)*280/box.width, y: (event.clientY-box.top)*280/box.height })
686
+ if (!drag || event.pointerId!==drag.id) return
687
+ const box=canvas.getBoundingClientRect(), scale=box.width*250/280
688
+ get('x').value=String(clampPosition(drag.x+(event.clientX-drag.startX)/scale))
689
+ get('y').value=String(clampPosition(drag.y+(event.clientY-drag.startY)/scale))
690
+ get('apply').disabled=true
691
+ if(dragFrame===null)dragFrame=window.requestAnimationFrame(()=>{dragFrame=null;renderPosition()})
692
+ }
693
+ function endDrag(commit) {
694
+ if(!drag)return
695
+ const previous=drag;drag=null
696
+ if(dragFrame!==null){window.cancelAnimationFrame(dragFrame);dragFrame=null}
697
+ canvas.style.removeProperty('cursor')
698
+ if(canvas.hasPointerCapture?.(previous.id))canvas.releasePointerCapture(previous.id)
699
+ if(!commit) {
700
+ get('x').value=String(previous.x);get('y').value=String(previous.y)
701
+ draft=previous.draft;dirty=previous.dirty;renderPosition();get('apply').disabled=saving || !draft
702
+ return
703
+ }
704
+ if(Number(get('x').value)===previous.x && Number(get('y').value)===previous.y){get('apply').disabled=saving || !draft;return}
705
+ dirty=true;renderPosition();void process()
706
+ }
707
+ const release = event => {if(drag?.id===event.pointerId){move(event);endDrag(true)}}
708
+ const abortDrag = event => {if(drag?.id===event.pointerId)endDrag(false)}
709
+ const positionKey = event => {
710
+ if(event.key==='Escape' && drag){event.preventDefault();event.stopPropagation();endDrag(false);return}
711
+ if(!['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(event.key) || saving || processing || (!file && !sourceDraftId) || !rendered || drag)return
712
+ event.preventDefault();event.stopPropagation()
713
+ const axis=['ArrowLeft','ArrowRight'].includes(event.key)?'x':'y',sign=['ArrowLeft','ArrowUp'].includes(event.key)?-1:1
714
+ get(axis).value=String(clampPosition(Number(get(axis).value)+sign*(event.shiftKey ? .025 : .005)))
715
+ dirty=true;get('apply').disabled=true;renderPosition()
716
+ window.clearTimeout(timer);pendingPosition=true;timer=window.setTimeout(process,140)
392
717
  }
393
- const leave = () => draw(draft || previewValue)
394
718
  root.addEventListener('change', change)
395
719
  root.addEventListener('input', change)
396
720
  root.addEventListener('click', click)
397
- canvas.addEventListener('pointermove', move)
398
- canvas.addEventListener('pointerleave', leave)
399
- return { cancelReorder: () => reorder.cancel(), dispose() {
721
+ canvas.addEventListener('pointerdown',beginDrag)
722
+ canvas.addEventListener('pointermove',move)
723
+ canvas.addEventListener('pointerup',release)
724
+ canvas.addEventListener('pointercancel',abortDrag)
725
+ canvas.addEventListener('lostpointercapture',abortDrag)
726
+ canvas.addEventListener('keydown',positionKey)
727
+ return { prefetch, close:cancel, cancelReorder: () => reorder.cancel(), dispose() {
728
+ cancel()
729
+ get('workspace').removeEventListener('cancel',editorCancel)
730
+ get('discard').removeEventListener('cancel',editorCancel)
731
+ get('reset-confirm').removeEventListener('cancel',resetCancel)
732
+ root.removeEventListener('keydown',resetKey); closeReset()
400
733
  disposed = true; reorder.dispose(); ++generation; stop(); window.clearTimeout(timer); unsubscribe()
401
734
  root.removeEventListener('change', change); root.removeEventListener('input', change); root.removeEventListener('click', click)
402
- canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave)
735
+ canvas.removeEventListener('pointerdown',beginDrag);canvas.removeEventListener('pointermove',move)
736
+ canvas.removeEventListener('pointerup',release);canvas.removeEventListener('pointercancel',abortDrag)
737
+ canvas.removeEventListener('lostpointercapture',abortDrag);canvas.removeEventListener('keydown',positionKey)
403
738
  } }
404
739
  }