@ash-qw/dsh-theme-prts 1.0.6 → 1.1.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 +15 -0
- package/README.en.md +9 -1
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/THIRD_PARTY_NOTICES.zh-CN.md +4 -2
- package/lib/client.js +656 -130
- package/lib/index.js +1 -3
- package/package.json +5 -2
- package/src/assets/cursors/animation.json +56 -0
- package/src/assets/cursors/prts-grab.png +0 -0
- package/src/assets/cursors/prts-grabbing.png +0 -0
- package/src/assets/cursors/prts-not-allowed.png +0 -0
- package/src/client/cursor-adapter.js +2 -2
- package/src/client/custom-emblem.js +404 -0
- package/src/client/emblem-reorder.js +143 -0
- package/src/client/emblem-tool-preview.js +17 -0
- package/src/client/index.js +7 -1
- package/src/client/operations-shell-v2.js +2 -8
- package/src/client/particle-field-adapter.js +32 -7
- package/src/client/theme-settings-workbench.js +64 -48
- package/src/index.js +13 -1
- package/src/server/api.js +54 -0
- package/src/server/emblems.js +153 -0
- package/src/server/image-worker.js +32 -0
- package/src/server/tools.js +67 -0
- package/src/styles/cursor.css +7 -1
- package/src/styles/prts.css +0 -8
- package/src/styles/refinement.css +2 -2
- package/src/styles/theme-settings-workbench.css +82 -85
- package/src/client/resize-shield-adapter.js +0 -69
package/lib/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ash-qw/dsh-theme-prts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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": ">=
|
|
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
|
}
|
|
@@ -506,5 +506,61 @@
|
|
|
506
506
|
"duration": 60000.0
|
|
507
507
|
}
|
|
508
508
|
]
|
|
509
|
+
},
|
|
510
|
+
"states": {
|
|
511
|
+
"grab": {
|
|
512
|
+
"file": "prts-grab.png",
|
|
513
|
+
"source": "PRTS-Replica-v8-96x-grab_enter_0011.cur",
|
|
514
|
+
"crop": [
|
|
515
|
+
18,
|
|
516
|
+
40,
|
|
517
|
+
86,
|
|
518
|
+
96
|
|
519
|
+
],
|
|
520
|
+
"size": [
|
|
521
|
+
45,
|
|
522
|
+
37
|
|
523
|
+
],
|
|
524
|
+
"hotspot": [
|
|
525
|
+
22,
|
|
526
|
+
19
|
|
527
|
+
]
|
|
528
|
+
},
|
|
529
|
+
"grabbing": {
|
|
530
|
+
"file": "prts-grabbing.png",
|
|
531
|
+
"source": "PRTS-Replica-v8-96x-grab_loop_0000.cur",
|
|
532
|
+
"crop": [
|
|
533
|
+
18,
|
|
534
|
+
40,
|
|
535
|
+
86,
|
|
536
|
+
96
|
|
537
|
+
],
|
|
538
|
+
"size": [
|
|
539
|
+
45,
|
|
540
|
+
37
|
|
541
|
+
],
|
|
542
|
+
"hotspot": [
|
|
543
|
+
22,
|
|
544
|
+
19
|
|
545
|
+
]
|
|
546
|
+
},
|
|
547
|
+
"not-allowed": {
|
|
548
|
+
"file": "prts-not-allowed.png",
|
|
549
|
+
"source": "PRTS-Replica-v8-96x-unavail1_static.cur",
|
|
550
|
+
"crop": [
|
|
551
|
+
18,
|
|
552
|
+
10,
|
|
553
|
+
96,
|
|
554
|
+
96
|
|
555
|
+
],
|
|
556
|
+
"size": [
|
|
557
|
+
51,
|
|
558
|
+
56
|
|
559
|
+
],
|
|
560
|
+
"hotspot": [
|
|
561
|
+
7,
|
|
562
|
+
7
|
|
563
|
+
]
|
|
564
|
+
}
|
|
509
565
|
}
|
|
510
566
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -107,7 +107,7 @@ export function createCursorAdapter({ document, window, assets }) {
|
|
|
107
107
|
restoreHandTarget()
|
|
108
108
|
if (!canAnimate(target) || !handCursor || target === textTarget) return
|
|
109
109
|
const cursor = window.getComputedStyle(target).cursor
|
|
110
|
-
if (!cursor.includes('data:image/png') || /(?:,\s*text|resize)$/.test(cursor)) return
|
|
110
|
+
if (!cursor.includes('data:image/png') || /(?:,\s*(?:text|grab|grabbing|not-allowed|no-drop)|resize)$/.test(cursor)) return
|
|
111
111
|
handTarget = target
|
|
112
112
|
originalCursor = target.style.getPropertyValue('cursor')
|
|
113
113
|
originalPriority = target.style.getPropertyPriority('cursor')
|
|
@@ -160,7 +160,7 @@ export function createCursorAdapter({ document, window, assets }) {
|
|
|
160
160
|
reset()
|
|
161
161
|
const cursor = target ? window.getComputedStyle(target).cursor : ''
|
|
162
162
|
if (!cursor.includes('data:image/png')) return
|
|
163
|
-
if (/(?:,\s*text|resize)$/.test(cursor) || !canAnimate(target)) return
|
|
163
|
+
if (/(?:,\s*(?:text|grab|grabbing|not-allowed|no-drop)|resize)$/.test(cursor) || !canAnimate(target)) return
|
|
164
164
|
gestureAssets = /,\s*pointer$/.test(cursor) && assets.link ? assets.link : assets
|
|
165
165
|
pointerId = event.pointerId
|
|
166
166
|
handCursor = cursor
|
|
@@ -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
|
+
}
|