@ash-qw/dsh-theme-prts 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,3 +1 @@
1
- export const PACKAGE_ID = "@ash-qw/dsh-theme-prts"
2
- export const THEME_PLUGIN_ID = 'dsh-theme-prts'
3
- export function apply() {}
1
+ export { PACKAGE_ID, THEME_PLUGIN_ID, inject, apply } from '../src/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web",
@@ -68,11 +68,14 @@
68
68
  "prepublishOnly": "npm run check"
69
69
  },
70
70
  "engines": {
71
- "node": ">=18",
71
+ "node": ">=20.9.0",
72
72
  "dsh": "0.2.0-rc.2"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@playwright/test": "1.55.1",
76
76
  "jsdom": "24.1.3"
77
+ },
78
+ "dependencies": {
79
+ "sharp": "^0.35.5"
77
80
  }
78
81
  }
@@ -12,6 +12,8 @@ function closestWithClassSuffix(node, suffix) {
12
12
  }
13
13
 
14
14
  function classify(button, operation) {
15
+ // The host's process disclosure owns its transparent, full-width hit area.
16
+ if (button.hasAttribute('data-turn-process')) return null
15
17
  if (button.tagName !== 'BUTTON' || !operation.contains(button)) return null
16
18
  if (hasClassSuffix(button, '_sessionLogButton')) return 'header'
17
19
  if (button.closest('[data-produced-files-row]') || hasClassSuffix(button, '_showFolder')) return 'utility'
@@ -96,7 +98,7 @@ export function createConversationControlAdapter({ document, window }) {
96
98
  subtree: true,
97
99
  childList: true,
98
100
  attributes: true,
99
- attributeFilter: ['class', 'data-approval-key'],
101
+ attributeFilter: ['class', 'data-approval-key', 'data-turn-process'],
100
102
  })
101
103
  },
