@ash-qw/dsh-theme-prts 2.3.0 → 2.4.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.3.0",
3
+ "version": "2.4.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",
@@ -0,0 +1,65 @@
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, hostFrame, 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 nextFrame=document.querySelector('[data-prts-region="frame"]')
13
+ const nextOperation=document.querySelector('[data-prts-region="operation"]')
14
+ const nextSessions=document.querySelector('[data-prts-region="sessions"]')
15
+ if(hostFrame!==nextFrame || operation!==nextOperation || sessions!==nextSessions){
16
+ resize?.disconnect(); hostFrame=nextFrame;operation=nextOperation;sessions=nextSessions
17
+ for(const node of [hostFrame||operation,sessions])if(node)resize?.observe(node)
18
+ }
19
+ const railWidth=parseFloat(window.getComputedStyle(root).getPropertyValue('--prts-nav-width'))||0
20
+ for(const slot of ['full','conversation','sidebar']){
21
+ const entry=state[slot]
22
+ const node=slot==='sidebar'?sessions:slot==='conversation'?(hostFrame||operation):operation
23
+ const width=slot==='full'?window.innerWidth:(node?.clientWidth||0)+(slot==='sidebar'?railWidth:0)
24
+ const height=slot==='full'?window.innerHeight:node?.clientHeight||0
25
+ const active=entry?.region && width>0 && height>0
26
+ if(active){
27
+ const p=backgroundPlacement(entry.width,entry.height,width,height,entry.region)
28
+ set(`--prts-protected-${slot}-x`,`${p.x}px`)
29
+ set(`--prts-protected-${slot}-y`,`${p.y}px`)
30
+ if(slot==='sidebar')set('--prts-protected-sidebar-width',`${p.width}px`)
31
+ }else{
32
+ root.style.removeProperty(`--prts-protected-${slot}-x`)
33
+ root.style.removeProperty(`--prts-protected-${slot}-y`)
34
+ if(slot==='sidebar')root.style.removeProperty('--prts-protected-sidebar-width')
35
+ }
36
+ }
37
+ }
38
+ function schedule(){if(!disposed && observing && frame===undefined)frame=raf(update)}
39
+ // ResizeObserver runs after layout, before paint. Deferring again to rAF
40
+ // paints the new container with the previous image bounds for one frame.
41
+ const resize=window.ResizeObserver?new window.ResizeObserver(()=>{
42
+ if(frame!==undefined)caf(frame)
43
+ update()
44
+ }):null
45
+ const mutations=new window.MutationObserver(()=>{
46
+ if(!operation?.isConnected || !sessions?.isConnected)schedule()
47
+ })
48
+ const attributes=new window.MutationObserver(schedule)
49
+ function clear(){
50
+ caf(frame);frame=undefined;resize?.disconnect();mutations.disconnect();attributes.disconnect();window.removeEventListener('resize',schedule)
51
+ hostFrame=undefined;operation=undefined;sessions=undefined;observing=false
52
+ for(const slot of ['full','conversation','sidebar'])for(const axis of ['x','y','width'])root.style.removeProperty(`--prts-protected-${slot}-${axis}`)
53
+ }
54
+ function reconcile(){
55
+ if(disposed || !enabled || !['full','conversation','sidebar'].some(slot=>state[slot]?.region)){clear();return}
56
+ if(!observing){
57
+ observing=true
58
+ mutations.observe(document.body||root,{childList:true,subtree:true})
59
+ attributes.observe(root,{attributes:true,attributeFilter:['data-prts-background-mode','data-prts-rail-mode']})
60
+ window.addEventListener('resize',schedule)
61
+ }
62
+ schedule()
63
+ }
64
+ return {update(value){state=value;reconcile()},setEnabled(value){enabled=value;reconcile()},dispose(){disposed=true;clear()}}
65
+ }
@@ -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,16 +1,20 @@
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
- const backgroundLabels = { sidebar: '侧边栏', conversation: '会话正文', full: '完整背景' }
5
+ const backgroundLabels = { sidebar: '左侧边栏', conversation: '会话正文', full: '完整背景' }
4
6
  function backgroundImageUrl(entry) {
5
7
  return /^[a-f0-9-]{36}$/.test(entry?.id ?? '') ? `${BACKGROUND_ENDPOINT}/image/${entry.id}` : null
6
8
  }
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)) {
@@ -67,17 +77,20 @@ export function backgroundMarkup(heroContrastControl = '') {
67
77
  return `<section data-prts-background-settings>
68
78
  <header><strong>会话背景</strong><small>分区使用不同图片,或让一张完整背景贯穿侧边栏与正文。</small></header>
69
79
  <div data-prts-background-modes role="group" aria-label="背景模式">
70
- ${[['off','默认背景','使用主题原有底色'],['split','分区背景','侧栏与正文独立配置'],['full','完整背景','一张图片贯穿整个界面']].map(([value,label,hint]) => `<button type="button" data-prts-setting-key="backgroundMode" data-prts-setting-value="${value}" aria-pressed="false"><span data-prts-background-diagram="${value}" aria-hidden="true"><i></i><i></i></span><span><strong>${label}</strong><small>${hint}</small></span></button>`).join('')}
80
+ ${[['off','默认背景','使用主题原有底色'],['split','分区背景','左侧边栏与正文独立配置'],['full','完整背景','一张图片贯穿整个界面']].map(([value,label,hint]) => `<button type="button" data-prts-setting-key="backgroundMode" data-prts-setting-value="${value}" aria-pressed="false"><span data-prts-background-diagram="${value}" aria-hidden="true"><i></i><i></i></span><span><strong>${label}</strong><small>${hint}</small></span></button>`).join('')}
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
  }
@@ -78,7 +78,12 @@ export function createHostGeometryAdapter({ document, window }) {
78
78
  shouldMeasure = true
79
79
  }
80
80
  }
81
- if (shouldMeasure || !entries.length) scheduleMeasure()
81
+ // Keep shared wallpaper geometry in the same paint as the resized sidebar.
82
+ if (shouldMeasure || !entries.length) {
83
+ if (frameRequest !== undefined) window?.cancelAnimationFrame?.(frameRequest)
84
+ frameRequest = undefined
85
+ measure()
86
+ }
82
87
  }
83
88
 
84
89
  function stopObservation() {
@@ -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,
@@ -28,6 +28,13 @@ export function createNativePreferences({ storage, onChange }) {
28
28
  migrationAttempted = true
29
29
  pending = { ...legacy, ...pending }
30
30
  }
31
+ // Materialize the old shared sidebar values once, so later left-side edits
32
+ // cannot change a right-side preference that was never written by the Host.
33
+ if (s.value?.preferencesInitialized && writable(s)) {
34
+ for (const key of ['rightSidebarOpacity', 'rightSidebarBlur']) {
35
+ if (!Object.hasOwn(s.value, key) && !Object.hasOwn(pending, key) && !Object.hasOwn(inFlight, key) && !Object.hasOwn(failed, key)) pending[key] = host[key]
36
+ }
37
+ }
31
38
  current = normalizePreferences({ ...host, ...inFlight, ...pending })
32
39
  if (s.value?.preferencesInitialized) {
33
40
  legacy = null
@@ -1,7 +1,8 @@
1
1
  // Ring geometry adapted from React Bits CometDial (David Haz).
2
2
  // See src/vendor/react-bits/LICENSE.md and THIRD_PARTY_NOTICES.md.
3
3
  export const BACKGROUND_OPACITY_CONTROLS = Object.freeze([
4
- { key: 'sidebarOpacity', label: '会话列表侧栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
4
+ { key: 'sidebarOpacity', label: '左侧边栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
5
+ { key: 'rightSidebarOpacity', label: '右侧边栏', property: '--prts-right-sidebar-opacity', value: 94, blurKey: 'rightSidebarBlur', blurProperty: '--prts-right-sidebar-blur', blurDefault: 8 },
5
6
  { key: 'railOpacity', label: '设置侧栏', property: '--prts-rail-opacity', value: 70, blurKey: 'railBlur', blurProperty: '--prts-rail-blur', blurDefault: 12 },
6
7
  { key: 'bakerOpacity', label: 'Baker 气泡', property: '--prts-baker-opacity', value: 50, blurKey: 'bakerBlur', blurProperty: '--prts-baker-blur', blurDefault: 8, theme: 'deck-chat' },
7
8
  { key: 'classicUserOpacity', label: '经典用户消息', property: '--prts-classic-user-opacity', value: 50, blurKey: 'classicUserBlur', blurProperty: '--prts-classic-user-blur', blurDefault: 8, theme: 'native' },
@@ -16,7 +17,8 @@ export function opacityDialMarkup() {
16
17
  const controls = BACKGROUND_OPACITY_CONTROLS.map(({key,label,value,blurDefault}) => {
17
18
  const sample = key === 'bakerOpacity' ? '<span>这是一条回复</span><i></i>'
18
19
  : key === 'classicUserOpacity' ? '<span>这是我发送的消息</span><i></i>'
19
- : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>' : '<span>工作区</span><i></i><i></i>'
20
+ : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>'
21
+ : key === 'rightSidebarOpacity' ? '<span>侧边栏</span><i></i><i></i>' : '<span>工作区</span><i></i><i></i>'
20
22
  return `<div data-prts-opacity-control="${key}">
21
23
  <div data-prts-opacity-heading><strong>${label}</strong><button type="button" data-prts-opacity-reset data-prts-opacity-reset-tooltip="恢复到 ${value}%" aria-label="恢复${label}透明度到 ${value}%"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
22
24
  <div data-prts-opacity-dial><svg viewBox="0 0 200 200" role="slider" tabindex="0" aria-label="${label}透明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${value}" aria-valuetext="${value}%"><circle data-dial-focus cx="100" cy="100" r="97"/><path data-dial-track d="${dialArc(110,430)}"/><path data-dial-lit/><path data-dial-tail/><circle data-dial-head r="8"/></svg><label data-prts-opacity-number><input type="number" min="0" max="100" step="1" value="${value}" aria-label="${label}透明度百分比"><span>%</span></label></div>
@@ -24,8 +26,8 @@ export function opacityDialMarkup() {
24
26
  <div data-prts-blur-control hidden><label><span>模糊</span><input type="range" min="0" max="32" step="1" value="${blurDefault}" aria-label="${label}模糊度"></label><output>${blurDefault}px</output><button type="button" data-prts-blur-reset title="恢复到 ${blurDefault}px" aria-label="恢复${label}模糊度到 ${blurDefault}px"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
25
27
  <small data-prts-opacity-note></small>
26
28
  </div>`
27
- }).join('')
28
- return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls}</div></fieldset>`
29
+ })
30
+ return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls.join('')}</div></fieldset>`
29
31
  }
30
32
  export function mountOpacityDials({ section, window, onChange }) {
31
33
  const root = section.ownerDocument.documentElement
@@ -108,14 +110,14 @@ export function mountOpacityDials({ section, window, onChange }) {
108
110
  for(const [el,type,fn] of bindings)el.addEventListener(type,fn)
109
111
  paint(value)
110
112
  return {
111
- update(preferences, active) {
113
+ update(preferences, active, inactiveNote) {
112
114
  container.hidden = Boolean(definition.theme && preferences.conversationStyle !== definition.theme)
113
115
  saved = 100 - Math.round((preferences[definition.key] ?? (100 - definition.value) / 100) * 100)
114
116
  if (!grip && section.ownerDocument.activeElement !== input) paint(saved)
115
117
  savedBlur = preferences[definition.blurKey] ?? definition.blurDefault
116
118
  if (section.ownerDocument.activeElement !== blurInput) paintBlur(savedBlur)
117
119
  blurControl.hidden = !active
118
- container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : '当前未启用此材质'
120
+ container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : inactiveNote
119
121
  },
120
122
  dispose() { if(grip)finish({},true);window.clearTimeout(timer);for(const [el,type,fn] of bindings)el.removeEventListener(type,fn) },
121
123
  }
@@ -127,10 +129,13 @@ export function mountOpacityDials({ section, window, onChange }) {
127
129
  section.querySelector('[data-prts-opacity-settings]').hidden = preferences.backgroundMode === 'off'
128
130
  const conversationImage = full || (split && Boolean(state.conversation))
129
131
  const sidebarImage = full || (split && Boolean(state.sidebar))
130
- const active = [sidebarImage, sidebarImage,
132
+ const active = [sidebarImage || conversationImage, conversationImage, sidebarImage,
131
133
  conversationImage && preferences.conversationStyle === 'deck-chat',
132
134
  conversationImage && preferences.conversationStyle === 'native']
133
- controls.forEach((control,i)=>control.update(preferences,active[i]))
135
+ const missingImage = full || !split ? '上传完整背景图片后生效' : null
136
+ const inactiveNotes = [missingImage || '上传左侧边栏或正文图片后生效', missingImage || '上传正文图片后生效',
137
+ missingImage || '上传左侧边栏图片后生效', missingImage || '上传正文图片后生效', missingImage || '上传正文图片后生效']
138
+ controls.forEach((control,i)=>control.update(preferences,active[i],inactiveNotes[i]))
134
139
  },
135
140
  dispose() {for(const control of controls)control.dispose()},
136
141
  }
@@ -29,10 +29,12 @@ export const DEFAULT_PREFERENCES = Object.freeze({
29
29
  backgroundDim: .45,
30
30
  heroContrast: true,
31
31
  sidebarOpacity: .06,
32
+ rightSidebarOpacity: .06,
32
33
  railOpacity: .3,
33
34
  bakerOpacity: .5,
34
35
  classicUserOpacity: .5,
35
36
  sidebarBlur: 8,
37
+ rightSidebarBlur: 8,
36
38
  railBlur: 12,
37
39
  bakerBlur: 8,
38
40
  classicUserBlur: 8,
@@ -100,7 +102,7 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
100
102
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
101
103
  })
102
104
  export const PREFERENCE_GROUPS = Object.freeze({
103
- background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
105
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'rightSidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'rightSidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
104
106
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
105
107
  material: Object.freeze(['glass', 'composerGlass']),
106
108
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
@@ -214,10 +216,12 @@ export function normalizePreferences(value) {
214
216
  bakerLoading: input.bakerLoading === true,
215
217
  backgroundMode: ['off', 'split', 'full'].includes(input.backgroundMode) ? input.backgroundMode : 'off',
216
218
  sidebarOpacity: typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
219
+ rightSidebarOpacity: typeof input.rightSidebarOpacity === 'number' && Number.isFinite(input.rightSidebarOpacity) ? Math.min(1, Math.max(0, input.rightSidebarOpacity)) : typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
217
220
  railOpacity: typeof input.railOpacity === 'number' && Number.isFinite(input.railOpacity) ? Math.min(1, Math.max(0, input.railOpacity)) : .3,
218
221
  bakerOpacity: typeof input.bakerOpacity === 'number' && Number.isFinite(input.bakerOpacity) ? Math.min(1, Math.max(0, input.bakerOpacity)) : .5,
219
222
  classicUserOpacity: typeof input.classicUserOpacity === 'number' && Number.isFinite(input.classicUserOpacity) ? Math.min(1, Math.max(0, input.classicUserOpacity)) : .5,
220
223
  sidebarBlur: materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur),
224
+ rightSidebarBlur: materialBlurOr(input.rightSidebarBlur, materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur)),
221
225
  railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
222
226
  bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
223
227
  classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
@@ -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
+ }