@ash-qw/dsh-theme-prts 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.3.0",
3
+ "version": "2.3.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",
@@ -0,0 +1,59 @@
1
+ import { backgroundPlacement } from '../shared/background-region.js'
2
+
3
+ export function createBackgroundLayout({window,document}) {
4
+ const root=document.documentElement
5
+ const raf=window.requestAnimationFrame?.bind(window) ?? (fn=>window.setTimeout(fn,16))
6
+ const caf=window.cancelAnimationFrame?.bind(window) ?? window.clearTimeout.bind(window)
7
+ let state={}, frame, disposed=false, enabled=false, observing=false, operation, sessions
8
+ const set=(name,value)=>{if(root.style.getPropertyValue(name)!==value) root.style.setProperty(name,value)}
9
+ const update=()=>{
10
+ frame=undefined
11
+ if(disposed||!observing)return
12
+ const nextOperation=document.querySelector('[data-prts-region="operation"]')
13
+ const nextSessions=document.querySelector('[data-prts-region="sessions"]')
14
+ if(operation!==nextOperation || sessions!==nextSessions){
15
+ resize?.disconnect(); operation=nextOperation;sessions=nextSessions
16
+ for(const node of [operation,sessions])if(node)resize?.observe(node)
17
+ }
18
+ const railWidth=parseFloat(window.getComputedStyle(root).getPropertyValue('--prts-nav-width'))||0
19
+ for(const slot of ['full','conversation','sidebar']){
20
+ const entry=state[slot]
21
+ const node=slot==='sidebar'?sessions:operation
22
+ const width=slot==='full'?window.innerWidth:(node?.clientWidth||0)+(slot==='sidebar'?railWidth:0)
23
+ const height=slot==='full'?window.innerHeight:node?.clientHeight||0
24
+ const active=entry?.region && width>0 && height>0
25
+ if(active){
26
+ const p=backgroundPlacement(entry.width,entry.height,width,height,entry.region)
27
+ set(`--prts-protected-${slot}-x`,`${p.x}px`)
28
+ set(`--prts-protected-${slot}-y`,`${p.y}px`)
29
+ if(slot==='sidebar')set('--prts-protected-sidebar-width',`${p.width}px`)
30
+ }else{
31
+ root.style.removeProperty(`--prts-protected-${slot}-x`)
32
+ root.style.removeProperty(`--prts-protected-${slot}-y`)
33
+ if(slot==='sidebar')root.style.removeProperty('--prts-protected-sidebar-width')
34
+ }
35
+ }
36
+ }
37
+ function schedule(){if(!disposed && observing && frame===undefined)frame=raf(update)}
38
+ const resize=window.ResizeObserver?new window.ResizeObserver(schedule):null
39
+ const mutations=new window.MutationObserver(()=>{
40
+ if(!operation?.isConnected || !sessions?.isConnected)schedule()
41
+ })
42
+ const attributes=new window.MutationObserver(schedule)
43
+ function clear(){
44
+ caf(frame);frame=undefined;resize?.disconnect();mutations.disconnect();attributes.disconnect();window.removeEventListener('resize',schedule)
45
+ operation=undefined;sessions=undefined;observing=false
46
+ for(const slot of ['full','conversation','sidebar'])for(const axis of ['x','y','width'])root.style.removeProperty(`--prts-protected-${slot}-${axis}`)
47
+ }
48
+ function reconcile(){
49
+ if(disposed || !enabled || !['full','conversation','sidebar'].some(slot=>state[slot]?.region)){clear();return}
50
+ if(!observing){
51
+ observing=true
52
+ mutations.observe(document.body||root,{childList:true,subtree:true})
53
+ attributes.observe(root,{attributes:true,attributeFilter:['data-prts-background-mode','data-prts-rail-mode']})
54
+ window.addEventListener('resize',schedule)
55
+ }
56
+ schedule()
57
+ }
58
+ return {update(value){state=value;reconcile()},setEnabled(value){enabled=value;reconcile()},dispose(){disposed=true;clear()}}
59
+ }
@@ -0,0 +1,103 @@
1
+ import { backgroundPlacement } from '../shared/background-region.js'
2
+
3
+ export function backgroundRegionMarkup(slot) {
4
+ return `<dialog data-prts-region-editor="${slot}" aria-labelledby="prts-region-title-${slot}">
5
+ <header><div><strong id="prts-region-title-${slot}">保护重要区域</strong><p>拖动框移动,拖动四边或四角缩放。横向优先保留中心,纵向优先保留顶端。</p></div><button type="button" data-prts-region-cancel aria-label="关闭保护区域设置">×</button></header>
6
+ <div data-prts-region-workspace><section data-prts-region-original><strong>原图与保护框</strong><div data-prts-region-stage><img alt="背景原图" draggable="false"><div data-prts-region-box tabindex="0" role="group" aria-label="保护区域,方向键移动"><span>保护区域</span>${['nw','ne','sw','se','n','e','s','w'].map((corner,i)=>`<button type="button" data-prts-region-handle="${corner}" aria-label="调整${['左上角','右上角','左下角','右下角','上边','右边','下边','左边'][i]},使用方向键"></button>`).join('')}</div><div data-prts-region-ruler="x"></div><div data-prts-region-ruler="y"></div></div><small>边缘刻度为图片坐标。可用方向键移动,Shift 加速。</small></section>
7
+ <section data-prts-region-samples><strong>窗口裁剪预览</strong><p>${slot==='sidebar' ? '按当前侧栏高度等比例预览,包含导航栏背景。' : '白框标出保护区域,调整时同步更新。'}</p><div data-prts-region-preview-grid>${(slot==='sidebar' ? [['narrow','较窄侧栏 · 264px'],['wide','较宽侧栏 · 420px']] : [['wide','横向窗口 · 16:9'],['narrow','纵向窗口 · 9:16']]).map(([kind,label])=>`<figure><figcaption>${label}</figcaption><div data-prts-region-preview="${kind}"><i></i></div><small data-prts-region-warning hidden>此比例无法完整显示保护区域:横向保中心,纵向保顶端。</small></figure>`).join('')}</div></section></div>
8
+ <footer><button type="button" data-prts-region-create>恢复默认框</button><button type="button" data-prts-region-clear>清除保护区域</button><span></span><button type="button" data-prts-region-cancel>取消</button><button type="button" data-prts-region-save>保存</button></footer><p data-prts-region-status role="status"></p>
9
+ </dialog>`
10
+ }
11
+ export function mountBackgroundRegionEditor({section,window,store}) {
12
+ let state=store.snapshot(), active, draft=null, revision, imageId, busy=false, disposed=false, drag
13
+ const clamp=(v,min,max)=>Math.max(min,Math.min(max,v))
14
+ const defaultRegion=()=>({x:.25,y:.2,width:.5,height:.6})
15
+ const editor=()=>active && section.querySelector(`[data-prts-region-editor="${active}"]`)
16
+ function draw(){
17
+ const el=editor(),entry=state[active];if(!el||!entry)return
18
+ const box=el.querySelector('[data-prts-region-box]')
19
+ box.hidden=!draft
20
+ if(draft)Object.assign(box.style,{left:`${draft.x*100}%`,top:`${draft.y*100}%`,width:`${draft.width*100}%`,height:`${draft.height*100}%`})
21
+ for(const axis of ['x','y']){
22
+ const ruler=el.querySelector(`[data-prts-region-ruler="${axis}"]`)
23
+ ruler.innerHTML=[0,25,50,75,100].filter(v=>!draft||Math.abs(v-draft[axis]*100)>8).map(v=>`<span style="${axis==='x'?'left':'top'}:${v}%">${v}</span>`).join('')
24
+ if(draft)ruler.innerHTML+=`<b style="${axis==='x'?'left':'top'}:${draft[axis]*100}%">${Math.round(draft[axis]*100)}%</b>`
25
+ }
26
+ const previews=[...el.querySelectorAll('[data-prts-region-preview]')]
27
+ const sidebarHeight=section.ownerDocument.querySelector('[data-prts-region="sessions"]')?.clientHeight||window.innerHeight
28
+ const railWidth=parseFloat(window.getComputedStyle(section.ownerDocument.documentElement).getPropertyValue('--prts-nav-width'))||0
29
+ // DSH 0.2.1-rc.2 clamps the sidebar track to 264–420px; use the same scale for both samples.
30
+ const sidebarScale=Math.min(240/Math.max(1,sidebarHeight),...previews.map(preview=>Math.max(1,preview.parentElement.clientWidth)/((preview.dataset.prtsRegionPreview==='narrow'?264:420)+railWidth)))
31
+ for(const preview of previews){
32
+ const narrow=preview.dataset.prtsRegionPreview==='narrow'
33
+ const w=active==='sidebar'?((narrow?264:420)+railWidth)*sidebarScale:Math.max(1,Math.min(narrow?112:200,preview.parentElement.clientWidth))
34
+ const h=active==='sidebar'?sidebarHeight*sidebarScale:w*(narrow?16/9:9/16)
35
+ const p=backgroundPlacement(entry.width,entry.height,w,h,draft)
36
+ Object.assign(preview.style,{width:`${w}px`,height:`${h}px`,backgroundImage:`url("/prts-theme-api/backgrounds/image/${entry.id}")`,backgroundSize:`${p.width}px ${p.height}px`,backgroundPosition:`${p.x}px ${p.y}px`})
37
+ const mark=preview.firstElementChild;mark.hidden=!draft
38
+ if(draft)Object.assign(mark.style,{left:`${p.x+draft.x*p.width}px`,top:`${p.y+draft.y*p.height}px`,width:`${draft.width*p.width}px`,height:`${draft.height*p.height}px`})
39
+ preview.parentElement.querySelector('[data-prts-region-warning]').hidden=!p.clipped
40
+ }
41
+ for(const button of el.querySelectorAll('button'))button.disabled=busy
42
+ }
43
+ const resize=window.ResizeObserver ? new window.ResizeObserver(()=>{if(!disposed)draw()}) : null
44
+ function close(){window.removeEventListener('resize',draw);resize?.disconnect();const el=editor();el?.querySelector('[data-prts-region-stage]').style.removeProperty('cursor');if(el?.open)el.close();if(active)section.querySelector(`[data-prts-background-region-open="${active}"]`)?.focus();active=undefined;drag=undefined}
45
+ function open(slot){
46
+ if(busy)return
47
+ close();const entry=state[slot];if(!entry?.width||!entry?.height)return
48
+ active=slot;revision=state.revision;imageId=entry.id;draft=entry.region?{...entry.region}:defaultRegion()
49
+ const el=editor();el.showModal();resize?.observe(el)
50
+ if(slot==='sidebar'){const sidebar=section.ownerDocument.querySelector('[data-prts-region="sessions"]');if(sidebar)resize?.observe(sidebar)}
51
+ window.addEventListener('resize',draw)
52
+ el.querySelector('[data-prts-region-stage]').style.width=`min(100%, ${Math.min(640,360*entry.width/entry.height)}px)`
53
+ el.querySelector('img').src=`/prts-theme-api/backgrounds/image/${entry.id}`
54
+ el.querySelector('[data-prts-region-status]').textContent=''
55
+ draw()
56
+ }
57
+ async function save(){
58
+ if(busy||!active)return
59
+ const el=editor(),slot=active,region=draft?{...draft}:null
60
+ busy=true;draw();el.querySelector('[data-prts-region-status]').textContent='正在保存…'
61
+ try{await store.saveRegion(slot,region,revision);if(!disposed){close();section.querySelector('[data-prts-background-status]').textContent='保护区域已保存,并同步到其他页面。';section.querySelector(`[data-prts-background-region-open="${slot}"]`).focus()}}
62
+ catch(error){if(!disposed)el.querySelector('[data-prts-region-status]').textContent=error.message+' 请取消后重新打开编辑。'}
63
+ finally{busy=false;if(!disposed)draw()}
64
+ }
65
+ const click=event=>{
66
+ const openButton=event.target.closest('[data-prts-background-region-open]');if(openButton){open(openButton.dataset.prtsBackgroundRegionOpen);return}
67
+ if(!active||busy)return
68
+ if(event.target.closest('[data-prts-region-cancel]'))close()
69
+ else if(event.target.closest('[data-prts-region-save]'))void save()
70
+ else if(event.target.closest('[data-prts-region-create]')){draft=defaultRegion();draw();editor().querySelector('[data-prts-region-box]').focus()}
71
+ else if(event.target.closest('[data-prts-region-clear]')){draft=null;draw()}
72
+ }
73
+ const down=event=>{
74
+ const el=editor(),stage=event.target.closest('[data-prts-region-stage]')
75
+ if(busy||!draft||!stage||!el?.contains(stage)||!event.target.closest('[data-prts-region-box]')||event.button!==0)return
76
+ event.preventDefault();const bounds=stage.getBoundingClientRect();if(!bounds.width||!bounds.height)return
77
+ const x=clamp((event.clientX-bounds.left)/bounds.width,0,1),y=clamp((event.clientY-bounds.top)/bounds.height,0,1)
78
+ drag={x,y,bounds,initial:{...draft},corner:event.target.dataset.prtsRegionHandle,id:event.pointerId}
79
+ stage.style.cursor=window.getComputedStyle(event.target).cursor
80
+ stage.setPointerCapture?.(event.pointerId);draw()
81
+ }
82
+ function adjust(initial,dx,dy,corner){
83
+ if(!corner)return {...initial,x:clamp(initial.x+dx,0,1-initial.width),y:clamp(initial.y+dy,0,1-initial.height)}
84
+ let left=initial.x,top=initial.y,right=left+initial.width,bottom=top+initial.height
85
+ if(corner.includes('w'))left=clamp(left+dx,0,right-.01);else if(corner.includes('e'))right=clamp(right+dx,left+.01,1)
86
+ if(corner.includes('n'))top=clamp(top+dy,0,bottom-.01);else if(corner.includes('s'))bottom=clamp(bottom+dy,top+.01,1)
87
+ return {x:left,y:top,width:/[ew]/.test(corner)?right-left:initial.width,height:/[ns]/.test(corner)?bottom-top:initial.height}
88
+ }
89
+ const move=event=>{if(!drag||event.pointerId!==drag.id)return;draft=adjust(drag.initial,(event.clientX-drag.bounds.left)/drag.bounds.width-drag.x,(event.clientY-drag.bounds.top)/drag.bounds.height-drag.y,drag.corner);draw()}
90
+ const up=event=>{if(drag&&event.pointerId===drag.id){if(event.type==='pointercancel')draft=drag.initial;drag=undefined;editor()?.querySelector('[data-prts-region-stage]').style.removeProperty('cursor');draw()}}
91
+ const key=event=>{
92
+ if(!active||busy)return
93
+ if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close();return}
94
+ if(!draft||!event.target.closest('[data-prts-region-box]')||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key))return
95
+ event.preventDefault();const step=event.shiftKey ? .05 : .01
96
+ draft=adjust(draft,event.key==='ArrowLeft'?-step:event.key==='ArrowRight'?step:0,event.key==='ArrowUp'?-step:event.key==='ArrowDown'?step:0,event.target.dataset.prtsRegionHandle);draw()
97
+ }
98
+ const cancel=event=>{if(event.target.matches('[data-prts-region-editor]')){event.preventDefault();event.stopPropagation();if(!busy)close()}}
99
+ section.addEventListener('cancel',cancel,true)
100
+ const handlers={click,pointerdown:down,pointermove:move,pointerup:up,pointercancel:up,keydown:key}
101
+ for(const [name,fn] of Object.entries(handlers))section.addEventListener(name,fn)
102
+ return {update(value,mode){state=value;if(active&&(state[active]?.id!==imageId||mode==='off'||(mode==='full')!==(active==='full')))close()},dispose(){disposed=true;close();section.removeEventListener('cancel',cancel,true);for(const [name,fn] of Object.entries(handlers))section.removeEventListener(name,fn)}}
103
+ }
@@ -69,7 +69,7 @@ export function createCursorAdapter({ document, window, assets }) {
69
69
  const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
70
70
  if (target === resizeTarget && target.style.cursor === resizeApplied) return
71
71
  clearResize()
72
- if (!target) return
72
+ if (!target || target.closest('[data-prts-region-stage]')) return
73
73
  const cursor = window.getComputedStyle(target).cursor
74
74
  const direction = { 'n-resize': 'ns', 's-resize': 'ns', 'ns-resize': 'ns', 'row-resize': 'ns',
75
75
  'e-resize': 'ew', 'w-resize': 'ew', 'ew-resize': 'ew', 'col-resize': 'ew',
@@ -1,3 +1,5 @@
1
+ import { createBackgroundLayout } from './background-layout.js'
2
+ import { mountBackgroundRegionEditor, backgroundRegionMarkup } from './background-region-editor.js'
1
3
  import { opacityDialMarkup, mountOpacityDials } from './opacity-dial.js'
2
4
  const BACKGROUND_ENDPOINT = '/prts-theme-api/backgrounds'
3
5
  const backgroundLabels = { sidebar: '侧边栏', conversation: '会话正文', full: '完整背景' }
@@ -7,10 +9,12 @@ function backgroundImageUrl(entry) {
7
9
  export function createBackgroundStore({ window, document }) {
8
10
  let state = { revision: -1, sidebar: null, conversation: null, full: null }, disposed = false, events, refreshing
9
11
  const listeners = new Set()
12
+ const layout = createBackgroundLayout({window,document})
10
13
  const abort = window.AbortController ? new window.AbortController() : null
11
14
  const apply = value => {
12
15
  if (disposed || value.revision < state.revision) return
13
16
  state = value
17
+ layout.update(state)
14
18
  for (const slot of Object.keys(backgroundLabels)) {
15
19
  const url = backgroundImageUrl(state[slot])
16
20
  const root = document.documentElement
@@ -48,12 +52,18 @@ export function createBackgroundStore({ window, document }) {
48
52
  window.addEventListener('focus', focus)
49
53
  return {
50
54
  snapshot: () => state, refresh,
55
+ setEnabled: value => layout.setEnabled(value),
51
56
  subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn) },
52
57
  async save(slot, image, revision = state.revision) {
53
58
  try { return await request({ slot, image, revision }) }
54
59
  catch (error) { await refresh().catch(() => {}); throw error }
55
60
  },
61
+ async saveRegion(slot, region, revision = state.revision) {
62
+ try { return await request({ slot, region, revision }) }
63
+ catch (error) { await refresh().catch(() => {}); throw error }
64
+ },
56
65
  dispose() {
66
+ layout.dispose()
57
67
  document.documentElement.style.removeProperty('--prts-sidebar-image-ratio')
58
68
  disposed = true; abort?.abort(); events?.close(); listeners.clear(); window.removeEventListener('focus', focus)
59
69
  for (const slot of Object.keys(backgroundLabels)) {
@@ -71,13 +81,16 @@ export function backgroundMarkup(heroContrastControl = '') {
71
81
  </div>
72
82
  <div data-prts-background-images hidden>${Object.entries(backgroundLabels).map(([slot,label]) => `<article data-prts-background-card="${slot}" hidden>
73
83
  <div data-prts-background-card-heading><strong>${label}</strong><button type="button" data-prts-background-remove="${slot}" aria-label="移除${label}图片" hidden>移除</button></div>
74
- <button type="button" data-prts-background-upload="${slot}" aria-label="上传或替换${label}"><span data-prts-background-thumbnail="${slot}" aria-hidden="true"></span><span data-prts-background-caption="${slot}">+ 选择图片</span></button>
84
+ <div data-prts-background-image-controls="${slot}"><span data-prts-background-thumbnail="${slot}" aria-hidden="true"></span><span data-prts-background-empty>尚未选择图片</span>
85
+ <div data-prts-background-image-actions><button type="button" data-prts-background-region-open="${slot}" hidden><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 3H3v4m10-4h4v4M3 13v4h4m10-4v4h-4"/><rect x="6.5" y="6.5" width="7" height="7" rx="1.5"/></svg><span>保护区域</span></button><button type="button" data-prts-background-upload="${slot}" aria-label="上传或替换${label}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="14" height="14" rx="3"/><path d="m4 14 4-4 3 3 2-2 4 4"/><circle cx="12.5" cy="7" r="1"/></svg><span data-prts-background-caption="${slot}">选择图片</span></button></div>
86
+ </div>
87
+ ${backgroundRegionMarkup(slot)}
75
88
  <input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
76
89
  </article>`).join('')}</div>
77
90
  <label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
78
91
  <div data-prts-background-hero-setting hidden>${heroContrastControl}</div>
79
92
  ${opacityDialMarkup()}
80
- <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片居中铺满,切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
93
+ <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片铺满,可框选重要区域优先保留。切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
81
94
  </section>`
82
95
  }
83
96
  export function mountBackgroundSettings({ panel, window, store, onChange }) {
@@ -86,9 +99,11 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
86
99
  let disposed = false, busy = false, preferences = {}, state = store.snapshot()
87
100
  const status = section.querySelector('[data-prts-background-status]')
88
101
  const range = section.querySelector('input[type="range"]')
102
+ const regions = mountBackgroundRegionEditor({section,window,store})
89
103
  const dials = mountOpacityDials({ section, window, onChange })
90
104
  function render() {
91
105
  dials.update(preferences, state)
106
+ regions.update(state, preferences.backgroundMode || 'off')
92
107
  const mode = preferences.backgroundMode || 'off'
93
108
  section.querySelector('[data-prts-background-images]').hidden = mode === 'off'
94
109
  section.querySelector('[data-prts-background-shade]').hidden = mode === 'off'
@@ -97,10 +112,11 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
97
112
  for (const slot of Object.keys(backgroundLabels)) {
98
113
  const url = backgroundImageUrl(state[slot])
99
114
  section.querySelector(`[data-prts-background-card="${slot}"]`).hidden = mode === 'off' || (mode === 'full') !== (slot === 'full')
100
- section.querySelector(`[data-prts-background-upload="${slot}"]`).toggleAttribute('data-has-image', Boolean(url))
115
+ section.querySelector(`[data-prts-background-image-controls="${slot}"]`).toggleAttribute('data-has-image', Boolean(url))
101
116
  section.querySelector(`[data-prts-background-thumbnail="${slot}"]`).style.backgroundImage = url ? `url("${url}")` : ''
102
- section.querySelector(`[data-prts-background-caption="${slot}"]`).textContent = url ? '替换图片' : '+ 选择图片'
117
+ section.querySelector(`[data-prts-background-caption="${slot}"]`).textContent = url ? '替换图片' : '选择图片'
103
118
  section.querySelector(`[data-prts-background-remove="${slot}"]`).hidden = !url
119
+ section.querySelector(`[data-prts-background-region-open="${slot}"]`).hidden = !url
104
120
  }
105
121
  for (const button of section.querySelectorAll('[data-prts-background-upload], [data-prts-background-remove]')) button.disabled = busy
106
122
  }
@@ -139,6 +155,6 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
139
155
  const unsubscribe = store.subscribe(value => { state = value; render() })
140
156
  return {
141
157
  update(value) { preferences = value; render() },
142
- dispose() { disposed = true; dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
158
+ dispose() { disposed = true; regions.dispose(); dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
143
159
  }
144
160
  }
@@ -179,6 +179,7 @@ export function applyPrtsPlugin(ctx, environment) {
179
179
  }
180
180
 
181
181
  const disposeVisualState = () => {
182
+ backgroundStore.setEnabled(false)
182
183
  particleField.update({ ...preferences, enabled: false })
183
184
  startup.stop({ restoreFocus: false })
184
185
  stopStartupReadyWatch()
@@ -201,6 +202,7 @@ export function applyPrtsPlugin(ctx, environment) {
201
202
  disposeVisualState()
202
203
  return
203
204
  }
205
+ backgroundStore.setEnabled(preferences.backgroundMode !== 'off')
204
206
  if (!themeAlreadyApplied) theme.apply(preferences)
205
207
  operations ||= createOperationsShell({
206
208
  document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore, backgroundStore,
@@ -0,0 +1,125 @@
1
+ // Reveal the temporarily selected card before mounting the destination conversation.
2
+ // Keep the destination stable through unrelated reflow; retarget smoothly only when needed.
3
+ export function createSessionCardReveal({window, root, sessionId, onDone, onStart}) {
4
+ let row, scroller, frame, timer, started = false, disposed = false, geometry, destination, stableSince, lastPosition
5
+ const findRow = () => [...root.querySelectorAll('[data-row-key], [data-session-id]')].find(el =>
6
+ el.getAttribute('data-row-key') === `session:${sessionId}` || el.getAttribute('data-session-id') === sessionId)
7
+ const instant = top => {
8
+ try { scroller.scrollTo({top, behavior: 'instant'}) }
9
+ catch { scroller.scrollTop = top }
10
+ }
11
+ function cancel() {
12
+ if (disposed) return
13
+ disposed = true
14
+ if (scroller && destination !== undefined) instant(scroller.scrollTop)
15
+ destination = undefined
16
+ window.cancelAnimationFrame(frame)
17
+ window.clearTimeout(timer)
18
+ resize?.disconnect()
19
+ mutations.disconnect()
20
+ root.removeEventListener('transitionend', schedule)
21
+ root.removeEventListener('animationend', schedule)
22
+ scroller?.removeEventListener('scrollend', scrollEnded)
23
+ }
24
+ function scrollEnded() {
25
+ if (destination !== undefined && Math.abs(scroller.scrollTop - destination) <= 1) destination = undefined
26
+ schedule()
27
+ }
28
+ function schedule() {
29
+ if (!disposed && frame === undefined) frame = window.requestAnimationFrame(() => { frame = undefined; check() })
30
+ }
31
+ function check(final = false) {
32
+ if (disposed) return
33
+ const nextRow = findRow()
34
+ if (!nextRow || (nextRow.getAttribute('data-prts-selection-target') !== 'true' && nextRow.getAttribute('aria-selected') !== 'true')) {
35
+ stableSince = undefined
36
+ if (started && !final) schedule()
37
+ return
38
+ }
39
+ const remounted = nextRow !== row
40
+ if (remounted) {
41
+ row = nextRow
42
+ const previousScroller = scroller
43
+ scroller?.removeEventListener('scrollend', scrollEnded)
44
+ resize?.disconnect()
45
+ scroller = undefined
46
+ for (let node = row.parentElement; node && node !== root.parentElement; node = node.parentElement) {
47
+ if (/^(auto|scroll)$/.test(window.getComputedStyle(node).overflowY) && node.clientHeight > 0) { scroller = node; break }
48
+ }
49
+ if (scroller !== previousScroller) destination = undefined
50
+ for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
51
+ for (const animation of node.getAnimations?.() ?? []) {
52
+ const timing = animation.effect?.getComputedTiming?.()
53
+ if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animation.finished.then(schedule, schedule)
54
+ }
55
+ }
56
+ resize?.observe(root)
57
+ if (scroller) { resize?.observe(scroller); scroller.addEventListener('scrollend', scrollEnded) }
58
+ }
59
+ let settled = true
60
+ const first = !started
61
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
62
+ || root.ownerDocument.documentElement.getAttribute('data-prts-motion') === 'reduced'
63
+ if (scroller) {
64
+ // Read all geometry before writing scrollTop. Offset is independent of smooth-scroll progress.
65
+ const card = row.getBoundingClientRect(), box = scroller.getBoundingClientRect()
66
+ const current = scroller.scrollTop, height = scroller.clientHeight
67
+ const top = card.top - box.top - scroller.clientTop, bottom = card.bottom - box.top - scroller.clientTop
68
+ const next = [top + current, card.height, height, scroller.scrollHeight]
69
+ const changed = remounted || !geometry || next.some((v, i) => Math.abs(v - geometry[i]) > 1)
70
+ if (first || changed || final) {
71
+ // Evaluate visibility at the pending destination, not at an intermediate animation frame.
72
+ // Rows below the target may change scrollHeight without invalidating that destination.
73
+ const base = destination ?? current
74
+ const offset = next[0], bottomOffset = offset + card.height
75
+ const limit = Math.max(0, next[3] - height)
76
+ const nearest = position => Math.max(0, Math.min(limit,
77
+ offset < position ? offset : bottomOffset > position + height ? bottomOffset - height : position))
78
+ // A stale endpoint can lie on the other side of a card that is already visible.
79
+ // Preserve valid endpoints, but correct invalid ones from the actual viewport.
80
+ const target = Math.abs(nearest(base) - base) > 1 ? nearest(current) : base
81
+ if (final) {
82
+ // End any native animation at the nearest visible position before disconnecting.
83
+ instant(nearest(current))
84
+ destination = undefined
85
+ } else if (Math.abs(target - base) > 1) {
86
+ destination = target
87
+ if (!reduced && typeof scroller.scrollTo === 'function') scroller.scrollTo({top: target, behavior: 'smooth'})
88
+ else instant(target)
89
+ }
90
+ }
91
+ settled = !changed && Math.abs(current - (lastPosition ?? current)) <= 1
92
+ && (destination === undefined || Math.abs(scroller.scrollTop - destination) <= 1)
93
+ lastPosition = current
94
+ geometry = next
95
+ } else if (first || remounted || final) {
96
+ row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:'instant'})
97
+ }
98
+ for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
99
+ if ((node.getAnimations?.() ?? []).some(animation => animation.playState === 'running'
100
+ && Number.isFinite(animation.effect?.getComputedTiming?.().endTime))) settled = false
101
+ }
102
+ if (first) {
103
+ started = true
104
+ onStart?.()
105
+ // Bound the pre-navigation wait; finish any remaining scroll before opening.
106
+ timer = window.setTimeout(() => { check(true); cancel(); onDone() }, 1000)
107
+ }
108
+ if (!final) {
109
+ const now = window.performance.now()
110
+ if (!settled) stableSince = undefined
111
+ else stableSince ??= now
112
+ if (stableSince !== undefined && now - stableSince >= 80) {
113
+ cancel()
114
+ onDone()
115
+ } else schedule()
116
+ }
117
+ }
118
+ const resize = typeof window.ResizeObserver === 'function' ? new window.ResizeObserver(schedule) : undefined
119
+ const mutations = new window.MutationObserver(schedule)
120
+ mutations.observe(root, {childList:true, subtree:true, attributes:true, attributeFilter:['aria-selected','aria-expanded','data-prts-selection-target']})
121
+ root.addEventListener('transitionend', schedule)
122
+ root.addEventListener('animationend', schedule)
123
+ schedule()
124
+ return {cancel, interrupt: cancel}
125
+ }
@@ -1,3 +1,4 @@
1
+ import { createSessionCardReveal } from './session-card-reveal.js'
1
2
  import { createSessionSummaryPreview, scrollSummaryTitle } from './session-summary-preview.js'
2
3
  import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
3
4
  import { createSessionLifelineAnimator } from './session-lifeline.js'
@@ -353,7 +354,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
353
354
  queueScan()
354
355
  }
355
356
  function interruptSelectionTarget(event) {
356
- if (event.target.closest?.('[data-prts-session-row]')) clearSelectionTarget()
357
+ if (event.target.closest?.('[data-prts-session-row]')) {
358
+ cancelPendingNavigation()
359
+ cancelReveal()
360
+ clearSelectionTarget()
361
+ }
357
362
  }
358
363
  function setSelectionTarget(id) {
359
364
  clearSelectionTarget()
@@ -362,27 +367,41 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
362
367
  document.addEventListener('pointerdown', interruptSelectionTarget, true)
363
368
  }
364
369
 
365
- let pendingReveal, revealTimer, revealFrame, revealSettleTimer
370
+ let pendingReveal, revealTimer
366
371
  let navigationFrame, navigationGeneration = 0
367
372
  function cancelPendingNavigation() {
368
373
  navigationGeneration++
369
374
  window.cancelAnimationFrame?.(navigationFrame)
370
375
  }
371
376
  function cancelReveal() {
377
+ pendingReveal?.tracker?.cancel()
372
378
  pendingReveal = undefined
373
379
  window.clearTimeout(revealTimer)
374
- window.clearTimeout(revealSettleTimer)
375
- window.cancelAnimationFrame?.(revealFrame)
380
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.removeEventListener(type, interruptReveal, true)
376
381
  }
377
- function revealSessionCard(sessionId) {
382
+ function interruptReveal(event) {
383
+ if (!event.target?.closest?.('[data-prts-region="sessions"]')) return
384
+ if (event.type === 'keydown' && !['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' '].includes(event.key)) return
385
+ cancelPendingNavigation()
378
386
  cancelReveal()
379
- pendingReveal = {sessionId, attempts: 0}
380
- revealTimer = window.setTimeout(cancelReveal, 4000)
387
+ clearSelectionTarget()
388
+ }
389
+ function revealSessionCard(sessionId, onDone) {
390
+ pendingReveal?.tracker?.interrupt()
391
+ cancelReveal()
392
+ pendingReveal = {sessionId, onDone}
393
+ revealTimer = window.setTimeout(finishReveal, 4000)
394
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.addEventListener(type, interruptReveal, {capture:true, passive:true})
381
395
  const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
382
396
  syncSessionReveal(root)
383
397
  queueScan()
384
398
  return Boolean(pendingReveal?.expanding)
385
399
  }
400
+ function finishReveal() {
401
+ const onDone = pendingReveal?.onDone
402
+ cancelReveal()
403
+ onDone?.()
404
+ }
386
405
  function syncSessionReveal(root) {
387
406
  if (!pendingReveal || !root) return
388
407
  const {sessionId} = pendingReveal
@@ -390,30 +409,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
390
409
  const row = rows.find(el => el.getAttribute('data-row-key') === `session:${sessionId}`
391
410
  || el.getAttribute('data-session-id') === sessionId)
392
411
  if (row) {
393
- if (pendingReveal.scrolling) return
394
- const request = pendingReveal
395
- request.scrolling = true
396
- // Let React finish mounting and let the host's row glide settle before scrolling.
397
- revealFrame = window.requestAnimationFrame(async () => {
398
- const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
399
- const animations = []
400
- if (!reduced) for (let node = row; node && node !== root; node = node.parentElement) {
401
- for (const animation of node.getAnimations?.() ?? []) {
402
- const timing = animation.effect?.getComputedTiming?.()
403
- if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animations.push(animation.finished)
404
- }
405
- }
406
- if (animations.length) await Promise.race([
407
- Promise.allSettled(animations),
408
- new Promise(resolve => { revealSettleTimer = window.setTimeout(resolve, 300) }),
409
- ])
410
- if (pendingReveal !== request || !row.isConnected) return
411
- row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:reduced ? 'instant' : 'smooth'})
412
- cancelReveal()
413
- })
412
+ if (!pendingReveal.tracker) {
413
+ const request = pendingReveal
414
+ request.tracker = createSessionCardReveal({window, root, sessionId, onStart: () => window.clearTimeout(revealTimer), onDone: () => {
415
+ if (pendingReveal === request) finishReveal()
416
+ }})
417
+ }
414
418
  return
415
419
  }
416
- if (++pendingReveal.attempts > 40) { cancelReveal(); return }
417
420
  const items = snapshotOf(workspaces?.list)?.items ?? []
418
421
  const owner = items.find(item => item.sessionIds?.includes(sessionId))
419
422
  const key = owner?.workspaceId ?? ''
@@ -439,7 +442,6 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
439
442
  closeNavigationPreview()
440
443
  cancelPendingNavigation()
441
444
  setSelectionTarget(sessionId)
442
- revealSessionCard(sessionId)
443
445
  const generation = navigationGeneration
444
446
  const navigate = () => {
445
447
  if (generation !== navigationGeneration || !adapterActive) return
@@ -462,11 +464,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
462
464
  if (row) row.click?.()
463
465
  else clearSelectionTarget()
464
466
  }
465
- // Paint selection and expansion before the host synchronously mounts another conversation.
466
- // Already-expanded workspaces need this too: queueScan has not applied selection yet.
467
- navigationFrame = window.requestAnimationFrame(() => {
468
- if (generation !== navigationGeneration) return
469
- navigationFrame = window.requestAnimationFrame(navigate)
467
+ // Finish sidebar motion first, then paint its final position before the host can block.
468
+ revealSessionCard(sessionId, () => {
469
+ if (generation !== navigationGeneration || !adapterActive) return
470
+ setSelectionTarget(sessionId)
471
+ navigationFrame = window.requestAnimationFrame(() => {
472
+ if (generation !== navigationGeneration) return
473
+ navigationFrame = window.requestAnimationFrame(navigate)
474
+ })
470
475
  })
471
476
  }
472
477
 
@@ -1,3 +1,4 @@
1
+ import { validBackgroundRegion } from '../shared/background-region.js'
1
2
  import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
2
3
 
3
4
  export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
@@ -22,6 +23,7 @@ export function validateThemePackage(value) {
22
23
  const a = value.assets
23
24
  if (!record(a) || !record(a.backgrounds) || !record(a.avatars) || !record(a.emblems)) throw Error('主题素材数据不完整')
24
25
  for (const slot of transferSlots) if (a.backgrounds[slot] !== null && !encodedImage(a.backgrounds[slot], 10 * 1024 * 1024)) throw Error('背景图片数据无效')
26
+ if (a.backgroundRegions !== undefined && (!record(a.backgroundRegions) || transferSlots.some(slot => !validBackgroundRegion(a.backgroundRegions[slot] ?? null)))) throw Error('背景保护区域无效')
25
27
  for (const role of transferRoles) {
26
28
  const avatar = a.avatars[role]
27
29
  if (avatar === null) continue
@@ -72,10 +74,11 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
72
74
  const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
73
75
  const states = {}
74
76
  if (includeAssets) {
75
- const backgrounds = {}, avatars = {}, items = [], ids = new Map()
77
+ const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
76
78
  states.backgrounds = await request('backgrounds/configuration')
77
79
  for (const slot of transferSlots) {
78
80
  const entry = states.backgrounds[slot]
81
+ backgroundRegions[slot] = entry?.region ?? null
79
82
  backgrounds[slot] = null
80
83
  if (entry) {
81
84
  if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
@@ -102,7 +105,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
102
105
  }
103
106
  const hero = await reference(states.emblems.configuration.hero), conversation = []
104
107
  for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
105
- value.assets = { backgrounds, avatars, emblems: { items, hero, conversation } }
108
+ value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
106
109
  for (const kind of ['backgrounds', 'avatars', 'emblems']) {
107
110
  if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
108
111
  }
@@ -146,7 +149,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
146
149
  for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
147
150
  const entry = (ref, index) => ref === null ? null : ref.kind === 'builtin' ? ref : { kind: 'custom', key: 'import-' + index + '-' + drafts[ref.asset].draftId, label: ref.label, draftId: drafts[ref.asset].draftId }
148
151
  const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
149
- for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot] }, { slot, image: old.backgrounds[slot] })
152
+ for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null })
150
153
  for (const role of transferRoles) await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop })
151
154
  await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
152
155
  }