@ash-qw/dsh-theme-prts 2.4.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.4.0",
3
+ "version": "2.5.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 and the official desktop client",
@@ -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>拖动亮区选择头像范围,滚轮或触摸板调整大小。</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 fileLink=tokens.some(token=>/(?:^|_)file(?:Link|Mention)(?:_|$)/.test(token))
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 (fileLink || fileChange || button.closest('[data-produced-files-row]')) return 'file'
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
- operation = document.querySelector('[data-prts-region="operation"]')
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-transfer-open],[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>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片铺满,可框选重要区域优先保留。切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
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>选择图片后在此预览粒子效果</p></aside>
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>导入 .prts-emblem.json 可恢复原图和调整参数,预览后再应用。</small>
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.ghost.style.transform=`translate3d(${current.x-current.startX}px,${current.y-current.startY}px,0)`
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
- finish(event.clientX>=box.left && event.clientX<=box.right && event.clientY>=viewport.top && event.clientY<=viewport.bottom)
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
+ }