@ash-qw/dsh-theme-prts 1.0.6 → 1.1.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.
@@ -0,0 +1,143 @@
1
+ // Hold anywhere on a card to sort; movement before the hold keeps native touch scrolling.
2
+ export function mountEmblemReorder({ list, window, status, canStart, onStart, onCommit }) {
3
+ let gesture = null, holdTimer, frame, previousTime = 0, suppressClick = false
4
+ const rows = () => [...list.children].filter(row => row.hasAttribute('data-emblem-key'))
5
+ const scroller = list.closest('[data-prts-theme-settings-scroll]')
6
+ const clear = () => {
7
+ window.clearTimeout(holdTimer); window.cancelAnimationFrame(frame)
8
+ holdTimer = frame = undefined; previousTime = 0
9
+ }
10
+ const finish = (commit = false) => {
11
+ const current = gesture
12
+ if (!current) return
13
+ gesture = null; clear()
14
+ current.ghost?.remove();current.row.removeAttribute('data-prts-emblem-dragging')
15
+ list.removeAttribute('data-prts-emblem-sorting')
16
+ current.handle.setAttribute('aria-pressed','false')
17
+ try { if (list.hasPointerCapture?.(current.pointerId)) list.releasePointerCapture(current.pointerId) } catch {}
18
+ const ordered = rows().map(row=>row.dataset.emblemKey)
19
+ const changed = ordered.some((key,index)=>key!==current.original[index].dataset.emblemKey)
20
+ if (!commit) for (const row of current.original) list.append(row)
21
+ if (current.active) {
22
+ status.textContent = commit ? '徽记顺序未改变' : '已取消排序,原顺序保持不变'
23
+ if (commit && changed) void onCommit(ordered,current.row.dataset.emblemKey)
24
+ else current.handle.focus({preventScroll:true})
25
+ }
26
+ }
27
+ const place = y => {
28
+ const current = gesture
29
+ if (!current?.active) return
30
+ const others = rows().filter(row=>row!==current.row)
31
+ const next = others.find(row=>{const box=row.getBoundingClientRect();return y < box.top + box.height/2})
32
+ if (next) {
33
+ if (current.row.nextElementSibling !== next) list.insertBefore(current.row,next)
34
+ } else if (others.at(-1)?.nextElementSibling !== current.row) list.insertBefore(current.row,current.ghost)
35
+ current.position = rows().indexOf(current.row)
36
+ }
37
+ const tick = time => {
38
+ if (!gesture?.active || gesture.keyboard) return
39
+ const current = gesture
40
+ if (!list.isConnected || !list.getClientRects().length) { finish();return }
41
+ const delta = previousTime ? Math.min(32,time-previousTime) : 16
42
+ previousTime=time
43
+ if (scroller) {
44
+ const box=scroller.getBoundingClientRect(), edge=48
45
+ const speed=current.y < box.top+edge ? -Math.min(1,(box.top+edge-current.y)/edge)
46
+ : current.y > box.bottom-edge ? Math.min(1,(current.y-box.bottom+edge)/edge) : 0
47
+ if (speed) scroller.scrollTop += speed * delta * .5
48
+ }
49
+ place(current.y)
50
+ current.ghost.style.transform=`translate3d(${current.x-current.startX}px,${current.y-current.startY}px,0)`
51
+ frame=window.requestAnimationFrame(tick)
52
+ }
53
+ const activate = () => {
54
+ const current=gesture
55
+ if (!current || !canStart() || !current.row.isConnected) {finish();return}
56
+ onStart?.()
57
+ current.active=true
58
+ suppressClick=true
59
+ current.handle.setAttribute('aria-pressed','true')
60
+ list.setAttribute('data-prts-emblem-sorting','')
61
+ if (!current.keyboard) {
62
+ const box=current.row.getBoundingClientRect()
63
+ const ghost=current.row.cloneNode(true)
64
+ ghost.removeAttribute('data-emblem-key');ghost.setAttribute('data-prts-emblem-drag-ghost','')
65
+ ghost.setAttribute('aria-hidden','true');ghost.inert=true
66
+ const originals=current.row.querySelectorAll('canvas')
67
+ ghost.querySelectorAll('canvas').forEach((canvas,index)=>canvas.getContext('2d')?.drawImage(originals[index],0,0))
68
+ Object.assign(ghost.style,{position:'fixed',left:`${box.left}px`,top:`${box.top}px`,width:`${box.width}px`,boxSizing:'border-box',margin:'0',pointerEvents:'none',zIndex:'10'})
69
+ current.ghost=ghost;list.append(ghost)
70
+ // Native dialogs can establish a fixed-position containing block. Keep the lifted row under the pointer.
71
+ const positioned=ghost.getBoundingClientRect()
72
+ ghost.style.left=`${2*box.left-positioned.left}px`;ghost.style.top=`${2*box.top-positioned.top}px`
73
+ try {list.setPointerCapture(current.pointerId)}catch{finish();return}
74
+ frame=window.requestAnimationFrame(tick)
75
+ }
76
+ current.row.setAttribute('data-prts-emblem-dragging','')
77
+ status.textContent='正在调整徽记顺序,松开保存;按 Esc 取消'
78
+ }
79
+ const down = event => {
80
+ if (gesture) { if (event.pointerId!==gesture.pointerId) finish();return }
81
+ const row=event.target.closest('[data-emblem-key]')
82
+ const handle=row?.querySelector('[data-prts-emblem-reorder]')
83
+ suppressClick=false
84
+ if (!handle || handle.disabled || event.button!==0 || event.isPrimary===false || !canStart() || rows().length<2) return
85
+ gesture={handle,row,pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,x:event.clientX,y:event.clientY,original:rows(),active:false}
86
+ holdTimer=window.setTimeout(activate,400)
87
+ }
88
+ const move = event => {
89
+ if (!gesture || gesture.pointerId!==event.pointerId) return
90
+ gesture.x=event.clientX;gesture.y=event.clientY
91
+ if (!gesture.active && Math.hypot(gesture.x-gesture.startX,gesture.y-gesture.startY)>8) {finish();return}
92
+ if (gesture.active) event.preventDefault()
93
+ }
94
+ const up = event => {
95
+ if (gesture?.pointerId!==event.pointerId) return
96
+ const box=list.getBoundingClientRect(), viewport=(scroller || list).getBoundingClientRect()
97
+ finish(event.clientX>=box.left && event.clientX<=box.right && event.clientY>=viewport.top && event.clientY<=viewport.bottom)
98
+ }
99
+ const cancelPointer = event => {if (gesture?.pointerId===event.pointerId) finish()}
100
+ const cancel = () => finish()
101
+ const visibility = () => {if (window.document.hidden) finish()}
102
+ const keydown = event => {
103
+ if (event.key==='Escape' && gesture) {event.preventDefault();event.stopPropagation();finish();return}
104
+ const handle=event.target.closest?.('[data-prts-emblem-reorder]')
105
+ if (!handle || handle.disabled || !canStart()) return
106
+ if ([' ','Enter'].includes(event.key)) {
107
+ event.preventDefault()
108
+ if (gesture?.keyboard) {finish(true);return}
109
+ if (gesture) return
110
+ gesture={handle,row:handle.closest('[data-emblem-key]'),original:rows(),keyboard:true,active:false}
111
+ activate();status.textContent='用上下方向键调整顺序,空格或回车保存;Esc 取消'
112
+ } else if (gesture?.keyboard && ['ArrowUp','ArrowDown','Home','End'].includes(event.key)) {
113
+ event.preventDefault()
114
+ const current=rows(),index=current.indexOf(gesture.row)
115
+ const target=event.key==='Home'?0:event.key==='End'?current.length-1:Math.max(0,Math.min(current.length-1,index+(event.key==='ArrowDown'?1:-1)))
116
+ if (target!==index)list.insertBefore(gesture.row,target<index?current[target]:current[target].nextSibling)
117
+ gesture.handle.focus({preventScroll:true});gesture.row.scrollIntoView({block:'nearest'})
118
+ status.textContent=`当前位置:第 ${target+1} 项,共 ${current.length} 项;空格保存`
119
+ }
120
+ }
121
+ const touchmove = event => {if(gesture?.active && !gesture.keyboard)event.preventDefault()}
122
+ const context = event => {if(event.target.closest('[data-emblem-key]')||gesture?.active)event.preventDefault()}
123
+ const click = event => {if(suppressClick || gesture?.active || event.target.closest('[data-prts-emblem-reorder]')){event.preventDefault();event.stopPropagation()}}
124
+ list.addEventListener('touchmove',touchmove,{passive:false})
125
+ list.addEventListener('pointerdown',down)
126
+ list.addEventListener('contextmenu',context)
127
+ list.addEventListener('click',click,true)
128
+ window.addEventListener('pointermove',move,{passive:false})
129
+ window.addEventListener('pointerup',up)
130
+ window.addEventListener('pointercancel',cancelPointer)
131
+ list.addEventListener('lostpointercapture',cancelPointer)
132
+ window.addEventListener('keydown',keydown,true)
133
+ window.addEventListener('blur',cancel)
134
+ window.addEventListener('resize',cancel)
135
+ window.document.addEventListener('visibilitychange',visibility)
136
+ return { cancel, get active(){return !!gesture?.active}, dispose() {
137
+ finish()
138
+ list.removeEventListener('touchmove',touchmove);list.removeEventListener('pointerdown',down);list.removeEventListener('contextmenu',context);list.removeEventListener('click',click,true)
139
+ window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancelPointer)
140
+ list.removeEventListener('lostpointercapture',cancelPointer);window.removeEventListener('keydown',keydown,true);window.removeEventListener('blur',cancel);window.removeEventListener('resize',cancel)
141
+ window.document.removeEventListener('visibilitychange',visibility)
142
+ } }
143
+ }
@@ -0,0 +1,17 @@
1
+ // The native tool seat keeps previews available even for sessions with a remote filesystem.
2
+ export function registerEmblemToolPreview(ctx, React) {
3
+ if (!ctx.slots?.inject || !React?.createElement) return () => {}
4
+ return ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
5
+ name:'tool.call.toolview',key:'prts_emblem_preview',
6
+ }, ({block}) => {
7
+ const h=React.createElement
8
+ let result
9
+ for(const part of block?.content ?? [])if(part.type==='text'){
10
+ try {const value=JSON.parse(part.text);if(value.draftId)result=value}catch{}
11
+ }
12
+ if(!result || !/^[a-zA-Z0-9-]{1,80}$/.test(result.draftId))return h('div',null,block?.isError?'徽记预览失败':'正在生成徽记预览…')
13
+ return h('figure',{'data-prts-tool-preview':'',style:{margin:'8px 0'}},
14
+ h('img',{src:`/prts-theme-api/emblems/draft/${result.draftId}/preview.png`,alt:'粒子徽记预览',width:384,height:384,style:{maxWidth:'100%',height:'auto',display:'block',borderRadius:8}}),
15
+ h('figcaption',null,`${result.label || '自定义徽记'} · 标准密度 ${result.particles?.standard ?? ''} 个粒子 · 未应用`))
16
+ }))
17
+ }
@@ -1,3 +1,5 @@
1
+ import { registerEmblemToolPreview } from './emblem-tool-preview.js'
2
+ import { createCustomEmblemStore } from './custom-emblem.js'
1
3
  import { DEFAULT_PREFERENCES, isSafeMode, loadPreferences, persistPreferences, resetPreferenceGroup, updatePreferenceValue } from './preferences.js'
