@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/CHANGELOG.md +20 -0
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/lib/client.js +430 -51
- package/package.json +1 -1
- package/src/client/background-layout.js +65 -0
- package/src/client/background-region-editor.js +103 -0
- package/src/client/cursor-adapter.js +1 -1
- package/src/client/custom-background.js +23 -7
- package/src/client/host-geometry-adapter.js +6 -1
- package/src/client/index.js +2 -0
- package/src/client/native-preferences.js +7 -0
- package/src/client/opacity-dial.js +13 -8
- package/src/client/preferences.js +5 -1
- package/src/client/session-card-reveal.js +125 -0
- package/src/client/sidebar-control-adapter.js +40 -35
- package/src/client/theme-controller-v2.js +2 -0
- package/src/client/theme-transfer-panel.js +9 -1
- package/src/client/theme-transfer.js +33 -3
- package/src/server/backgrounds.js +10 -6
- package/src/shared/background-region.js +21 -0
- package/src/styles/cursor.css +8 -0
- package/src/styles/custom-background.css +95 -19
- package/src/styles/prts.css +2 -2
- package/src/styles/refinement.css +6 -0
- package/src/styles/theme-settings-workbench.css +1 -0
package/package.json
CHANGED
|
@@ -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: '
|
|
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','分区背景','
|
|
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
|
-
<
|
|
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
|
|
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-
|
|
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
|
-
|
|
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() {
|
package/src/client/index.js
CHANGED
|
@@ -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: '
|
|
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>'
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
+
}
|