102
104
  dispose() {
@@ -0,0 +1,404 @@
1
+ import { mountEmblemReorder } from './emblem-reorder.js'
2
+ import { PARTICLE_DENSITY_PROFILES } from './particle-field-adapter.js'
3
+
4
+ // 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('图片数据无效')
7
+ const alpha = new Uint8Array(size * size)
8
+ let transparent = false
9
+ for (let i = 3; i < rgba.length; i += 4) if (rgba[i] < 128) { transparent = true; break }
10
+ const method = mode === 'auto' ? (transparent ? 'alpha' : 'dark') : mode
11
+ for (let i = 0; i < alpha.length; i++) {
12
+ const j = i * 4
13
+ const light = rgba[j] * .2126 + rgba[j + 1] * .7152 + rgba[j + 2] * .0722
14
+ alpha[i] = rgba[j + 3] >= (method === 'alpha' ? threshold : 64)
15
+ && (method === 'alpha' || (method === 'light' ? light >= threshold : light < threshold)) ? 255 : 0
16
+ }
17
+ const at = (x, y) => x >= 0 && y >= 0 && x < size && y < size && alpha[Math.floor(y) * size + Math.floor(x)] > 0
18
+ const targets = {}
19
+ for (const [density, { pitch }] of Object.entries(profiles)) {
20
+ const points = []
21
+ for (let y = pitch / 2, row = 0; y < size; y += pitch, row++) {
22
+ 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)
25
+ points.push({ x: (x - size / 2) / size, y: (y - size / 2) / size,
26
+ kind: edge ? 'skeleton' : 'fill', latticeColumn: column, latticeRow: row, component: 0 })
27
+ }
28
+ }
29
+ targets[density] = points
30
+ }
31
+ if (targets.sparse.length < 4) throw new Error('可用轮廓太少,请调整提取方式、阈值或换一张图片')
32
+ return { version: 1, key: 'custom', label: '自定义徽记', targets }
33
+ }
34
+
35
+ export function validCustomEmblem(value) {
36
+ return value?.version === 1 && ['sparse', 'light', 'standard', 'dense', 'ultra'].every(density => {
37
+ const points = value.targets?.[density]
38
+ return Array.isArray(points) && points.length >= 4 && points.length <= 10000 && points.every(p =>
39
+ Number.isFinite(p.x) && Number.isFinite(p.y) && Math.abs(p.x) <= .5 && Math.abs(p.y) <= .5
40
+ && ['skeleton', 'fill'].includes(p.kind) && Number.isInteger(p.latticeRow) && Number.isInteger(p.latticeColumn))
41
+ })
42
+ }
43
+
44
+ export function defaultEmblemConfiguration(emblems) {
45
+ return { version: 2, hero: null, conversation: emblems.map(({ key, label }) => ({ kind: 'builtin', key, label })) }
46
+ }
47
+
48
+ export function validEmblemConfiguration(value, emblems) {
49
+ const builtinKeys = new Set(emblems.map(e => e.key))
50
+ const custom = e => e?.kind === 'custom' && typeof e.key === 'string' && e.key.length < 100
51
+ && typeof e.label === 'string' && e.label.length <= 100 && validCustomEmblem(e)
52
+ return value?.version === 2 && (value.hero === null || custom(value.hero))
53
+ && Array.isArray(value.conversation) && value.conversation.length >= 1 && value.conversation.length <= 12
54
+ && new Set(value.conversation.map(e => e?.key)).size === value.conversation.length
55
+ && value.conversation.every(e => e?.kind === 'builtin' ? builtinKeys.has(e.key) : custom(e))
56
+ }
57
+
58
+ export function createCustomEmblemStore({ window, emblems = [], preview = () => null, onChange = () => {} }) {
59
+ const api = '/prts-theme-api/emblems'
60
+ const defaults = () => defaultEmblemConfiguration(emblems)
61
+ let current = defaults(), revision = -1, disposed = false, events, refreshing = null, announced = -1, reconnectTimer
62
+ const subscribers = new Set(), controller = new window.AbortController()
63
+ const request = async (path, data, signal = controller.signal) => {
64
+ const response = await window.fetch(`${api}${path}`, { credentials:'same-origin', signal,
65
+ ...(data === undefined ? {} : {method:'POST',headers:{'content-type':'application/json','x-prts-request':'1'},body:JSON.stringify(data)}) })
66
+ const result = await response.json()
67
+ if (!response.ok) throw new Error(result.error || '无法连接徽记服务')
68
+ return result
69
+ }
70
+ const accept = state => {
71
+ if (disposed || state.revision <= revision) return
72
+ if (!validEmblemConfiguration(state.configuration,emblems)) throw new Error('徽记配置无效')
73
+ const previous = new Map([current.hero,...current.conversation].filter(Boolean).map(e=>[e.key,e]))
74
+ const reuse = entry => {
75
+ const old = entry && previous.get(entry.key)
76
+ return old && entry.draftId && old.draftId === entry.draftId && old.label === entry.label ? old : entry
77
+ }
78
+ state.configuration.hero = reuse(state.configuration.hero)
79
+ state.configuration.conversation = state.configuration.conversation.map(reuse)
80
+ revision = state.revision; current = state.configuration
81
+ onChange(current); for (const fn of subscribers) fn(current)
82
+ }
83
+ const refresh = () => refreshing ||= request('/configuration').then(accept).finally(()=>{refreshing=null;if(!disposed && announced>revision) { announced=-1; refresh().catch(()=>{}) }})
84
+ const connect = () => {
85
+ if (disposed || !window.EventSource) return
86
+ events?.close()
87
+ events = new window.EventSource(`${api}/events`)
88
+ events.onmessage = event => {
89
+ try { announced=JSON.parse(event.data).revision;if(announced>revision)refresh().catch(()=>{}) } catch {}
90
+ }
91
+ events.onerror = () => {
92
+ if (disposed) return
93
+ events.close();window.clearTimeout(reconnectTimer);reconnectTimer=window.setTimeout(connect,3000)
94
+ }
95
+ }
96
+ const ready = refresh().catch(()=>{}).then(connect)
97
+ const focus = () => { if (!disposed) refresh().catch(()=>{}) }
98
+ window.addEventListener('focus',focus)
99
+ return {
100
+ ready, defaults, preview, get: () => current,
101
+ subscribe(fn) { subscribers.add(fn); fn(current); return () => subscribers.delete(fn) },
102
+ async save(value) {
103
+ if (!validEmblemConfiguration(value,emblems)) throw new Error('徽记数据无效')
104
+ if (revision < 0) await refresh()
105
+ const ref = entry => entry && ({kind:entry.kind,key:entry.key,label:entry.label,...(entry.draftId?{draftId:entry.draftId}:{})})
106
+ const configuration = {version:2,hero:ref(value.hero),conversation:value.conversation.map(ref)}
107
+ try { accept(await request('/configuration',{configuration,revision})) }
108
+ catch(error) { await refresh().catch(()=>{}); throw error }
109
+ },
110
+ exportAsset: id => request(`/draft/${encodeURIComponent(id)}/export`),
111
+ importAsset: (asset,signal) => request('/import',asset,signal),
112
+ getDraft: id => request(`/draft/${encodeURIComponent(id)}`),
113
+ async process({file,draftId,params},signal) {
114
+ let image
115
+ if (file) image = await new Promise((resolve,reject)=>{
116
+ const reader = new window.FileReader()
117
+ reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=()=>reject(new Error('图片读取失败'))
118
+ reader.readAsDataURL(file)
119
+ })
120
+ return request('/preview',{image,draftId,params,label:file?.name},signal)
121
+ },
122
+ dispose() { disposed=true; controller.abort(); window.clearTimeout(reconnectTimer);events?.close();subscribers.clear();window.removeEventListener('focus',focus) },
123
+ }
124
+ }
125
+
126
+ export function customEmblemMarkup() {
127
+ return `<section data-prts-custom-emblem>
128
+ <header><strong>徽记管理</strong><small>新会话与正文分别设置,可在任意会话中让 AI 调整,设置在当前 DSH 实例共享</small></header>
129
+ <div data-prts-emblem-hero-panel><h4>新会话徽记</h4><div data-prts-emblem-hero-row></div></div>
130
+ <div><h4>正文轮换徽记</h4><small>按列表顺序随正文滚动轮换,可混合使用内置与上传的图片,最多 12 张。长按徽记选项任意位置拖动排序,松开保存。</small>
131
+ <ol data-prts-emblem-list></ol>
132
+ <div data-prts-emblem-actions><button type="button" data-prts-emblem-add>新增徽记</button><button type="button" data-prts-emblem-reset-list>恢复内置列表</button></div>
133
+ </div>
134
+ <div data-prts-emblem-workspace hidden>
135
+ <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>
140
+ <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>
142
+ <label>阈值<input type="range" min="1" max="254" value="128" data-prts-emblem-threshold></label>
143
+ <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>
146
+ </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>
150
+ <p role="status" aria-live="polite" data-prts-emblem-status>正在读取徽记设置…</p>
151
+ </section>`
152
+ }
153
+
154
+ export function mountCustomEmblemEditor({ panel, window, store }) {
155
+ const root = panel?.querySelector('[data-prts-custom-emblem]')
156
+ if (!root || !store) return { dispose() {} }
157
+ const get = suffix => root.querySelector(`[data-prts-emblem-${suffix}]`)
158
+ const fileInput = get('file'), canvas = get('preview'), status = get('status')
159
+ 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) => {
162
+ canvas.hidden = !value
163
+ const ctx = canvas.getContext('2d')
164
+ if (!ctx) return
165
+ ctx.clearRect(0, 0, 280, 280)
166
+ ctx.fillStyle = '#0d1114'; ctx.fillRect(0, 0, 280, 280)
167
+ 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
+ }
174
+ ctx.fillRect(x, y, point.kind === 'skeleton' ? 2 : 1.5, point.kind === 'skeleton' ? 2 : 1.5)
175
+ }
176
+ }
177
+ const stop = () => { processingController?.abort(); processingController = undefined }
178
+ const process = async () => {
179
+ if ((!file && !sourceDraftId) || disposed) return
180
+ const token = ++generation
181
+ stop(); draft = null; get('apply').disabled = true
182
+ status.textContent = '正在处理图片…'
183
+ processingController = new window.AbortController()
184
+ 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)
186
+ if (disposed || token !== generation) return
187
+ draft = {...result.value,draftId:result.draftId,label:result.label}
188
+ draw(draft); get('apply').disabled = saving
189
+ status.textContent = `标准密度预览:${draft.targets.standard.length} 个粒子 · 尚未应用,可移动鼠标查看扰动`
190
+ } catch(error) {
191
+ if (disposed || token !== generation) return
192
+ status.textContent = error.message || '图片处理失败';draw(null)
193
+ }
194
+ }
195
+ const importAsset = async input => {
196
+ const selected=input.files?.[0]
197
+ if (!selected) return
198
+ input.value=''
199
+ const token=++generation
200
+ stop();window.clearTimeout(timer);draft=null;get('apply').disabled=true
201
+ status.textContent='正在导入徽记素材…'
202
+ processingController=new window.AbortController()
203
+ const signal=processingController.signal
204
+ try {
205
+ if(selected.size>12*1024*1024)throw new Error('徽记素材包不能超过 12 MB')
206
+ let asset
207
+ try {asset=JSON.parse(await selected.text())}catch{throw new Error('无法读取徽记素材包,请选择导出的 .prts-emblem.json 文件')}
208
+ if(disposed || token!==generation)return
209
+ const result=await store.importAsset(asset,signal)
210
+ if(disposed || token!==generation)return
211
+ 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)
214
+ get('editor').hidden=false;draw(draft);get('apply').disabled=saving
215
+ status.textContent='素材已导入预览,可继续调整;点击“应用到此处”才会保存'
216
+ }catch(error){if(!disposed && token===generation)status.textContent=error.message}
217
+ }
218
+ const change = event => {
219
+ if (saving) return
220
+ if(event.target===get('import')) {
221
+ if(event.type==='change')void importAsset(event.target)
222
+ return
223
+ }
224
+ if (event.target === fileInput) {
225
+ if (event.type !== 'change') return
226
+ const next = fileInput.files?.[0]
227
+ if (!next) return
228
+ if (!['image/png','image/jpeg','image/webp'].includes(next.type) || next.size > 8 * 1024 * 1024) {
229
+ ++generation; stop(); window.clearTimeout(timer); file = null; draft = null
230
+ get('apply').disabled = true; get('editor').hidden = true; draw(previewValue)
231
+ status.textContent = '请选择不超过 8 MB 的 PNG、WebP 或 JPEG 图片'; fileInput.value = ''; return
232
+ }
233
+ file = next; get('editor').hidden = false
234
+ process()
235
+ } 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)
238
+ }
239
+ }
240
+ const cancel = () => {
241
+ ++generation; stop(); window.clearTimeout(timer)
242
+ file = null; draft = null; sourceDraftId = null; previewValue = null; destination = null; fileInput.value = ''
243
+ get('import').value = ''
244
+ get('editor').hidden = true; get('workspace').hidden = true; get('apply').disabled = true; draw(null)
245
+ }
246
+ const entryPreview = entry => entry?.kind === 'custom' ? entry : store.preview(entry)
247
+ const button = (text, action, index, disabled = false) => {
248
+ const el = window.document.createElement('button')
249
+ el.type = 'button'; el.textContent = text; el.dataset.emblemAction = action
250
+ if (index !== undefined) el.dataset.emblemIndex = String(index)
251
+ el.disabled = disabled || saving || !loaded
252
+ return el
253
+ }
254
+ const thumbnail = entry => {
255
+ const element = window.document.createElement('canvas'); element.width = element.height = 64
256
+ element.setAttribute('aria-hidden', 'true')
257
+ const ctx = element.getContext('2d'); const value = entryPreview(entry)
258
+ if (ctx) { ctx.fillStyle = '#0d1114'; ctx.fillRect(0,0,64,64); ctx.fillStyle = '#edf1f2'
259
+ for (const p of value?.targets?.standard ?? []) ctx.fillRect(32+p.x*58,32+p.y*58,1,1)
260
+ }
261
+ return element
262
+ }
263
+ const renderList = () => {
264
+ reorder?.cancel()
265
+ const state = store.get()
266
+ const hero = get('hero-row'); hero.replaceChildren()
267
+ const name = window.document.createElement('span'); name.textContent = state.hero?.label || '内置罗德岛徽记'
268
+ hero.append(thumbnail(state.hero),name,button('预览 / 替换','hero'),button('恢复内置','reset-hero',undefined,!state.hero))
269
+ if(state.hero?.draftId)hero.append(button('导出素材','export-hero'))
270
+ const list = get('list'); list.replaceChildren()
271
+ state.conversation.forEach((entry,index) => {
272
+ const row = window.document.createElement('li'); row.dataset.emblemKey = entry.key
273
+ const label = window.document.createElement('span'); label.textContent = `${index+1}. ${entry.label}`
274
+ const actions = window.document.createElement('div'); actions.setAttribute('data-prts-emblem-actions','')
275
+ actions.append(button('预览 / 替换','edit',index),button('删除','delete',index,state.conversation.length===1))
276
+ if(entry.draftId)actions.append(button('导出素材','export',index))
277
+ const handle = window.document.createElement('button')
278
+ handle.type='button';handle.setAttribute('data-prts-emblem-reorder','')
279
+ handle.setAttribute('aria-label',`调整 ${entry.label} 的顺序`);handle.setAttribute('aria-pressed','false')
280
+ handle.title='长按拖动;键盘按空格开始,方向键调整,回车保存'
281
+ handle.disabled=saving || !loaded || state.conversation.length<2
282
+ handle.append(thumbnail(entry))
283
+ row.append(handle,label,actions); list.append(row)
284
+ })
285
+ get('add').disabled = saving || !loaded || state.conversation.length >= 12
286
+ get('reset-list').disabled = saving || !loaded
287
+ }
288
+ const startEdit = async target => {
289
+ cancel(); destination = target
290
+ const state = store.get()
291
+ const entry = target === 'hero' ? state.hero : target === 'new' ? null : state.conversation[target]
292
+ previewValue = target === 'new' ? null : entryPreview(entry)
293
+ get('target').textContent = target === 'hero' ? '编辑新会话徽记' : target === 'new' ? '新增正文轮换徽记' : `替换正文第 ${target+1} 张徽记`
294
+ 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
296
+ draw(previewValue)
297
+ status.textContent = '上传图片或导入徽记素材包后可调整并预览,点击“应用到此处”才会保存'
298
+ get('workspace').scrollIntoView?.({block:'nearest'})
299
+ if (entry?.draftId) {
300
+ const token = generation
301
+ try {
302
+ const saved = await store.getDraft(entry.draftId)
303
+ if (disposed || token !== generation) return
304
+ sourceDraftId = saved.draftId
305
+ for (const [key,value] of Object.entries(saved.params)) get(key).value = String(value)
306
+ get('editor').hidden = false
307
+ status.textContent = '可继续调整原图参数,或上传替换图片;应用后才会保存'
308
+ } catch(error) { if (!disposed && token === generation) status.textContent = error.message }
309
+ }
310
+
311
+ }
312
+ const unsubscribe = store.subscribe(() => {
313
+ if (destination !== null && !saving) { cancel();status.textContent='徽记设置已在其他页面或会话更新,请重新选择要编辑的徽记' }
314
+ renderList()
315
+ })
316
+ store.ready.then(() => { if (!disposed) { loaded = true; renderList(); status.textContent = '新会话与正文徽记独立保存;正文至少保留一张' } })
317
+ const persist = async value => {
318
+ saving = true
319
+ for (const input of root.querySelectorAll('input,select,button')) input.disabled = true
320
+ try {
321
+ await store.save(value)
322
+ if (disposed) return
323
+ cancel(); status.textContent = '已保存徽记设置 · 刷新后仍然保留'
324
+ } catch (error) { if (!disposed) status.textContent = `${error.message},原徽记保持不变` }
325
+ finally {
326
+ saving = false
327
+ if (!disposed) {
328
+ for (const input of root.querySelectorAll('input,select,button')) input.disabled = false
329
+ renderList(); get('apply').disabled = !draft
330
+ }
331
+ }
332
+ }
333
+ const click = async event => {
334
+ const target = event.target.closest('button')
335
+ if (!target || target.disabled || saving || !loaded || reorder?.active) return
336
+ if (target.hasAttribute('data-prts-emblem-cancel')) { cancel(); status.textContent = '已取消编辑,当前徽记保持不变'; return }
337
+ if (target.hasAttribute('data-prts-emblem-add')) { startEdit('new'); return }
338
+ const state = store.get()
339
+ if (target.hasAttribute('data-prts-emblem-reset-list')) { await persist({...state,conversation:store.defaults().conversation}); return }
340
+ if (target.hasAttribute('data-prts-emblem-apply')) {
341
+ if (!draft || destination === null) return
342
+ const value = {...draft,kind:'custom',key:window.crypto.randomUUID(),label:(file?.name || draft.label || '自定义徽记').slice(0,100)}
343
+ if (destination === 'hero') await persist({...state,hero:value})
344
+ else {
345
+ const conversation = [...state.conversation]
346
+ if (destination === 'new') { if (conversation.length >= 12) return; conversation.push(value) }
347
+ else conversation[destination] = value
348
+ await persist({...state,conversation})
349
+ }
350
+ return
351
+ }
352
+ 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]
355
+ if(!entry?.draftId)return
356
+ target.disabled=true
357
+ try {
358
+ const asset=await store.exportAsset(entry.draftId)
359
+ if(disposed)return
360
+ const url=window.URL.createObjectURL(new window.Blob([JSON.stringify(asset)],{type:'application/json'}))
361
+ const link=window.document.createElement('a')
362
+ link.href=url;link.download=`${entry.label.replace(/[\\/:*?"<>|\x00-\x1f]/g,'_')}.prts-emblem.json`
363
+ link.hidden=true;root.append(link);link.click();link.remove()
364
+ window.setTimeout(()=>window.URL.revokeObjectURL(url),1000)
365
+ status.textContent='已导出徽记素材包,包含原图和调整参数,可在其他 DSH 中导入'
366
+ }catch(error){if(!disposed)status.textContent=`导出失败:${error.message}`}
367
+ finally{if(!disposed)target.disabled=saving}
368
+ return
369
+ }
370
+ if (action === 'hero') { startEdit('hero'); return }
371
+ if (action === 'reset-hero') { await persist({...state,hero:null}); return }
372
+ if (!Number.isInteger(index) || index < 0 || index >= state.conversation.length) return
373
+ if (action === 'edit') { startEdit(index); return }
374
+ const conversation = [...state.conversation]
375
+ if (action === 'delete' && conversation.length > 1) conversation.splice(index,1)
376
+ else return
377
+ await persist({...state,conversation})
378
+ }
379
+ reorder = mountEmblemReorder({ list:get('list'),window,status,canStart:()=>loaded && !saving && !disposed,
380
+ onStart:cancel,
381
+ onCommit:async (keys,movedKey)=>{
382
+ const state=store.get(), entries=new Map(state.conversation.map(entry=>[entry.key,entry]))
383
+ if(keys.length!==entries.size || keys.some(key=>!entries.has(key))){renderList();return}
384
+ await persist({...state,conversation:keys.map(key=>entries.get(key))})
385
+ const moved=[...get('list').children].find(row=>row.dataset.emblemKey===movedKey)
386
+ moved?.querySelector('[data-prts-emblem-reorder]')?.focus({preventScroll:true})
387
+ },
388
+ })
389
+ 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 })
392
+ }
393
+ const leave = () => draw(draft || previewValue)
394
+ root.addEventListener('change', change)
395
+ root.addEventListener('input', change)
396
+ root.addEventListener('click', click)
397
+ canvas.addEventListener('pointermove', move)
398
+ canvas.addEventListener('pointerleave', leave)
399
+ return { cancelReorder: () => reorder.cancel(), dispose() {
400
+ disposed = true; reorder.dispose(); ++generation; stop(); window.clearTimeout(timer); unsubscribe()
401
+ root.removeEventListener('change', change); root.removeEventListener('input', change); root.removeEventListener('click', click)
402
+ canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave)
403
+ } }
404
+ }
@@ -0,0 +1,143 @@
1
+ // Hold anywhere on a card to sort; movement before the hold keeps native touch scrolling.
2
+ export function mountEmblemReorder({ list, window, status, canStart, onStart, onCommit }) {
3
+ let gesture = null, holdTimer, frame, previousTime = 0, suppressClick = false
4
+ const rows = () => [...list.children].filter(row => row.hasAttribute('data-emblem-key'))
5
+ const scroller = list.closest('[data-prts-theme-settings-scroll]')
6
+ const clear = () => {
7
+ window.clearTimeout(holdTimer); window.cancelAnimationFrame(frame)
8
+ holdTimer = frame = undefined; previousTime = 0
9
+ }
10
+ const finish = (commit = false) => {
11
+ const current = gesture
12
+ if (!current) return
13
+ gesture = null; clear()
14
+ current.ghost?.remove();current.row.removeAttribute('data-prts-emblem-dragging')
15
+ list.removeAttribute('data-prts-emblem-sorting')
16
+ current.handle.setAttribute('aria-pressed','false')
17
+ try { if (list.hasPointerCapture?.(current.pointerId)) list.releasePointerCapture(current.pointerId) } catch {}
18
+ const ordered = rows().map(row=>row.dataset.emblemKey)
19
+ const changed = ordered.some((key,index)=>key!==current.original[index].dataset.emblemKey)
20
+ if (!commit) for (const row of current.original) list.append(row)
21
+ if (current.active) {
22
+ status.textContent = commit ? '徽记顺序未改变' : '已取消排序,原顺序保持不变'
23
+ if (commit && changed) void onCommit(ordered,current.row.dataset.emblemKey)
24
+ else current.handle.focus({preventScroll:true})
25
+ }
26
+ }
27
+ const place = y => {
28
+ const current = gesture
29
+ if (!current?.active) return
30
+ const others = rows().filter(row=>row!==current.row)
31
+ const next = others.find(row=>{const box=row.getBoundingClientRect();return y < box.top + box.height/2})
32
+ if (next) {
33
+ if (current.row.nextElementSibling !== next) list.insertBefore(current.row,next)
34
+ } else if (others.at(-1)?.nextElementSibling !== current.row) list.insertBefore(current.row,current.ghost)
35
+ current.position = rows().indexOf(current.row)
36
+ }
37
+ const tick = time => {
38
+ if (!gesture?.active || gesture.keyboard) return
39
+ const current = gesture
40
+ if (!list.isConnected || !list.getClientRects().length) { finish();return }
41
+ const delta = previousTime ? Math.min(32,time-previousTime) : 16
42
+ previousTime=time
43
+ if (scroller) {
44
+ const box=scroller.getBoundingClientRect(), edge=48
45
+ const speed=current.y < box.top+edge ? -Math.min(1,(box.top+edge-current.y)/edge)
46
+ : current.y > box.bottom-edge ? Math.min(1,(current.y-box.bottom+edge)/edge) : 0
47
+ if (speed) scroller.scrollTop += speed * delta * .5
48
+ }
49
+ place(current.y)
50
+ current.ghost.style.transform=`translate3d(${current.x-current.startX}px,${current.y-current.startY}px,0)`
51
+ frame=window.requestAnimationFrame(tick)
52
+ }
53
+ const activate = () => {
54
+ const current=gesture
55
+ if (!current || !canStart() || !current.row.isConnected) {finish();return}
56
+ onStart?.()
57
+ current.active=true
58
+ suppressClick=true
59
+ current.handle.setAttribute('aria-pressed','true')
60
+ list.setAttribute('data-prts-emblem-sorting','')
61
+ if (!current.keyboard) {
62
+ const box=current.row.getBoundingClientRect()
63
+ const ghost=current.row.cloneNode(true)
64
+ ghost.removeAttribute('data-emblem-key');ghost.setAttribute('data-prts-emblem-drag-ghost','')
65
+ ghost.setAttribute('aria-hidden','true');ghost.inert=true
66
+ const originals=current.row.querySelectorAll('canvas')
67
+ ghost.querySelectorAll('canvas').forEach((canvas,index)=>canvas.getContext('2d')?.drawImage(originals[index],0,0))
68
+ Object.assign(ghost.style,{position:'fixed',left:`${box.left}px`,top:`${box.top}px`,width:`${box.width}px`,boxSizing:'border-box',margin:'0',pointerEvents:'none',zIndex:'10'})
69
+ current.ghost=ghost;list.append(ghost)
70
+ // Native dialogs can establish a fixed-position containing block. Keep the lifted row under the pointer.
71
+ const positioned=ghost.getBoundingClientRect()
72
+ ghost.style.left=`${2*box.left-positioned.left}px`;ghost.style.top=`${2*box.top-positioned.top}px`
73
+ try {list.setPointerCapture(current.pointerId)}catch{finish();return}
74
+ frame=window.requestAnimationFrame(tick)
75
+ }
76
+ current.row.setAttribute('data-prts-emblem-dragging','')
77
+ status.textContent='正在调整徽记顺序,松开保存;按 Esc 取消'
78
+ }
79
+ const down = event => {
80
+ if (gesture) { if (event.pointerId!==gesture.pointerId) finish();return }
81
+ const row=event.target.closest('[data-emblem-key]')
82
+ const handle=row?.querySelector('[data-prts-emblem-reorder]')
83
+ suppressClick=false
84
+ if (!handle || handle.disabled || event.button!==0 || event.isPrimary===false || !canStart() || rows().length<2) return
85
+ gesture={handle,row,pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,x:event.clientX,y:event.clientY,original:rows(),active:false}
86
+ holdTimer=window.setTimeout(activate,400)
87
+ }
88
+ const move = event => {
89
+ if (!gesture || gesture.pointerId!==event.pointerId) return
90
+ gesture.x=event.clientX;gesture.y=event.clientY
91
+ if (!gesture.active && Math.hypot(gesture.x-gesture.startX,gesture.y-gesture.startY)>8) {finish();return}
92
+ if (gesture.active) event.preventDefault()
93
+ }
94
+ const up = event => {
95
+ if (gesture?.pointerId!==event.pointerId) return
96
+ const box=list.getBoundingClientRect(), viewport=(scroller || list).getBoundingClientRect()
97
+ finish(event.clientX>=box.left && event.clientX<=box.right && event.clientY>=viewport.top && event.clientY<=viewport.bottom)
98
+ }
99
+ const cancelPointer = event => {if (gesture?.pointerId===event.pointerId) finish()}
100
+ const cancel = () => finish()
101
+ const visibility = () => {if (window.document.hidden) finish()}
102
+ const keydown = event => {
103
+ if (event.key==='Escape' && gesture) {event.preventDefault();event.stopPropagation();finish();return}
104
+ const handle=event.target.closest?.('[data-prts-emblem-reorder]')
105
+ if (!handle || handle.disabled || !canStart()) return
106
+ if ([' ','Enter'].includes(event.key)) {
107
+ event.preventDefault()
108
+ if (gesture?.keyboard) {finish(true);return}
109
+ if (gesture) return
110
+ gesture={handle,row:handle.closest('[data-emblem-key]'),original:rows(),keyboard:true,active:false}
111
+ activate();status.textContent='用上下方向键调整顺序,空格或回车保存;Esc 取消'
112
+ } else if (gesture?.keyboard && ['ArrowUp','ArrowDown','Home','End'].includes(event.key)) {
113
+ event.preventDefault()
114
+ const current=rows(),index=current.indexOf(gesture.row)
115
+ const target=event.key==='Home'?0:event.key==='End'?current.length-1:Math.max(0,Math.min(current.length-1,index+(event.key==='ArrowDown'?1:-1)))
116
+ if (target!==index)list.insertBefore(gesture.row,target<index?current[target]:current[target].nextSibling)
117
+ gesture.handle.focus({preventScroll:true});gesture.row.scrollIntoView({block:'nearest'})
118
+ status.textContent=`当前位置:第 ${target+1} 项,共 ${current.length} 项;空格保存`
119
+ }
120
+ }
121
+ const touchmove = event => {if(gesture?.active && !gesture.keyboard)event.preventDefault()}
122
+ const context = event => {if(event.target.closest('[data-emblem-key]')||gesture?.active)event.preventDefault()}
123
+ const click = event => {if(suppressClick || gesture?.active || event.target.closest('[data-prts-emblem-reorder]')){event.preventDefault();event.stopPropagation()}}
124
+ list.addEventListener('touchmove',touchmove,{passive:false})
125
+ list.addEventListener('pointerdown',down)
126
+ list.addEventListener('contextmenu',context)
127
+ list.addEventListener('click',click,true)
128
+ window.addEventListener('pointermove',move,{passive:false})
129
+ window.addEventListener('pointerup',up)
130
+ window.addEventListener('pointercancel',cancelPointer)
131
+ list.addEventListener('lostpointercapture',cancelPointer)
132
+ window.addEventListener('keydown',keydown,true)
133
+ window.addEventListener('blur',cancel)
134
+ window.addEventListener('resize',cancel)
135
+ window.document.addEventListener('visibilitychange',visibility)
136
+ return { cancel, get active(){return !!gesture?.active}, dispose() {
137
+ finish()
138
+ list.removeEventListener('touchmove',touchmove);list.removeEventListener('pointerdown',down);list.removeEventListener('contextmenu',context);list.removeEventListener('click',click,true)
139
+ window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancelPointer)
140
+ list.removeEventListener('lostpointercapture',cancelPointer);window.removeEventListener('keydown',keydown,true);window.removeEventListener('blur',cancel);window.removeEventListener('resize',cancel)
141
+ window.document.removeEventListener('visibilitychange',visibility)
142
+ } }
143
+ }
@@ -0,0 +1,17 @@
1
+ // The native tool seat keeps previews available even for sessions with a remote filesystem.
2
+ export function registerEmblemToolPreview(ctx, React) {
3
+ if (!ctx.slots?.inject || !React?.createElement) return () => {}
4
+ return ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
5
+ name:'tool.call.toolview',key:'prts_emblem_preview',
6
+ }, ({block}) => {
7
+ const h=React.createElement
8
+ let result
9
+ for(const part of block?.content ?? [])if(part.type==='text'){
10
+ try {const value=JSON.parse(part.text);if(value.draftId)result=value}catch{}
11
+ }
12
+ if(!result || !/^[a-zA-Z0-9-]{1,80}$/.test(result.draftId))return h('div',null,block?.isError?'徽记预览失败':'正在生成徽记预览…')
13
+ return h('figure',{'data-prts-tool-preview':'',style:{margin:'8px 0'}},
14
+ h('img',{src:`/prts-theme-api/emblems/draft/${result.draftId}/preview.png`,alt:'粒子徽记预览',width:384,height:384,style:{maxWidth:'100%',height:'auto',display:'block',borderRadius:8}}),
15
+ h('figcaption',null,`${result.label || '自定义徽记'} · 标准密度 ${result.particles?.standard ?? ''} 个粒子 · 未应用`))
16
+ }))
17
+ }