2
4
  import { createHostGeometryAdapter } from './host-geometry-adapter.js'
3
5
  import { createComposerGlassAdapter } from './composer-glass-adapter.js'
@@ -42,6 +44,7 @@ export function applyPrtsPlugin(ctx, environment) {
42
44
  startupTimings,
43
45
  } = environment
44
46
  const safeMode = isSafeMode(window?.location?.search ?? '')
47
+ const stopEmblemPreview = safeMode ? null : registerEmblemToolPreview(ctx, React)
45
48
  const storage = window?.localStorage
46
49
  const themeService = contextService(ctx, 'theme')
47
50
  const composerGlass = createComposerGlassAdapter({ document, window })
@@ -61,6 +64,7 @@ export function applyPrtsPlugin(ctx, environment) {
61
64
  sync()
62
65
  },
63
66
  })
67
+ const customEmblemStore = createCustomEmblemStore({ window, emblems: assets.emblems, preview: entry => particleField.previewEmblem?.(entry), onChange: value => particleField.setCustomEmblemConfiguration?.(value) })
64
68
  const startup = createPrtsStartupSequence({
65
69
  document,
66
70
  window,
@@ -187,7 +191,7 @@ export function applyPrtsPlugin(ctx, environment) {
187
191
  }
188
192
  if (!themeAlreadyApplied) theme.apply(preferences)
189
193
  operations ||= createOperationsShell({
190
- document, window, assets, adapter, hostGeometry,
194
+ document, window, assets, adapter, hostGeometry, customEmblemStore,
191
195
  onSchemeToggle(next, interaction) {
192
196
  return theme.setTheme(next, { ...interaction, animate: true })
193
197
  },
@@ -339,7 +343,9 @@ export function applyPrtsPlugin(ctx, environment) {
339
343
  applyVisualState()
340
344
  }
341
345
  ctx.effect(() => () => {
346
+ if (typeof stopEmblemPreview === 'function') stopEmblemPreview()
342
347
  statusSource.dispose()
348
+ customEmblemStore.dispose()
343
349
  particleField.dispose()
344
350
  startup.dispose({ restoreFocus: false })
345
351
  stopStartupReadyWatch()
@@ -1,5 +1,4 @@
1
1
  import { createThemeSettingsOverlay, themeSettingsMarkup } from './theme-settings-workbench.js'
2
- import { createResizeShieldAdapter } from './resize-shield-adapter.js'
3
2
 
4
3
  const PHONE_RAIL_BREAKPOINT = 640
5
4
  const RAIL_HOLD_DELAY_MS = 350
@@ -46,13 +45,13 @@ export function createOperationsShell({
46
45
  onSchemeToggle = () => {},
47
46
  onSchemeDragStart = () => undefined,
48
47
  onThemeDisable = () => {},
48
+ customEmblemStore,
49
49
  onPreferenceChange = () => {},
50
50
  onPreferencePreview = () => {},
51
51
  onVisualReset = () => {},
52
52
  onRetrySave = () => {},
53
53
  }) {
54
54
  const root = document.documentElement
55
- const resizeShield = createResizeShieldAdapter({ window })
56
55
  let shell
57
56
  let navRail
58
57
  let railLauncher
@@ -456,16 +455,12 @@ export function createOperationsShell({
456
455
  trigger: shell.querySelector('[data-prts-rail-brand]'),
457
456
  panel: shell.querySelector('[data-prts-theme-settings]'),
458
457
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
458
+ customEmblemStore,
459
459
  onPreferenceChange,
460
460
  onPreferencePreview,
461
461
  onResetVisual: onVisualReset,
462
462
  onRetrySave,
463
463
  })
464
- resizeShield.start([
465
- operationRegion,
466
- shell.querySelector('[data-prts-theme-settings]'),
467
- shell.querySelector('[data-prts-settings-backdrop]'),
468
- ])
469
464
  conversationObserver = new window.MutationObserver(syncConversationState)
470
465
  conversationObserver.observe(operationRegion, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-chat-flow-kind', 'data-message-role'] })
471
466
  schemeObserver = new window.MutationObserver(syncSchemeToggle)
@@ -518,7 +513,6 @@ export function createOperationsShell({
518
513
  railViewportQuery = undefined
519
514
  railPointerQuery = undefined
520
515
  railHoverQuery = undefined
521
- resizeShield.dispose()
522
516
  settingsOverlay?.dispose()
523
517
  conversationObserver?.disconnect()
524
518
  schemeObserver?.disconnect()
@@ -365,6 +365,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
365
365
  },
366
366
  opticalScale: suppliedHeroMask.opticalScale,
367
367
  } : maskRecords[rhodesIndex]
368
+ let customEmblemConfiguration = null
368
369
  const targetCache = new Map()
369
370
  let particles = []
370
371
  let pointerActive = false
@@ -448,35 +449,44 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
448
449
  }
449
450
 
450
451
  function sequenceIndexForSegment(segmentValue) {
451
- const length = Math.max(1, emblemSequence.length)
452
+ const length = Math.max(1, (customEmblemConfiguration?.conversation ?? emblemSequence).length)
452
453
  return ((segmentValue % length) + length) % length
453
454
  }
454
455
 
455
456
  function emblemForSegment(segmentValue) {
456
- return emblemSequence[sequenceIndexForSegment(segmentValue)] ?? emblemSequence[0]
457
+ const sequence = customEmblemConfiguration?.conversation ?? emblemSequence
458
+ return sequence[sequenceIndexForSegment(segmentValue)] ?? sequence[0]
457
459
  }
458
460
 
459
- function targetsForIndex(index, density = 'standard') {
460
- const key = `${index}:${density}`
461
+ function targetsForBuiltin(entry, density = 'standard') {
462
+ const index = emblemSequence.findIndex(item => item.key === entry?.key)
463
+ const key = `builtin:${index}:${density}`
461
464
  const cached = targetCache.get(key)
462
465
  if (cached) return cached
463
466
  const record = maskRecords[index]
464
467
  const targets = record ? createOrthogonalTargets(record.mask, density) : []
465
- if (!targets.length) return []
466
- targetCache.set(key, targets)
468
+ if (targets.length) targetCache.set(key, targets)
467
469
  return targets
468
470
  }
469
471
 
472
+ function targetsForIndex(index, density = 'standard') {
473
+ const entry = (customEmblemConfiguration?.conversation ?? emblemSequence)[index]
474
+ if (entry?.kind === 'custom') return entry.targets[density] ?? entry.targets.standard
475
+ return targetsForBuiltin(entry, density)
476
+ }
477
+
470
478
  function targetsForSegment(segmentValue, density = preferences.conversationParticleDensity) {
471
479
  return targetsForIndex(sequenceIndexForSegment(segmentValue), density)
472
480
  }
473
481
 
474
482
  function heroEmblem() {
483
+ if (customEmblemConfiguration?.hero) return customEmblemConfiguration.hero
475
484
  return emblemSequence[rhodesIndex] ?? emblemSequence[0]
476
485
  }
477
486
 
478
487
  function targetsForHero(density = preferences.heroParticleDensity) {
479
488
  const effectiveDensity = effectiveHeroDensity(density)
489
+ if (customEmblemConfiguration?.hero) return customEmblemConfiguration.hero.targets[effectiveDensity] ?? customEmblemConfiguration.hero.targets.standard
480
490
  const key = `hero:${effectiveDensity}`
481
491
  const cached = targetCache.get(key)
482
492
  if (cached) return cached
@@ -688,7 +698,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
688
698
  function scheduleTargetPrewarm() {
689
699
  cancelTargetPrewarm()
690
700
  if (typeof window.requestIdleCallback !== 'function' || !particles.length) return
691
- targetPrewarmQueue = emblemSequence.map((_, index) => index)
701
+ targetPrewarmQueue = (customEmblemConfiguration?.conversation ?? emblemSequence).map((_, index) => index)
692
702
  .filter(index => index !== sequenceIndexForSegment(segment))
693
703
  const warmNext = () => {
694
704
  targetPrewarmHandle = undefined
@@ -1836,6 +1846,21 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1836
1846
  schemeTransitionPaused = next
1837
1847
  updateFrameGate()
1838
1848
  },
1849
+ previewEmblem(entry) {
1850
+ if (entry?.kind === 'custom') return entry
1851
+ const points = entry ? targetsForBuiltin(entry, 'standard') : heroMaskRecord ? createOrthogonalTargets(heroMaskRecord.mask, 'standard') : []
1852
+ return { targets: { standard: points } }
1853
+ },
1854
+ setCustomEmblemConfiguration(value) {
1855
+ const previousSequence = customEmblemConfiguration?.conversation ?? emblemSequence
1856
+ const nextSequence = value?.conversation ?? emblemSequence
1857
+ const changed = heroActive
1858
+ ? (customEmblemConfiguration?.hero ?? null) !== (value?.hero ?? null)
1859
+ : previousSequence.length !== nextSequence.length || previousSequence.some((entry, index) =>
1860
+ entry.key !== nextSequence[index]?.key || entry.targets !== nextSequence[index]?.targets)
1861
+ customEmblemConfiguration = value
1862
+ if (canvas && changed) { resetTraversal(); scheduleFrame() }
1863
+ },
1839
1864
  update(nextPreferences = {}) {
1840
1865
  const previousPreferences = preferences
1841
1866
  const traversalSpeedChanged = previousPreferences.particleTraversalSpeed !== undefined
@@ -1,8 +1,10 @@
1
+ import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
1
2
  import { PARTICLE_TRAVERSAL_SPEED_DEFAULT, PARTICLE_TRAVERSAL_SPEED_MAX, PARTICLE_TRAVERSAL_SPEED_MIN, PARTICLE_TRAVERSAL_SPEED_STEP, SESSION_FLOW_SPEED_DEFAULT, SESSION_FLOW_SPEED_MAX, SESSION_FLOW_SPEED_MIN, SESSION_FLOW_SPEED_STEP, resolveParticleDetail } from './preferences.js'
2
3
 
3
4
  const SETTINGS_FOCUSABLE = [
4
5
  'button:not([disabled])',
5
6
  'input:not([disabled])',
7
+ 'select:not([disabled])',
6
8
  'summary',
7
9
  '[href]',
8
10
  '[tabindex]:not([tabindex="-1"])',
@@ -119,14 +121,31 @@ export function themeSettingsMarkup() {
119
121
  </header>
120
122
 
121
123
  <div data-prts-settings-body>
122
- <nav data-prts-settings-nav aria-label="设置分区">
123
- <button type="button" data-prts-settings-jump="appearance">外观</button>
124
- <button type="button" data-prts-settings-jump="interaction">交互</button>
125
- <button type="button" data-prts-settings-jump="flow">会话流线</button>
126
- <button type="button" data-prts-settings-jump="particles">粒子效果</button>
124
+ <div data-prts-settings-sidebar>
125
+ <nav data-prts-settings-nav role="tablist" aria-label="设置分区">
126
+ <button type="button" data-prts-settings-jump="appearance" role="tab" id="prts-tab-appearance" aria-controls="prts-page-appearance" aria-selected="true" tabindex="0">外观</button>
127
+ <button type="button" data-prts-settings-jump="interaction" role="tab" id="prts-tab-interaction" aria-controls="prts-page-interaction" aria-selected="false" tabindex="-1">交互</button>
128
+ <button type="button" data-prts-settings-jump="flow" role="tab" id="prts-tab-flow" aria-controls="prts-page-flow" aria-selected="false" tabindex="-1">会话流线</button>
129
+ <button type="button" data-prts-settings-jump="particles" role="tab" id="prts-tab-particles" aria-controls="prts-page-particles" aria-selected="false" tabindex="-1">粒子效果</button>
127
130
  </nav>
131
+ <footer data-prts-settings-actions aria-label="全局主题设置操作">
132
+ <div data-prts-persistence-error hidden role="alert">
133
+ <span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
134
+ <button type="button" data-prts-retry-save>重试保存</button>
135
+ </div>
136
+ <div data-prts-reset-zone>
137
+
138
+ <button type="button" data-prts-reset-visual title="恢复全部外观设置" aria-label="恢复全部外观设置">恢复默认</button>
139
+ <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部外观设置">
140
+ <span>重置四个分类的外观参数,保留主题开关、启动动画和徽记配置。</span>
141
+ <button type="button" data-prts-reset-confirm-action>确认恢复</button>
142
+ <button type="button" data-prts-reset-cancel>取消</button>
143
+ </div>
144
+ </div>
145
+ </footer>
146
+ </div>
128
147
  <div data-prts-theme-settings-scroll>
129
- <section data-prts-settings-section="appearance" tabindex="-1">
148
+ <section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
130
149
  <header data-prts-section-heading><strong>外观</strong><small>预设、界面材质与会话样式</small></header>
131
150
  <section data-prts-settings-presets>
132
151
  <header><span><strong>视觉预设</strong></span></header>
@@ -141,13 +160,13 @@ export function themeSettingsMarkup() {
141
160
  </div>
142
161
  </section>
143
162
  </section>
144
- <section data-prts-settings-common data-prts-settings-section="interaction" tabindex="-1">
163
+ <section data-prts-settings-common data-prts-settings-section="interaction" role="tabpanel" id="prts-page-interaction" aria-labelledby="prts-tab-interaction" tabindex="0" hidden>
145
164
  <header data-prts-section-heading><strong>交互</strong><small>光标、导航栏与动态反馈</small></header>
146
165
  <div data-prts-settings-common-grid>
147
166
  ${COMMON_CONTROLS.filter(([key]) => ['cursor', 'railDefaultHidden', 'motion'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
148
167
  </div>
149
168
  </section>
150
- <section data-prts-settings-common data-prts-settings-section="flow" tabindex="-1">
169
+ <section data-prts-settings-common data-prts-settings-section="flow" role="tabpanel" id="prts-page-flow" aria-labelledby="prts-tab-flow" tabindex="0" hidden>
151
170
  <header data-prts-section-heading><strong>会话流线</strong><small>当前会话的流线与辉光</small></header>
152
171
  <div data-prts-settings-common-grid>
153
172
  ${COMMON_CONTROLS.filter(([key]) => ['sessionFlow', 'sessionGlow', 'sessionFlowPalette'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
@@ -160,9 +179,10 @@ export function themeSettingsMarkup() {
160
179
  </div>
161
180
  </section>
162
181
 
163
- <details data-prts-settings-advanced data-prts-settings-section="particles" tabindex="-1">
164
- <summary data-prts-section-heading><span><strong>粒子效果</strong><small>徽记精度与跟随移动速度</small></span><output data-prts-advanced-summary>标准 / 1×</output></summary>
182
+ <section data-prts-settings-advanced data-prts-settings-section="particles" role="tabpanel" id="prts-page-particles" aria-labelledby="prts-tab-particles" tabindex="0" hidden>
183
+ <header data-prts-section-heading><span><strong>粒子效果</strong><small>徽记精度与跟随移动速度</small></span><output data-prts-advanced-summary>标准 / 1×</output></header>
165
184
  <div data-prts-settings-advanced-content>
185
+ ${customEmblemMarkup()}
166
186
  <section data-prts-particle-detail>
167
187
  <header><strong>粒子精度</strong><span>同时调整会话与新会话徽记</span></header>
168
188
  <div role="radiogroup" aria-label="粒子精度">${particleMarkup()}</div>
@@ -173,25 +193,11 @@ export function themeSettingsMarkup() {
173
193
  <span data-prts-particle-error role="alert" hidden>粒子资源加载异常,主题已使用静态后备效果</span>
174
194
  </section>
175
195
  </div>
176
- </details>
196
+ </section>
177
197
 
178
- <footer data-prts-settings-actions>
179
- <div data-prts-persistence-error hidden role="alert">
180
- <span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
181
- <button type="button" data-prts-retry-save>重试保存</button>
182
- </div>
183
- <div data-prts-reset-zone>
184
- <span><strong>恢复主题默认</strong><small>保留主题开关和启动动画设置</small></span>
185
- <button type="button" data-prts-reset-visual>恢复默认</button>
186
- <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复主题默认">
187
- <span>确认恢复全部主题参数?</span>
188
- <button type="button" data-prts-reset-confirm-action>确认恢复</button>
189
- <button type="button" data-prts-reset-cancel>取消</button>
190
- </div>
191
- </div>
192
- </footer>
193
198
  </div>
194
199
  </div>
200
+
195
201
  </div>
196
202
  </dialog>`
197
203
  }
@@ -201,6 +207,7 @@ export function createThemeSettingsOverlay({
201
207
  trigger,
202
208
  panel,
203
209
  backdrop,
210
+ customEmblemStore,
204
211
  onOpen = () => {},
205
212
  onPreferenceChange = () => {},
206
213
  onPreferencePreview = () => {},
@@ -208,6 +215,7 @@ export function createThemeSettingsOverlay({
208
215
  onRetrySave = () => {},
209
216
  }) {
210
217
  const window = document?.defaultView
218
+ const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
211
219
  const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
212
220
  const motionMedia = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
213
221
  const nativeModal = typeof panel?.showModal === 'function' && typeof panel?.close === 'function'
@@ -519,19 +527,25 @@ export function createThemeSettingsOverlay({
519
527
  }, 300)
520
528
  }
521
529
 
522
- function syncSectionNavigation() {
530
+ let activeSection = 'appearance'
531
+ const sectionScroll = new Map()
532
+ function selectSection(key, focus = false) {
523
533
  const scroller = panel?.querySelector('[data-prts-theme-settings-scroll]')
524
- if (!scroller || !scroller.clientHeight) return
525
- const sections = [...panel.querySelectorAll('[data-prts-settings-section]')]
526
- const top = scroller.getBoundingClientRect().top + 32
527
- let current = sections[0]
528
- for (const section of sections) {
529
- if (section.getBoundingClientRect().top <= top) current = section
534
+ const section = panel?.querySelector(`[data-prts-settings-section="${key}"]`)
535
+ if (!scroller || !section) return
536
+ if (key !== activeSection) {
537
+ sectionScroll.set(activeSection, scroller.scrollTop)
538
+ customEditor.cancelReorder?.()
539
+ for (const page of panel.querySelectorAll('[data-prts-settings-section]')) page.hidden = page !== section
540
+ activeSection = key
541
+ scroller.scrollTop = sectionScroll.get(key) || 0
542
+ setResetConfirmation(false)
530
543
  }
531
- if (scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) current = sections.at(-1)
532
- for (const button of panel.querySelectorAll('[data-prts-settings-jump]')) {
533
- if (button.dataset.prtsSettingsJump === current?.dataset.prtsSettingsSection) button.setAttribute('aria-current', 'location')
534
- else button.removeAttribute('aria-current')
544
+ for (const tab of panel.querySelectorAll('[data-prts-settings-jump]')) {
545
+ const selected = tab.dataset.prtsSettingsJump === key
546
+ tab.setAttribute('aria-selected', String(selected))
547
+ tab.tabIndex = selected ? 0 : -1
548
+ if (selected && focus) tab.focus({preventScroll:true})
535
549
  }
536
550
  }
537
551
 
@@ -540,14 +554,7 @@ export function createThemeSettingsOverlay({
540
554
  const target = event.target?.closest?.('button')
541
555
  if (!target) return
542
556
  if (target.hasAttribute('data-prts-settings-jump')) {
543
- const section = panel.querySelector(`[data-prts-settings-section="${target.dataset.prtsSettingsJump}"]`)
544
- const scroller = panel.querySelector('[data-prts-theme-settings-scroll]')
545
- if (section && scroller) {
546
- if (section.tagName === 'DETAILS') section.open = true
547
- scroller.scrollTo?.({ top: target.dataset.prtsSettingsJump === 'appearance' ? 0 : scroller.scrollTop + section.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 16, behavior: 'instant' })
548
- section.focus?.({ preventScroll: true })
549
- syncSectionNavigation()
550
- }
557
+ selectSection(target.dataset.prtsSettingsJump)
551
558
  return
552
559
  }
553
560
  if (target.hasAttribute('data-prts-theme-settings-close')) { setOpen(false); return }
@@ -615,6 +622,16 @@ export function createThemeSettingsOverlay({
615
622
 
616
623
  function onKeydown(event) {
617
624
  if (!isOpen) return
625
+ const tab = event.target.closest?.('[data-prts-settings-jump]')
626
+ if (tab && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End'].includes(event.key)) {
627
+ event.preventDefault()
628
+ const tabs = [...panel.querySelectorAll('[data-prts-settings-jump]')]
629
+ const index = tabs.indexOf(tab)
630
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length-1
631
+ : (index + (['ArrowRight','ArrowDown'].includes(event.key) ? 1 : -1) + tabs.length) % tabs.length
632
+ selectSection(tabs[next].dataset.prtsSettingsJump, true)
633
+ return
634
+ }
618
635
  if (event.key === 'Escape') { event.preventDefault(); setOpen(false); return }
619
636
  if (nativeModal || event.key !== 'Tab') return
620
637
  const nodes = focusable()
@@ -642,8 +659,7 @@ export function createThemeSettingsOverlay({
642
659
 
643
660
  trigger?.addEventListener('click', toggle)
644
661
  panel?.addEventListener('click', onPanelClick)
645
- panel?.querySelector('[data-prts-theme-settings-scroll]')?.addEventListener('scroll', syncSectionNavigation, { passive: true })
646
- panel?.querySelector('[data-prts-settings-jump]')?.setAttribute('aria-current', 'location')
662
+ selectSection('appearance')
647
663
  panel?.addEventListener('cancel', onCancel)
648
664
  panel?.addEventListener('input', onPanelInput)
649
665
  panel?.addEventListener('change', commitFromControl)
@@ -670,6 +686,7 @@ export function createThemeSettingsOverlay({
670
686
  close: () => setOpen(false),
671
687
  toggle,
672
688
  dispose() {
689
+ customEditor.dispose()
673
690
  isOpen = false
674
691
  hidePanel()
675
692
  if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
@@ -678,7 +695,6 @@ export function createThemeSettingsOverlay({
678
695
  trigger?.setAttribute('aria-expanded', 'false')
679
696
  trigger?.removeEventListener('click', toggle)
680
697
  panel?.removeEventListener('click', onPanelClick)
681
- panel?.querySelector('[data-prts-theme-settings-scroll]')?.removeEventListener('scroll', syncSectionNavigation)
682
698
  panel?.removeEventListener('cancel', onCancel)
683
699
  panel?.removeEventListener('input', onPanelInput)
684
700
  panel?.removeEventListener('change', commitFromControl)
package/src/index.js CHANGED
@@ -1,4 +1,16 @@
1
+ import { join } from 'node:path'
2
+ import { homedir } from 'node:os'
3
+ import { createEmblemService } from './server/emblems.js'
4
+ import { emblemTools } from './server/tools.js'
5
+ import { registerEmblemApi } from './server/api.js'
1
6
  export const PACKAGE_ID = '@ash-qw/dsh-theme-prts'
2
7
  export const THEME_PLUGIN_ID = 'dsh-theme-prts'
8
+ export const inject = ['tools', 'attachments', 'connection', 'webServer']
3
9
 
4
- export function apply() {}
10
+ export async function apply(ctx) {
11
+ const directory=join(process.env.DSH_HOME?.trim() || join(homedir(),'.dsh'),'plugins','dsh-theme-prts','emblems')
12
+ const service=await createEmblemService(directory)
13
+ const disposers=emblemTools(service,ctx.attachments).map(tool=>ctx.tools.register(tool))
14
+ disposers.push(registerEmblemApi(ctx,service))
15
+ ctx.on('dispose',()=>{for(const dispose of disposers.reverse())dispose()})
16
+ }
@@ -0,0 +1,54 @@
1
+ import { API, draftView, fault } from './emblems.js'
2
+
3
+ async function body(req) {
4
+ const chunks=[];let size=0
5
+ for await(const chunk of req){size+=chunk.length;if(size>16*1024*1024)throw fault(413,'请求过大');chunks.push(chunk)}
6
+ try{return JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw fault(400,'请求数据无效')}
7
+ }
8
+ function json(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
9
+ export function registerEmblemApi(ctx,service) {
10
+ const streams=new Set()
11
+ const dispose=ctx.webServer.register({kind:'prefix',path:API,handler:async(req,res)=>{
12
+ try {
13
+ const admission=ctx.connection.admit(req)
14
+ if('rejection' in admission){json(res,401,{error:'请先登录 DSH'});return}
15
+ const path=new URL(req.url,'http://localhost').pathname.slice(API.length)
16
+ if(req.method==='GET'&&path==='/events') {
17
+ res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-cache','x-accel-buffering':'no'})
18
+ streams.add(res)
19
+ const send=revision=>res.write(`data: ${JSON.stringify({revision})}\n\n`)
20
+ const unsubscribe=service.subscribe(send)
21
+ send(service.snapshot().revision)
22
+ const heartbeat=setInterval(()=>res.write(': heartbeat\n\n'),25000)
23
+ res.on('close',()=>{clearInterval(heartbeat);unsubscribe();streams.delete(res)})
24
+ return
25
+ }
26
+ if(req.method==='GET'&&path==='/configuration'){json(res,200,service.snapshot());return}
27
+ const exported=path.match(/^\/draft\/([a-zA-Z0-9-]+)\/export$/)
28
+ if(req.method==='GET'&&exported){json(res,200,await service.exportAsset(exported[1]));return}
29
+ const match=path.match(/^\/draft\/([a-zA-Z0-9-]+)(?:\/(preview\.png|source))?$/)
30
+ if(req.method==='GET'&&match){
31
+ if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value});return}
32
+ const bytes=await service.image(match[1],match[2]==='source')
33
+ res.writeHead(200,{'content-type':match[2]==='source'?'application/octet-stream':'image/png','cache-control':'private, max-age=3600','x-content-type-options':'nosniff'});res.end(bytes);return
34
+ }
35
+ if(req.method!=='POST')throw fault(404,'接口不存在')
36
+ if(req.headers['x-prts-request']!=='1')throw fault(403,'缺少请求标识')
37
+ const controller=new AbortController()
38
+ res.on('close',()=>{if(!res.writableEnded)controller.abort(new Error('请求已取消'))})
39
+ const data=await body(req)
40
+ if(path==='/configuration'){json(res,200,await service.save(data.configuration,data.revision,controller.signal));return}
41
+ if(path==='/import'){
42
+ const d=await service.importAsset(data,controller.signal)
43
+ json(res,200,{...draftView(d),value:d.value});return
44
+ }
45
+ if(path==='/preview'){
46
+ if(data.image && (typeof data.image!=='string'||! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data.image)))throw fault(400,'图片编码无效')
47
+ const d=await service.preview({bytes:data.image?Buffer.from(data.image,'base64'):undefined,baseId:data.draftId,params:data.params,label:data.label},controller.signal)
48
+ json(res,200,{...draftView(d),value:d.value});return
49
+ }
50
+ throw fault(404,'接口不存在')
51
+ }catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
52
+ }})
53
+ return()=>{dispose();for(const res of streams)res.end();streams.clear()}
54
+ }