@ash-qw/dsh-theme-prts 2.4.0 → 2.5.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 +23 -0
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/lib/client.js +432 -74
- package/package.json +2 -2
- package/src/client/avatar-crop.js +7 -2
- package/src/client/composer-glass-surface.js +10 -1
- package/src/client/conversation-control-adapter.js +152 -3
- package/src/client/custom-background.js +53 -3
- package/src/client/custom-emblem.js +9 -3
- package/src/client/emblem-reorder.js +41 -10
- package/src/client/file-drop.js +48 -0
- package/src/client/theme-transfer-panel.js +58 -24
- package/src/client/theme-transfer.js +68 -26
- package/src/styles/custom-background.css +66 -6
- package/src/styles/prts.css +4 -2
- package/src/styles/refinement.css +41 -2
- package/src/styles/theme-settings-workbench.css +25 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ash-qw/dsh-theme-prts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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 and the official desktop client",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
"engines": {
|
|
71
71
|
"node": ">=20.9.0",
|
|
72
|
-
"dsh": "0.2.1-rc.2"
|
|
72
|
+
"dsh": "0.2.1-rc.2 || 0.2.1-rc.3"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@playwright/test": "1.55.1",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { mountFileDrop } from './file-drop.js'
|
|
1
2
|
export function mountAvatarCrop({panel,window,load,onSave}){
|
|
2
3
|
const dialog=panel.ownerDocument.createElement('dialog')
|
|
3
4
|
dialog.setAttribute('data-prts-avatar-crop','')
|
|
4
5
|
dialog.setAttribute('aria-labelledby','prts-avatar-crop-title')
|
|
5
6
|
dialog.innerHTML=`<header><strong id="prts-avatar-crop-title">头像设置</strong><label data-prts-avatar-upload>更换图片<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" data-crop-file aria-label="上传新头像"></label></header>
|
|
6
|
-
<p
|
|
7
|
+
<p>可拖入图片更换头像。拖动亮区选择头像范围,滚轮或触摸板调整大小。</p>
|
|
7
8
|
<canvas width="320" height="320" tabindex="0" aria-label="原图与头像裁剪范围,可拖动或用方向键调整亮区"></canvas>
|
|
8
9
|
<div data-crop-tools><label>缩放 <input type="range" min="1" max="4" step=".01" value="1" aria-label="头像裁剪缩放"></label><button type="button" data-crop-center>重置裁剪</button></div>
|
|
9
10
|
<p role="status" data-prts-avatar-crop-status></p>
|
|
@@ -109,6 +110,10 @@ export function mountAvatarCrop({panel,window,load,onSave}){
|
|
|
109
110
|
catch(error){status.textContent=error.message}
|
|
110
111
|
finally{busy=false;controls()}
|
|
111
112
|
})
|
|
113
|
+
const drop = mountFileDrop({ area: dialog, input: dialog.querySelector('[data-crop-file]'),
|
|
114
|
+
enabled: () => dialog.open && ready && !busy && !disposed,
|
|
115
|
+
onError: message => { status.textContent = message },
|
|
116
|
+
})
|
|
112
117
|
return{
|
|
113
118
|
async open(target){
|
|
114
119
|
const ticket=++generation
|
|
@@ -129,6 +134,6 @@ export function mountAvatarCrop({panel,window,load,onSave}){
|
|
|
129
134
|
finally{if(ticket===generation){busy=false;controls();canvas.focus()}}
|
|
130
135
|
},
|
|
131
136
|
close,
|
|
132
|
-
dispose(){disposed=true;close();dialog.remove()},
|
|
137
|
+
dispose(){disposed=true;drop.dispose();close();dialog.remove()},
|
|
133
138
|
}
|
|
134
139
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Keep host content in place; mount upstream GlassSurface only as a background.
|
|
2
2
|
export function createComposerGlassSurface({document, window, mount}) {
|
|
3
3
|
const surfaces = new Map()
|
|
4
|
+
const observed = new Set()
|
|
4
5
|
let observer, resize, frame
|
|
5
6
|
function remove(card, entry) {
|
|
6
7
|
window.clearTimeout(entry.resizeTimer)
|
|
7
8
|
resize?.unobserve(card)
|
|
9
|
+
observed.delete(card)
|
|
8
10
|
entry.unmount()
|
|
9
11
|
entry.container.remove()
|
|
10
12
|
card.removeAttribute('data-prts-composer-glass-active')
|
|
@@ -14,7 +16,14 @@ export function createComposerGlassSurface({document, window, mount}) {
|
|
|
14
16
|
frame = undefined
|
|
15
17
|
const cards = new Set(document.querySelectorAll('[data-composer-card], [data-prts-composer-fallback]'))
|
|
16
18
|
for (const [card, entry] of surfaces) if (!cards.has(card)) remove(card, entry)
|
|
19
|
+
for (const card of observed) {
|
|
20
|
+
if (cards.has(card)) continue
|
|
21
|
+
resize?.unobserve(card)
|
|
22
|
+
observed.delete(card)
|
|
23
|
+
}
|
|
17
24
|
for (const card of cards) {
|
|
25
|
+
// Hidden composers still need observation so their first visible size mounts glass.
|
|
26
|
+
if (!observed.has(card)) { resize?.observe(card); observed.add(card) }
|
|
18
27
|
// Use layout dimensions, unaffected by host entrance/resize transforms.
|
|
19
28
|
const w = card.offsetWidth
|
|
20
29
|
const h = card.offsetHeight
|
|
@@ -37,7 +46,6 @@ export function createComposerGlassSurface({document, window, mount}) {
|
|
|
37
46
|
entry = {container, shape, width:w, height:h, unmount:mount(container, {shape})}
|
|
38
47
|
surfaces.set(card, entry)
|
|
39
48
|
card.setAttribute('data-prts-composer-glass-active', '')
|
|
40
|
-
resize?.observe(card)
|
|
41
49
|
} else if (entry.shape !== shape) {
|
|
42
50
|
entry.shape = shape
|
|
43
51
|
entry.width = w
|
|
@@ -76,6 +84,7 @@ export function createComposerGlassSurface({document, window, mount}) {
|
|
|
76
84
|
observer?.disconnect()
|
|
77
85
|
observer = undefined
|
|
78
86
|
resize?.disconnect()
|
|
87
|
+
observed.clear()
|
|
79
88
|
window.cancelAnimationFrame?.(frame)
|
|
80
89
|
frame = undefined
|
|
81
90
|
for (const [card, entry] of surfaces) remove(card, entry)
|
|
@@ -14,6 +14,7 @@ function closestWithClassSuffix(node, suffix) {
|
|
|
14
14
|
function classify(button, operation) {
|
|
15
15
|
// The host's process disclosure owns its transparent, full-width hit area.
|
|
16
16
|
if (button.hasAttribute('data-turn-process')) return null
|
|
17
|
+
if (button.querySelector('[data-compaction-disclosure]')) return null
|
|
17
18
|
// Notification headers belong to the card surface, not standalone buttons.
|
|
18
19
|
if (button.parentElement?.matches('[data-turn-trigger]')) return null
|
|
19
20
|
// Changed-file cards own one outer surface; their header and rows stay native.
|
|
@@ -23,11 +24,14 @@ function classify(button, operation) {
|
|
|
23
24
|
if (button.tagName !== 'BUTTON' || !operation.contains(button)) return null
|
|
24
25
|
if (hasClassSuffix(button, '_sessionLogButton')) return 'header'
|
|
25
26
|
const tokens=[...(button.classList ?? [])]
|
|
26
|
-
const
|
|
27
|
+
const fileMention=tokens.some(token=>/(?:^|_)fileMention(?:_|$)/.test(token))
|
|
28
|
+
const fileLink=tokens.some(token=>/(?:^|_)fileLink(?:_|$)/.test(token))
|
|
29
|
+
// Tool summaries own a compact, single-line link; card padding/wrapping clips it.
|
|
30
|
+
if (fileLink && !fileMention) return null
|
|
27
31
|
const fileChange=hasClassSuffix(button,'_header')
|
|
28
32
|
&& [...button.children].some(node=>hasClassSuffix(node,'_tile'))
|
|
29
33
|
&& [...button.children].some(node=>hasClassSuffix(node,'_titles'))
|
|
30
|
-
if (
|
|
34
|
+
if (fileMention || fileChange || button.closest('[data-produced-files-row]')) return 'file'
|
|
31
35
|
if (hasClassSuffix(button, '_showFolder')) return 'utility'
|
|
32
36
|
if (closestWithClassSuffix(button, '_older')) return 'utility'
|
|
33
37
|
if (hasClassSuffix(button, '_inspectButton')) return 'inspect'
|
|
@@ -42,6 +46,7 @@ function classify(button, operation) {
|
|
|
42
46
|
|
|
43
47
|
export function createConversationControlAdapter({ document, window }) {
|
|
44
48
|
let observer
|
|
49
|
+
let disposeFrame
|
|
45
50
|
let operation
|
|
46
51
|
const owned = new Set()
|
|
47
52
|
const pending = new Set()
|
|
@@ -100,8 +105,13 @@ export function createConversationControlAdapter({ document, window }) {
|
|
|
100
105
|
return {
|
|
101
106
|
start() {
|
|
102
107
|
if (!document) return
|
|
103
|
-
|
|
108
|
+
const nextOperation = document.querySelector('[data-prts-region="operation"]')
|
|
109
|
+
if(operation && operation!==nextOperation){
|
|
110
|
+
disposeFrame?.();disposeFrame=undefined;observer?.disconnect();observer=undefined;clear()
|
|
111
|
+
}
|
|
112
|
+
operation = nextOperation
|
|
104
113
|
if (!operation) return
|
|
114
|
+
disposeFrame ||= mountBodyTargetFrame(document, window, operation)
|
|
105
115
|
collectButtons(operation)
|
|
106
116
|
scan()
|
|
107
117
|
if (observer || !window?.MutationObserver) return
|
|
@@ -114,6 +124,7 @@ export function createConversationControlAdapter({ document, window }) {
|
|
|
114
124
|
})
|
|
115
125
|
},
|
|
116
126
|
dispose() {
|
|
127
|
+
disposeFrame?.();disposeFrame=undefined
|
|
117
128
|
observer?.disconnect()
|
|
118
129
|
observer = undefined
|
|
119
130
|
clear()
|
|
@@ -121,3 +132,141 @@ export function createConversationControlAdapter({ document, window }) {
|
|
|
121
132
|
},
|
|
122
133
|
}
|
|
123
134
|
}
|
|
135
|
+
|
|
136
|
+
// One shared frame follows body targets, including transitions between neighboring controls.
|
|
137
|
+
function mountBodyTargetFrame(document, window, operation) {
|
|
138
|
+
// Nonvisual hosts can still mount the theme without an animation clock.
|
|
139
|
+
if (!window?.requestAnimationFrame || !window?.cancelAnimationFrame) return () => {}
|
|
140
|
+
const composerButton=':is([data-composer-card],[data-prts-composer-fallback]) button:not([data-slot="conversation.input.attachments"] *,[data-prts-glass-menu] *,[data-prts-floating-glass] *,[role="menu"] *,[role="listbox"] *)'
|
|
141
|
+
const selector='[data-prts-conversation-control],[data-presented-file],[data-changed-files],[data-produced-files-row] > button,[data-prts-glass-control],[data-prts-footer-action],[data-prts-transfer] button,'+composerButton
|
|
142
|
+
const frame=document.createElement('div')
|
|
143
|
+
frame.setAttribute('data-prts-body-target-frame','');frame.setAttribute('aria-hidden','true')
|
|
144
|
+
const corners=Array.from({length:4},()=>frame.appendChild(document.createElement('i')))
|
|
145
|
+
document.body.append(frame)
|
|
146
|
+
if(frame.showPopover)frame.setAttribute('popover','manual')
|
|
147
|
+
let layer=null
|
|
148
|
+
const hideFrame=()=>{if(frame.hidePopover && frame.matches(':popover-open'))frame.hidePopover()}
|
|
149
|
+
const showFrame=()=>{
|
|
150
|
+
if(!frame.showPopover)return
|
|
151
|
+
const modal=target?.closest('dialog[open]')
|
|
152
|
+
if(modal!==layer)hideFrame()
|
|
153
|
+
if(!frame.matches(':popover-open'))frame.showPopover()
|
|
154
|
+
layer=modal
|
|
155
|
+
}
|
|
156
|
+
let target=null, hovered=null, focused=null, raf=0, pressed=false, disposed=false
|
|
157
|
+
let pointer={x:0,y:0}, state=null, settled=false, tween=null, bounds=null, boundsDirty=false, hitDirty=false, exitGoal=null, motionDuration=240, targetArm=7
|
|
158
|
+
const resizeObserver=window.ResizeObserver?new window.ResizeObserver(()=>{boundsDirty=true;wake()}):null
|
|
159
|
+
const reduced=window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
160
|
+
const fine=window.matchMedia?.('(hover:hover) and (pointer:fine)')
|
|
161
|
+
const find=node=>{
|
|
162
|
+
if (!node?.closest || node.closest('[data-prts-workspace-actions],[data-prts-session-actions]')) return null
|
|
163
|
+
const card=node.closest('[data-presented-file],[data-changed-files]')
|
|
164
|
+
const candidate=card || node.closest(selector)
|
|
165
|
+
return candidate && !candidate.matches(':disabled,[aria-disabled="true"]') && !candidate.closest('[inert]') ? candidate : null
|
|
166
|
+
}
|
|
167
|
+
const measure=()=>{bounds=target.getBoundingClientRect();targetArm=7+Math.max(0,Math.min(1,(Math.min(bounds.width,bounds.height)-36)/36))*5;boundsDirty=false}
|
|
168
|
+
const wake=()=>{if(!raf && !disposed)raf=window.requestAnimationFrame(draw)}
|
|
169
|
+
const choose=()=>{
|
|
170
|
+
const next=hovered || focused
|
|
171
|
+
if(next!==target){
|
|
172
|
+
target?.removeAttribute('data-prts-corner-target')
|
|
173
|
+
pressed=false;settled=false;tween=null;exitGoal=null;motionDuration=240;target=next;boundsDirty=true
|
|
174
|
+
resizeObserver?.disconnect()
|
|
175
|
+
if(target)resizeObserver?.observe(target)
|
|
176
|
+
if(target){
|
|
177
|
+
target.setAttribute('data-prts-corner-target','');showFrame()
|
|
178
|
+
measure();const box=bounds
|
|
179
|
+
if(!state || state.alpha<.01)state={x:(hovered?pointer.x:box.left+box.width/2)-10,y:(hovered?pointer.y:box.top+box.height/2)-10,w:20,h:20,alpha:0,arm:7}
|
|
180
|
+
const style=window.getComputedStyle(target)
|
|
181
|
+
frame.style.color=style.getPropertyValue(target.getAttribute('data-prts-conversation-control')==='danger'?'--prts-red':'--prts-yellow')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if(!settled || !target && state?.alpha>0)wake()
|
|
185
|
+
}
|
|
186
|
+
function draw(time) {
|
|
187
|
+
raf=0
|
|
188
|
+
if(hitDirty){hitDirty=false;hovered=find(document.elementFromPoint(pointer.x,pointer.y));choose()}
|
|
189
|
+
if(target && !target.isConnected){target.removeAttribute('data-prts-corner-target');target=hovered=focused=null;pressed=false;settled=false;exitGoal=null}
|
|
190
|
+
if(!state)return
|
|
191
|
+
if(target && boundsDirty){
|
|
192
|
+
measure()
|
|
193
|
+
if(!bounds.width && !bounds.height){target.removeAttribute('data-prts-corner-target');target=hovered=focused=null;pressed=false;settled=false;exitGoal=null;resizeObserver?.disconnect()}
|
|
194
|
+
}
|
|
195
|
+
const box=target?bounds:null,gap=pressed?3:5
|
|
196
|
+
// Freeze the exit destination: subsequent pointer motion must not restart the fade.
|
|
197
|
+
const goal=box?{x:box.left-gap,y:box.top-gap,w:box.width+gap*2,h:box.height+gap*2,alpha:1,arm:targetArm}:
|
|
198
|
+
(exitGoal ||= {x:pointer.x-10,y:pointer.y-10,w:20,h:20,alpha:0,arm:7})
|
|
199
|
+
// Align endpoints once; rounding each animation frame turns a 3px release into visible steps.
|
|
200
|
+
const ratio=window.devicePixelRatio || 1,pixel=value=>Math.round(value*ratio)/ratio
|
|
201
|
+
const edgeRight=pixel(goal.x+goal.w),edgeBottom=pixel(goal.y+goal.h)
|
|
202
|
+
goal.x=pixel(goal.x);goal.y=pixel(goal.y);goal.w=edgeRight-goal.x;goal.h=edgeBottom-goal.y
|
|
203
|
+
const keys=['x','y','w','h','alpha','arm']
|
|
204
|
+
if(!tween || keys.some(key=>tween.to[key]!==goal[key])){
|
|
205
|
+
const remaining=tween && tween.pressed===pressed?Math.max(0,tween.start+tween.duration-time):motionDuration
|
|
206
|
+
tween={from:{...state},to:{...goal},start:time,duration:remaining,pressed}
|
|
207
|
+
}
|
|
208
|
+
// All channels share one endpoint; no separate thresholds or late position snap.
|
|
209
|
+
const progress=reduced?.matches || settled || !tween.duration?1:Math.min(1,(time-tween.start)/tween.duration)
|
|
210
|
+
const ease=1-(1-progress)**3
|
|
211
|
+
for(const key of keys)state[key]=tween.from[key]+(goal[key]-tween.from[key])*ease
|
|
212
|
+
const moving=progress<1
|
|
213
|
+
const x=state.x,y=state.y
|
|
214
|
+
const right=state.x+state.w-7,bottom=state.y+state.h-7
|
|
215
|
+
const positions=[[x,y],[right,y],[x,bottom],[right,bottom]]
|
|
216
|
+
corners.forEach((corner,index)=>{
|
|
217
|
+
const transform=`translate3d(${positions[index][0]}px,${positions[index][1]}px,0)`
|
|
218
|
+
if(corner.style.transform!==transform)corner.style.transform=transform
|
|
219
|
+
})
|
|
220
|
+
const armScale=String(state.arm/7)
|
|
221
|
+
if(frame.style.getPropertyValue('--prts-arm-scale')!==armScale)frame.style.setProperty('--prts-arm-scale',armScale)
|
|
222
|
+
const opacity=String(state.alpha)
|
|
223
|
+
if(frame.style.opacity!==opacity)frame.style.opacity=opacity
|
|
224
|
+
settled=!moving
|
|
225
|
+
frame.toggleAttribute('data-pressed',pressed)
|
|
226
|
+
if(moving)wake()
|
|
227
|
+
else {tween=null;if(!state.alpha)hideFrame()}
|
|
228
|
+
}
|
|
229
|
+
const move=event=>{
|
|
230
|
+
if(document.hidden || event.pointerType==='touch' || !fine?.matches)return
|
|
231
|
+
pointer={x:event.clientX,y:event.clientY}
|
|
232
|
+
const next=find(event.target)
|
|
233
|
+
if(next===hovered && (next || !state?.alpha))return
|
|
234
|
+
hovered=next;choose()
|
|
235
|
+
}
|
|
236
|
+
const leave=()=>{hovered=null;choose()}
|
|
237
|
+
const focus=event=>{focused=event.target.matches?.(':focus-visible')?find(event.target):null;choose()}
|
|
238
|
+
const blur=()=>{focused=null;choose()}
|
|
239
|
+
const down=event=>{
|
|
240
|
+
pointer={x:event.clientX,y:event.clientY}
|
|
241
|
+
if(event.pointerType==='touch')hovered=find(event.target)
|
|
242
|
+
choose()
|
|
243
|
+
if(target && find(event.target)===target){pressed=true;motionDuration=100;settled=false;wake()}
|
|
244
|
+
}
|
|
245
|
+
const up=event=>{pressed=false;motionDuration=140;settled=false;if(event.pointerType==='touch')hovered=null;choose()}
|
|
246
|
+
const reset=()=>{
|
|
247
|
+
target?.removeAttribute('data-prts-corner-target');hideFrame()
|
|
248
|
+
hovered=focused=target=null;state=tween=bounds=exitGoal=null
|
|
249
|
+
pressed=false;settled=true;boundsDirty=hitDirty=false
|
|
250
|
+
resizeObserver?.disconnect();window.cancelAnimationFrame(raf);raf=0
|
|
251
|
+
frame.style.opacity='0';frame.removeAttribute('data-pressed')
|
|
252
|
+
}
|
|
253
|
+
const visibility=()=>{if(document.hidden)reset()}
|
|
254
|
+
const scroll=()=>{if(!target && !state?.alpha)return;boundsDirty=true;hitDirty=!!hovered;wake()}
|
|
255
|
+
document.addEventListener('scroll',scroll,true)
|
|
256
|
+
window.addEventListener('resize',reset)
|
|
257
|
+
const key=event=>{if(target && (event.key===' ' || event.key==='Enter')){pressed=event.type==='keydown';motionDuration=pressed?100:140;settled=false;wake()}}
|
|
258
|
+
const listeners=[
|
|
259
|
+
[document,'pointermove',move],[document,'pointerleave',leave],
|
|
260
|
+
[document,'focusin',focus],[document,'focusout',blur],
|
|
261
|
+
[document,'pointerdown',down],[document,'pointerup',up],[document,'pointercancel',reset],
|
|
262
|
+
[document,'keydown',key],[document,'keyup',key],[window,'blur',reset],
|
|
263
|
+
[window,'pagehide',reset],[document,'visibilitychange',visibility]
|
|
264
|
+
]
|
|
265
|
+
for(const [node,event,handler] of listeners)node.addEventListener(event,handler)
|
|
266
|
+
return ()=>{
|
|
267
|
+
disposed=true;target?.removeAttribute('data-prts-corner-target');hideFrame();window.cancelAnimationFrame(raf);resizeObserver?.disconnect()
|
|
268
|
+
document.removeEventListener('scroll',scroll,true);window.removeEventListener('resize',reset)
|
|
269
|
+
for(const [node,event,handler] of listeners)node.removeEventListener(event,handler)
|
|
270
|
+
frame.remove()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mountFileDrop } from './file-drop.js'
|
|
1
2
|
import { createBackgroundLayout } from './background-layout.js'
|
|
2
3
|
import { mountBackgroundRegionEditor, backgroundRegionMarkup } from './background-region-editor.js'
|
|
3
4
|
import { opacityDialMarkup, mountOpacityDials } from './opacity-dial.js'
|
|
@@ -87,10 +88,19 @@ export function backgroundMarkup(heroContrastControl = '') {
|
|
|
87
88
|
${backgroundRegionMarkup(slot)}
|
|
88
89
|
<input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
|
|
89
90
|
</article>`).join('')}</div>
|
|
91
|
+
<dialog data-prts-background-confirm aria-labelledby="prts-background-confirm-title" aria-describedby="prts-background-confirm-note">
|
|
92
|
+
<header><small>会话背景</small><h2 id="prts-background-confirm-title"></h2></header>
|
|
93
|
+
<div data-prts-background-compare>
|
|
94
|
+
<figure><img data-prts-background-before alt="当前背景"><figcaption>当前背景</figcaption></figure>
|
|
95
|
+
<figure><img data-prts-background-after alt="新背景"><figcaption>新背景<span data-prts-background-filename></span></figcaption></figure>
|
|
96
|
+
</div>
|
|
97
|
+
<p id="prts-background-confirm-note"></p>
|
|
98
|
+
<footer><button type="button" data-prts-background-confirm-cancel autofocus>取消</button><button type="button" data-prts-background-confirm-apply>替换背景</button></footer>
|
|
99
|
+
</dialog>
|
|
90
100
|
<label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
|
|
91
101
|
<div data-prts-background-hero-setting hidden>${heroContrastControl}</div>
|
|
92
102
|
${opacityDialMarkup()}
|
|
93
|
-
<small data-prts-background-help
|
|
103
|
+
<small data-prts-background-help>可将图片拖入对应背景区域。支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片铺满,可框选重要区域优先保留。切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
|
|
94
104
|
</section>`
|
|
95
105
|
}
|
|
96
106
|
export function mountBackgroundSettings({ panel, window, store, onChange }) {
|
|
@@ -99,6 +109,38 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
|
|
|
99
109
|
let disposed = false, busy = false, preferences = {}, state = store.snapshot()
|
|
100
110
|
const status = section.querySelector('[data-prts-background-status]')
|
|
101
111
|
const range = section.querySelector('input[type="range"]')
|
|
112
|
+
const confirmDialog = section.querySelector('[data-prts-background-confirm]')
|
|
113
|
+
let finishConfirmation, previewUrl, returnFocus
|
|
114
|
+
const finish = accepted => {
|
|
115
|
+
if (!finishConfirmation) return
|
|
116
|
+
const resolve = finishConfirmation; finishConfirmation = undefined
|
|
117
|
+
confirmDialog.close()
|
|
118
|
+
confirmDialog.querySelectorAll('img').forEach(image => image.removeAttribute('src'))
|
|
119
|
+
window.URL.revokeObjectURL(previewUrl); previewUrl = undefined
|
|
120
|
+
returnFocus?.focus?.({ preventScroll: true })
|
|
121
|
+
resolve(accepted)
|
|
122
|
+
}
|
|
123
|
+
const confirmReplace = (slot, file) => new Promise(resolve => {
|
|
124
|
+
returnFocus = section.querySelector(`[data-prts-background-upload="${slot}"]`)
|
|
125
|
+
previewUrl = window.URL.createObjectURL(file)
|
|
126
|
+
confirmDialog.querySelector('h2').textContent = `替换${backgroundLabels[slot]}?`
|
|
127
|
+
confirmDialog.querySelector('[data-prts-background-before]').src = backgroundImageUrl(state[slot])
|
|
128
|
+
confirmDialog.querySelector('[data-prts-background-after]').src = previewUrl
|
|
129
|
+
confirmDialog.querySelector('[data-prts-background-filename]').textContent = file.name
|
|
130
|
+
confirmDialog.querySelector('p').textContent = state[slot].region ? '替换后,当前图片的保护区域将清除。' : '确认后,新图片将应用到此区域。'
|
|
131
|
+
finishConfirmation = resolve
|
|
132
|
+
confirmDialog.showModal()
|
|
133
|
+
confirmDialog.querySelector('[data-prts-background-confirm-cancel]').focus()
|
|
134
|
+
})
|
|
135
|
+
const confirmClick = event => {
|
|
136
|
+
if (event.target.closest('[data-prts-background-confirm-cancel]')) finish(false)
|
|
137
|
+
if (event.target.closest('[data-prts-background-confirm-apply]')) finish(true)
|
|
138
|
+
}
|
|
139
|
+
const confirmCancel = event => { event.preventDefault(); event.stopPropagation(); finish(false) }
|
|
140
|
+
const confirmKey = event => { if (event.key === 'Escape') confirmCancel(event) }
|
|
141
|
+
confirmDialog.addEventListener('click', confirmClick)
|
|
142
|
+
confirmDialog.addEventListener('cancel', confirmCancel)
|
|
143
|
+
confirmDialog.addEventListener('keydown', confirmKey)
|
|
102
144
|
const regions = mountBackgroundRegionEditor({section,window,store})
|
|
103
145
|
const dials = mountOpacityDials({ section, window, onChange })
|
|
104
146
|
function render() {
|
|
@@ -121,9 +163,11 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
|
|
|
121
163
|
for (const button of section.querySelectorAll('[data-prts-background-upload], [data-prts-background-remove]')) button.disabled = busy
|
|
122
164
|
}
|
|
123
165
|
async function save(slot, file) {
|
|
124
|
-
if (busy) return
|
|
166
|
+
if (busy || finishConfirmation) return
|
|
125
167
|
if (file && file.size > 10 * 1024 * 1024) { status.textContent = '请上传不超过 10 MB 的图片'; return }
|
|
126
168
|
const revision = state.revision
|
|
169
|
+
if (file && state[slot] && !await confirmReplace(slot, file)) return
|
|
170
|
+
if (disposed) return
|
|
127
171
|
busy = true; status.textContent = '正在保存背景…'; render()
|
|
128
172
|
try {
|
|
129
173
|
let image = null
|
|
@@ -152,9 +196,15 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
|
|
|
152
196
|
}
|
|
153
197
|
const input = event => { if (event.target === range) section.querySelector('output').textContent = `${range.value}%` }
|
|
154
198
|
section.addEventListener('click', click); section.addEventListener('change', change); section.addEventListener('input', input)
|
|
199
|
+
const drops = Object.keys(backgroundLabels).map(slot => mountFileDrop({
|
|
200
|
+
area: section.querySelector(`[data-prts-background-image-controls="${slot}"]`),
|
|
201
|
+
input: section.querySelector(`[data-prts-background-file="${slot}"]`),
|
|
202
|
+
enabled: () => !busy && !finishConfirmation && !disposed,
|
|
203
|
+
onError: message => { status.textContent = message },
|
|
204
|
+
}))
|
|
155
205
|
const unsubscribe = store.subscribe(value => { state = value; render() })
|
|
156
206
|
return {
|
|
157
207
|
update(value) { preferences = value; render() },
|
|
158
|
-
dispose() { disposed = true; regions.dispose(); dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
|
|
208
|
+
dispose() { disposed = true; finish(false); confirmDialog.removeEventListener('click', confirmClick); confirmDialog.removeEventListener('cancel', confirmCancel); confirmDialog.removeEventListener('keydown', confirmKey); drops.forEach(drop => drop.dispose()); regions.dispose(); dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
|
|
159
209
|
}
|
|
160
210
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mountFileDrop } from './file-drop.js'
|
|
1
2
|
import { mountEmblemReorder } from './emblem-reorder.js'
|
|
2
3
|
import { PARTICLE_DENSITY_PROFILES } from './particle-field-adapter.js'
|
|
3
4
|
|
|
@@ -268,13 +269,13 @@ export function customEmblemMarkup() {
|
|
|
268
269
|
<strong data-prts-emblem-target></strong>
|
|
269
270
|
<button type="button" data-prts-emblem-cancel aria-label="关闭徽记编辑">×</button></div>
|
|
270
271
|
<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
|
|
272
|
+
<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
273
|
<div data-prts-emblem-edit-controls>
|
|
273
274
|
<label>素材来源<select data-prts-emblem-source><option value="image">上传图片</option><option value="package">导入素材包</option></select></label>
|
|
274
275
|
<label data-prts-emblem-source-kind="image">选择图片<input type="file" accept="image/png,image/jpeg,image/webp" data-prts-emblem-file></label>
|
|
275
276
|
<small data-prts-emblem-source-kind="image">支持 PNG、WebP、JPEG,最大 8 MB / 1600 万像素。透明底标志和黑白剪影效果更好。</small>
|
|
276
277
|
<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
|
|
278
|
+
<small data-prts-emblem-source-kind="package" hidden>选择“导入素材包”后可拖入 .prts-emblem.json,恢复原图和调整参数,预览后再应用。</small>
|
|
278
279
|
<div data-prts-emblem-editor hidden>
|
|
279
280
|
<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
281
|
<small>线稿模式提取轮廓;阈值越高,保留的细节越少。彩色原图建议先由 AI 简化背景与纹理。</small>
|
|
@@ -724,8 +725,13 @@ export function mountCustomEmblemEditor({ panel, window, store }) {
|
|
|
724
725
|
canvas.addEventListener('pointercancel',abortDrag)
|
|
725
726
|
canvas.addEventListener('lostpointercapture',abortDrag)
|
|
726
727
|
canvas.addEventListener('keydown',positionKey)
|
|
728
|
+
const drop = mountFileDrop({ area: get('workspace'),
|
|
729
|
+
input: () => get('source').value === 'package' ? get('import') : fileInput,
|
|
730
|
+
enabled: () => get('workspace').open && destination !== null && !saving && !processing && !disposed,
|
|
731
|
+
onError: message => { status.textContent = message },
|
|
732
|
+
})
|
|
727
733
|
return { prefetch, close:cancel, cancelReorder: () => reorder.cancel(), dispose() {
|
|
728
|
-
cancel()
|
|
734
|
+
drop.dispose(); cancel()
|
|
729
735
|
get('workspace').removeEventListener('cancel',editorCancel)
|
|
730
736
|
get('discard').removeEventListener('cancel',editorCancel)
|
|
731
737
|
get('reset-confirm').removeEventListener('cancel',resetCancel)
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
// Cards own touch gestures: immediate swipes scroll, while a hold locks into sorting.
|
|
2
2
|
export function mountEmblemReorder({ list, window, status, canStart, onStart, onCommit }) {
|
|
3
3
|
let gesture = null, holdTimer, frame, previousTime = 0, suppressClick = false
|
|
4
|
+
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
5
|
+
const animations = new Map()
|
|
6
|
+
const stopAnimations = () => { for (const animation of animations.values()) animation.cancel(); animations.clear() }
|
|
7
|
+
const rearrange = mutate => {
|
|
8
|
+
const before = new Map(rows().map(row => [row, row.getBoundingClientRect().top]))
|
|
9
|
+
stopAnimations()
|
|
10
|
+
mutate()
|
|
11
|
+
if (reduced()) return
|
|
12
|
+
for (const row of rows()) {
|
|
13
|
+
const offset = before.get(row) - row.getBoundingClientRect().top
|
|
14
|
+
if (!offset || (row === gesture?.row && !gesture.keyboard)) continue
|
|
15
|
+
const animation = row.animate?.([{transform:`translateY(${offset}px)`},{transform:'translateY(0)'}],{duration:220,easing:'cubic-bezier(.2,.8,.2,1)'})
|
|
16
|
+
if (animation) { animations.set(row,animation); animation.onfinish=()=>animations.delete(row) }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
4
19
|
const rows = () => [...list.children].filter(row => row.hasAttribute('data-emblem-key'))
|
|
5
20
|
const scroller = list.closest('[data-prts-theme-settings-scroll]')
|
|
6
21
|
const clear = () => {
|
|
@@ -10,7 +25,7 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
10
25
|
const finish = (commit = false) => {
|
|
11
26
|
const current = gesture
|
|
12
27
|
if (!current) return
|
|
13
|
-
gesture = null; clear()
|
|
28
|
+
gesture = null; clear(); stopAnimations(); current.landing?.cancel()
|
|
14
29
|
current.ghost?.remove();current.row.removeAttribute('data-prts-emblem-dragging')
|
|
15
30
|
list.removeAttribute('data-prts-emblem-sorting')
|
|
16
31
|
scroller?.removeAttribute('data-prts-emblem-sort-active')
|
|
@@ -29,14 +44,14 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
29
44
|
const current = gesture
|
|
30
45
|
if (!current?.active) return
|
|
31
46
|
const others = rows().filter(row=>row!==current.row)
|
|
32
|
-
const next = others.find(row=>{const box=row.getBoundingClientRect();return y < box.top + box.height/2})
|
|
47
|
+
const next = others.find(row=>{const box=row.getBoundingClientRect();const offset=animations.has(row)?new window.DOMMatrixReadOnly(window.getComputedStyle(row).transform).m42:0;return y < box.top-offset + box.height/2})
|
|
33
48
|
if (next) {
|
|
34
|
-
if (current.row.nextElementSibling !== next) list.insertBefore(current.row,next)
|
|
35
|
-
} else if (others.at(-1)?.nextElementSibling !== current.row) list.insertBefore(current.row,current.ghost)
|
|
49
|
+
if (current.row.nextElementSibling !== next) rearrange(()=>list.insertBefore(current.row,next))
|
|
50
|
+
} else if (others.at(-1)?.nextElementSibling !== current.row) rearrange(()=>list.insertBefore(current.row,current.ghost))
|
|
36
51
|
current.position = rows().indexOf(current.row)
|
|
37
52
|
}
|
|
38
53
|
const tick = time => {
|
|
39
|
-
if (!gesture?.active || gesture.keyboard) return
|
|
54
|
+
if (!gesture?.active || gesture.keyboard || gesture.settling) return
|
|
40
55
|
const current = gesture
|
|
41
56
|
if (!list.isConnected || !list.getClientRects().length) { finish();return }
|
|
42
57
|
const delta = previousTime ? Math.min(32,time-previousTime) : 16
|
|
@@ -48,7 +63,9 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
48
63
|
if (speed) scroller.scrollTop += speed * delta * .5
|
|
49
64
|
}
|
|
50
65
|
place(current.y)
|
|
51
|
-
current.
|
|
66
|
+
const dx=current.x-current.startX,dy=current.y-current.startY
|
|
67
|
+
const tilt=reduced()?0:Math.max(-3,Math.min(3,dx*.035))
|
|
68
|
+
current.ghost.style.transform=`translate3d(${dx}px,${dy}px,0) rotate(${tilt}deg) scale(${reduced()?1:1.025})`
|
|
52
69
|
frame=window.requestAnimationFrame(tick)
|
|
53
70
|
}
|
|
54
71
|
const activate = () => {
|
|
@@ -79,6 +96,7 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
79
96
|
status.textContent='正在调整徽记顺序,松开保存;按 Esc 取消'
|
|
80
97
|
}
|
|
81
98
|
const down = event => {
|
|
99
|
+
if (gesture?.settling) finish(true)
|
|
82
100
|
if (gesture) { if (event.pointerId!==gesture.pointerId) finish();return }
|
|
83
101
|
const row=event.target.closest('[data-emblem-key]')
|
|
84
102
|
const handle=row?.querySelector('[data-prts-emblem-reorder]')
|
|
@@ -92,7 +110,7 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
92
110
|
if (sortable) holdTimer=window.setTimeout(activate,400)
|
|
93
111
|
}
|
|
94
112
|
const move = event => {
|
|
95
|
-
if (!gesture || gesture.pointerId!==event.pointerId) return
|
|
113
|
+
if (!gesture || gesture.settling || gesture.pointerId!==event.pointerId) return
|
|
96
114
|
const previousY=gesture.y
|
|
97
115
|
gesture.x=event.clientX;gesture.y=event.clientY
|
|
98
116
|
if (!gesture.active && !gesture.scrolling && Math.hypot(gesture.x-gesture.startX,gesture.y-gesture.startY)>8) {
|
|
@@ -110,18 +128,31 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
110
128
|
if (gesture?.pointerId!==event.pointerId) return
|
|
111
129
|
if (gesture.scrolling) {finish();return}
|
|
112
130
|
const box=list.getBoundingClientRect(), viewport=(scroller || list).getBoundingClientRect()
|
|
113
|
-
|
|
131
|
+
const commit=event.clientX>=box.left && event.clientX<=box.right && event.clientY>=viewport.top && event.clientY<=viewport.bottom
|
|
132
|
+
if (!commit || !gesture.active || reduced() || !gesture.ghost?.animate) {finish(commit);return}
|
|
133
|
+
const current=gesture
|
|
134
|
+
place(event.clientY)
|
|
135
|
+
current.settling=true;clear()
|
|
136
|
+
current.ghost.style.transform='none'
|
|
137
|
+
const target=current.row.getBoundingClientRect(), floating=current.ghost.getBoundingClientRect()
|
|
138
|
+
const dx=target.left-floating.left,dy=target.top-floating.top
|
|
139
|
+
current.landing=current.ghost.animate([
|
|
140
|
+
{transform:`translate3d(${current.x-current.startX}px,${current.y-current.startY}px,0) rotate(${Math.max(-3,Math.min(3,(current.x-current.startX)*.035))}deg) scale(1.025)`},
|
|
141
|
+
{transform:`translate3d(${dx}px,${dy}px,0) scale(1)`,opacity:1}
|
|
142
|
+
],{duration:160,easing:'cubic-bezier(.2,.8,.2,1)',fill:'forwards'})
|
|
143
|
+
current.landing.onfinish=()=>{if(gesture===current)finish(true)}
|
|
114
144
|
}
|
|
115
145
|
const cancelPointer = event => {if (gesture?.pointerId===event.pointerId) finish()}
|
|
116
146
|
// A touch initially belongs to its hit target. Transferring capture to the
|
|
117
147
|
// list emits a bubbling loss event from that child, not a canceled drag.
|
|
118
148
|
const lostCapture = event => {
|
|
119
|
-
if (event.target === list && !list.hasPointerCapture(event.pointerId)) cancelPointer(event)
|
|
149
|
+
if (!gesture?.settling && event.target === list && !list.hasPointerCapture(event.pointerId)) cancelPointer(event)
|
|
120
150
|
}
|
|
121
151
|
const cancel = () => finish()
|
|
122
152
|
const visibility = () => {if (window.document.hidden) finish()}
|
|
123
153
|
const keydown = event => {
|
|
124
154
|
if (event.key==='Escape' && gesture) {event.preventDefault();event.stopPropagation();finish();return}
|
|
155
|
+
if (gesture?.settling) finish(true)
|
|
125
156
|
const handle=event.target.closest?.('[data-prts-emblem-reorder]')
|
|
126
157
|
if (!handle || handle.disabled || !canStart()) return
|
|
127
158
|
if ([' ','Enter'].includes(event.key)) {
|
|
@@ -134,7 +165,7 @@ export function mountEmblemReorder({ list, window, status, canStart, onStart, on
|
|
|
134
165
|
event.preventDefault()
|
|
135
166
|
const current=rows(),index=current.indexOf(gesture.row)
|
|
136
167
|
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)))
|
|
137
|
-
if (target!==index)list.insertBefore(gesture.row,target<index?current[target]:current[target].nextSibling)
|
|
168
|
+
if (target!==index)rearrange(()=>list.insertBefore(gesture.row,target<index?current[target]:current[target].nextSibling))
|
|
138
169
|
gesture.handle.focus({preventScroll:true});gesture.row.scrollIntoView({block:'nearest'})
|
|
139
170
|
status.textContent=`当前位置:第 ${target+1} 项,共 ${current.length} 项;空格保存`
|
|
140
171
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const extensions = {
|
|
2
|
+
'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'],
|
|
3
|
+
'image/avif': ['.avif'], 'image/gif': ['.gif'], 'application/json': ['.json'],
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Feed the existing input/change flow so picker and drop share validation and saving.
|
|
7
|
+
export function mountFileDrop({ area, input, enabled = () => true, onError }) {
|
|
8
|
+
let depth = 0
|
|
9
|
+
const getInput = () => typeof input === 'function' ? input() : input
|
|
10
|
+
const clear = () => { depth = 0; area.removeAttribute('data-prts-drop-active') }
|
|
11
|
+
const available = () => enabled() && !getInput()?.disabled && !area.closest('[hidden], [inert]')
|
|
12
|
+
const isFile = event => Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
|
13
|
+
|| Boolean(event.dataTransfer?.files?.length)
|
|
14
|
+
const enter = event => {
|
|
15
|
+
if (!isFile(event)) return
|
|
16
|
+
event.preventDefault(); event.stopPropagation()
|
|
17
|
+
depth++
|
|
18
|
+
if (available()) area.setAttribute('data-prts-drop-active', '')
|
|
19
|
+
}
|
|
20
|
+
const over = event => {
|
|
21
|
+
if (!isFile(event)) return
|
|
22
|
+
event.preventDefault(); event.stopPropagation()
|
|
23
|
+
event.dataTransfer.dropEffect = available() ? 'copy' : 'none'
|
|
24
|
+
if (!available()) clear()
|
|
25
|
+
}
|
|
26
|
+
const leave = event => {
|
|
27
|
+
if (!depth) return
|
|
28
|
+
event.stopPropagation()
|
|
29
|
+
if (--depth <= 0) clear()
|
|
30
|
+
}
|
|
31
|
+
const drop = event => {
|
|
32
|
+
if (!isFile(event)) return
|
|
33
|
+
event.preventDefault(); event.stopPropagation(); clear()
|
|
34
|
+
if (!available()) return
|
|
35
|
+
const files = event.dataTransfer.files, target = getInput()
|
|
36
|
+
if (files.length !== 1) { onError('一次只能添加一个文件'); return }
|
|
37
|
+
const file = files[0], name = file.name.toLowerCase()
|
|
38
|
+
const accepts = target.accept.toLowerCase().split(',').map(value => value.trim()).filter(Boolean)
|
|
39
|
+
const matches = accepts.some(type => type.startsWith('.') ? name.endsWith(type)
|
|
40
|
+
: file.type.toLowerCase() === type || (!file.type && extensions[type]?.some(ext => name.endsWith(ext))))
|
|
41
|
+
if (accepts.length && !matches) { onError('文件类型不支持,请选择此处支持的图片或文件'); return }
|
|
42
|
+
target.files = files
|
|
43
|
+
target.dispatchEvent(new area.ownerDocument.defaultView.Event('change', { bubbles: true }))
|
|
44
|
+
}
|
|
45
|
+
const handlers = { dragenter: enter, dragover: over, dragleave: leave, drop, dragend: clear }
|
|
46
|
+
for (const [name, handler] of Object.entries(handlers)) area.addEventListener(name, handler)
|
|
47
|
+
return { dispose() { clear(); for (const [name, handler] of Object.entries(handlers)) area.removeEventListener(name, handler) } }
|
|
48
|
+
}
|