@ash-qw/dsh-theme-prts 2.0.0 → 2.1.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.
@@ -0,0 +1,137 @@
1
+ // Ring geometry adapted from React Bits CometDial (David Haz).
2
+ // See src/vendor/react-bits/LICENSE.md and THIRD_PARTY_NOTICES.md.
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 },
5
+ { key: 'railOpacity', label: '设置侧栏', property: '--prts-rail-opacity', value: 70, blurKey: 'railBlur', blurProperty: '--prts-rail-blur', blurDefault: 12, fullBackgroundOnly: true },
6
+ { key: 'bakerOpacity', label: 'Baker 气泡', property: '--prts-baker-opacity', value: 50, blurKey: 'bakerBlur', blurProperty: '--prts-baker-blur', blurDefault: 8, theme: 'deck-chat' },
7
+ { key: 'classicUserOpacity', label: '经典用户消息', property: '--prts-classic-user-opacity', value: 50, blurKey: 'classicUserBlur', blurProperty: '--prts-classic-user-blur', blurDefault: 8, theme: 'native' },
8
+ ])
9
+ const dialClamp = value => Math.min(100, Math.max(0, Math.round(value)))
10
+ const dialPoint = angle => [100 + Math.cos(angle * Math.PI / 180) * 80, 100 + Math.sin(angle * Math.PI / 180) * 80]
11
+ const dialArc = (start, end) => {
12
+ const [x, y] = dialPoint(start), [ex, ey] = dialPoint(end)
13
+ return `M ${x} ${y} A 80 80 0 ${end - start > 180 ? 1 : 0} 1 ${ex} ${ey}`
14
+ }
15
+ export function opacityDialMarkup() {
16
+ const controls = BACKGROUND_OPACITY_CONTROLS.map(({key,label,value,blurDefault}) => {
17
+ const sample = key === 'bakerOpacity' ? '<span>这是一条回复</span><i></i>'
18
+ : key === 'classicUserOpacity' ? '<span>这是我发送的消息</span><i></i>'
19
+ : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>' : '<span>工作区</span><i></i><i></i>'
20
+ return `<div data-prts-opacity-control="${key}">
21
+ <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
+ <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>
23
+ <div data-prts-material-preview="${key}" aria-hidden="true"><div data-prts-material-preview-surface>${sample}</div></div>
24
+ <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
+ <small data-prts-opacity-note></small>
26
+ </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
+ export function mountOpacityDials({ section, window, onChange }) {
31
+ const root = section.ownerDocument.documentElement
32
+ const controls = BACKGROUND_OPACITY_CONTROLS.map(definition => {
33
+ const container = section.querySelector(`[data-prts-opacity-control="${definition.key}"]`)
34
+ const svg = container.querySelector('[data-prts-opacity-dial] svg'), input = container.querySelector('[data-prts-opacity-number] input'), tail = svg.querySelector('[data-dial-tail]')
35
+ const blurControl = container.querySelector('[data-prts-blur-control]')
36
+ const blurInput = blurControl.querySelector('input'), blurOutput = blurControl.querySelector('output')
37
+ const materialPreview = container.querySelector('[data-prts-material-preview]')
38
+ let value = definition.value, saved = value, grip = null, timer
39
+ let blurValue = definition.blurDefault, savedBlur = blurValue
40
+ const paint = next => {
41
+ const previous = value
42
+ value = dialClamp(next)
43
+ const angle = 110 + value * 3.2, [x,y] = dialPoint(angle)
44
+ svg.querySelector('[data-dial-lit]').setAttribute('d', value ? dialArc(110,angle) : '')
45
+ const head = svg.querySelector('[data-dial-head]');head.setAttribute('cx', x);head.setAttribute('cy', y)
46
+ svg.setAttribute('aria-valuenow', value);svg.setAttribute('aria-valuetext', `${value}%`)
47
+ input.value = value
48
+ window.clearTimeout(timer)
49
+ const reach = Math.min(65, Math.abs(value - previous) * 3.2)
50
+ tail.setAttribute('d', reach && value !== previous ? dialArc(Math.max(110, value > previous ? angle-reach : angle), Math.min(430, value > previous ? angle : angle+reach)) : '')
51
+ tail.style.opacity = reach ? '.5' : '0'
52
+ if (reach) timer = window.setTimeout(() => {tail.style.opacity = '0'}, 160)
53
+ }
54
+ const preview = next => { paint(next);root.style.setProperty(definition.property, `${100 - value}%`) }
55
+ const commit = () => { if (value !== saved) { saved = value;onChange(definition.key, (100 - value) / 100) } }
56
+ const position = event => {
57
+ const b = svg.getBoundingClientRect()
58
+ const x = (event.clientX-b.left)/b.width*200-100, y = (event.clientY-b.top)/b.height*200-100
59
+ if (Math.hypot(x,y) < 35) return value
60
+ let angle = (Math.atan2(y,x)*180/Math.PI-110+720)%360
61
+ // Keep a drag on its own side of the dead zone, avoiding 100 -> 0 jumps.
62
+ if (angle > 320) angle = grip?.side === 'high' ? 320 : grip?.side === 'low' ? 0 : angle < 340 ? 320 : 0
63
+ else if (grip) grip.side = angle > 160 ? 'high' : 'low'
64
+ return angle/3.2
65
+ }
66
+ const down = event => {
67
+ if (event.button > 0 || grip) return
68
+ event.preventDefault();svg.focus({preventScroll:true})
69
+ grip = { id: event.pointerId, side: null }
70
+ svg.setPointerCapture?.(event.pointerId);container.setAttribute('data-dragging','')
71
+ preview(position(event))
72
+ }
73
+ const move = event => { if (grip?.id === event.pointerId) preview(position(event)) }
74
+ const finish = (event, cancel = false) => {
75
+ if (!grip || (event.pointerId !== undefined && grip.id !== event.pointerId)) return
76
+ const id = grip.id;grip = null;container.removeAttribute('data-dragging')
77
+ if (svg.hasPointerCapture?.(id)) svg.releasePointerCapture(id)
78
+ if (cancel) preview(saved);else commit()
79
+ }
80
+ const up = event => finish(event)
81
+ const cancel = event => finish(event,true)
82
+ const key = event => {
83
+ if (event.key === 'Escape') { if(grip){event.preventDefault();event.stopPropagation();finish(event,true)}return }
84
+ const step = event.shiftKey ? 10 : 1
85
+ const next = {ArrowRight:value+step,ArrowUp:value+step,ArrowLeft:value-step,ArrowDown:value-step,PageUp:value+10,PageDown:value-10,Home:0,End:100}[event.key]
86
+ if (next === undefined) return
87
+ event.preventDefault();preview(next);commit()
88
+ }
89
+ const change = () => { const n = input.valueAsNumber;if(!Number.isFinite(n)){paint(saved);return}preview(n);commit() }
90
+ const reset = () => { preview(definition.value);commit() }
91
+ const paintBlur = next => {
92
+ blurValue = Math.min(32, Math.max(0, Math.round(next)))
93
+ blurInput.value = blurValue
94
+ blurOutput.value = `${blurValue}px`
95
+ materialPreview.style.setProperty(definition.blurProperty, `${blurValue}px`)
96
+ }
97
+ const previewBlur = next => {
98
+ paintBlur(next)
99
+ if (root.hasAttribute('data-dsh-prts')) root.style.setProperty(definition.blurProperty, `${blurValue}px`)
100
+ }
101
+ const commitBlur = () => { if (blurValue !== savedBlur) { savedBlur = blurValue;onChange(definition.blurKey, blurValue) } }
102
+ const blurChange = () => { previewBlur(blurInput.valueAsNumber);commitBlur() }
103
+ const blurCancel = event => { if (event.key === 'Escape') { event.preventDefault();previewBlur(savedBlur);blurInput.blur() } }
104
+ const resetBlur = () => { previewBlur(definition.blurDefault);commitBlur() }
105
+ const resetButton = container.querySelector('[data-prts-opacity-reset]')
106
+ const blurResetButton = blurControl.querySelector('[data-prts-blur-reset]')
107
+ const bindings = [[svg,'pointerdown',down],[svg,'pointermove',move],[svg,'pointerup',up],[svg,'pointercancel',cancel],[svg,'lostpointercapture',cancel],[svg,'keydown',key],[input,'change',change],[resetButton,'click',reset],[blurInput,'input',event=>previewBlur(event.target.valueAsNumber)],[blurInput,'change',blurChange],[blurInput,'keydown',blurCancel],[blurResetButton,'click',resetBlur],[window,'blur',cancel]]
108
+ for(const [el,type,fn] of bindings)el.addEventListener(type,fn)
109
+ paint(value)
110
+ return {
111
+ update(preferences, active) {
112
+ container.hidden = Boolean((definition.theme && preferences.conversationStyle !== definition.theme)
113
+ || (definition.fullBackgroundOnly && preferences.backgroundMode !== 'full'))
114
+ saved = 100 - Math.round((preferences[definition.key] ?? (100 - definition.value) / 100) * 100)
115
+ if (!grip && section.ownerDocument.activeElement !== input) paint(saved)
116
+ savedBlur = preferences[definition.blurKey] ?? definition.blurDefault
117
+ if (section.ownerDocument.activeElement !== blurInput) paintBlur(savedBlur)
118
+ blurControl.hidden = !active
119
+ container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : '当前未启用此材质'
120
+ },
121
+ dispose() { if(grip)finish({},true);window.clearTimeout(timer);for(const [el,type,fn] of bindings)el.removeEventListener(type,fn) },
122
+ }
123
+ })
124
+ return {
125
+ update(preferences, state) {
126
+ const full = preferences.backgroundMode === 'full' && Boolean(state.full)
127
+ const split = preferences.backgroundMode === 'split'
128
+ section.querySelector('[data-prts-opacity-settings]').hidden = preferences.backgroundMode === 'off'
129
+ const conversationImage = full || (split && Boolean(state.conversation))
130
+ const active = [full || (split && Boolean(state.sidebar)), full,
131
+ conversationImage && preferences.conversationStyle === 'deck-chat',
132
+ conversationImage && preferences.conversationStyle === 'native']
133
+ controls.forEach((control,i)=>control.update(preferences,active[i]))
134
+ },
135
+ dispose() {for(const control of controls)control.dispose()},
136
+ }
137
+ }
@@ -45,7 +45,7 @@ export function createOperationsShell({
45
45
  onSchemeToggle = () => {},
46
46
  onSchemeDragStart = () => undefined,
47
47
  onThemeDisable = () => {},
48
- customEmblemStore, avatarStore,
48
+ customEmblemStore, avatarStore, backgroundStore,
49
49
  onPreferenceChange = () => {},
50
50
  onPreferencePreview = () => {},
51
51
  onVisualReset = () => {},
@@ -455,7 +455,7 @@ export function createOperationsShell({
455
455
  trigger: shell.querySelector('[data-prts-rail-brand]'),
456
456
  panel: shell.querySelector('[data-prts-theme-settings]'),
457
457
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
458
- customEmblemStore, avatarStore,
458
+ customEmblemStore, avatarStore, backgroundStore,
459
459
  onPreferenceChange,
460
460
  onPreferencePreview,
461
461
  onResetVisual: onVisualReset,
@@ -24,6 +24,17 @@ export const DEFAULT_PREFERENCES = Object.freeze({
24
24
  glass: 'standard',
25
25
  motion: 'system',
26
26
  bootAnimation: true,
27
+ composerGlass: false,
28
+ backgroundMode: 'off',
29
+ backgroundDim: .45,
30
+ sidebarOpacity: .06,
31
+ railOpacity: .3,
32
+ bakerOpacity: .5,
33
+ classicUserOpacity: .5,
34
+ sidebarBlur: 8,
35
+ railBlur: 12,
36
+ bakerBlur: 8,
37
+ classicUserBlur: 8,
27
38
  cursor: 'prts',
28
39
  conversationParticleDensity: 'sparse',
29
40
  heroParticleDensity: 'light',
@@ -48,6 +59,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({
48
59
  sessionFlowSpeed: SESSION_FLOW_SPEED_DEFAULT,
49
60
  conversationStyle: 'native',
50
61
  bakerGroupAvatars: true,
62
+ bakerLoading: false,
51
63
  conversationEmblem: true,
52
64
  })
53
65
 
@@ -85,12 +97,12 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
85
97
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
86
98
  })
87
99
  export const PREFERENCE_GROUPS = Object.freeze({
88
- background: Object.freeze(['texture']),
100
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
89
101
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
90
- material: Object.freeze(['glass']),
102
+ material: Object.freeze(['glass', 'composerGlass']),
91
103
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
92
104
  navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
93
- conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'conversationEmblem']),
105
+ conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'bakerLoading', 'conversationEmblem']),
94
106
  })
95
107
 
96
108
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -134,6 +146,12 @@ function colorOr(value, fallback) {
134
146
  return typeof value === 'string' && /^#[\da-f]{6}$/i.test(value) ? value.toLowerCase() : fallback
135
147
  }
136
148
 
149
+ function materialBlurOr(value, fallback) {
150
+ return typeof value === 'number' && Number.isFinite(value)
151
+ ? Math.min(32, Math.max(0, Math.round(value)))
152
+ : fallback
153
+ }
154
+
137
155
  function particleTraversalSpeedOr(value, fallback) {
138
156
  const numeric = Number(value)
139
157
  if (!Number.isFinite(numeric)) return fallback
@@ -189,6 +207,18 @@ export function normalizePreferences(value) {
189
207
  texture: enumOr(input.texture, TEXTURES, DEFAULT_PREFERENCES.texture),
190
208
  glass: normalizeGlass(input),
191
209
  motion: input.motion === 'full' ? 'system' : enumOr(input.motion, MOTIONS, DEFAULT_PREFERENCES.motion),
210
+ bakerLoading: input.bakerLoading === true,
211
+ backgroundMode: ['off', 'split', 'full'].includes(input.backgroundMode) ? input.backgroundMode : 'off',
212
+ sidebarOpacity: typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
213
+ railOpacity: typeof input.railOpacity === 'number' && Number.isFinite(input.railOpacity) ? Math.min(1, Math.max(0, input.railOpacity)) : .3,
214
+ bakerOpacity: typeof input.bakerOpacity === 'number' && Number.isFinite(input.bakerOpacity) ? Math.min(1, Math.max(0, input.bakerOpacity)) : .5,
215
+ classicUserOpacity: typeof input.classicUserOpacity === 'number' && Number.isFinite(input.classicUserOpacity) ? Math.min(1, Math.max(0, input.classicUserOpacity)) : .5,
216
+ sidebarBlur: materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur),
217
+ railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
218
+ bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
219
+ classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
220
+ backgroundDim: typeof input.backgroundDim === 'number' && Number.isFinite(input.backgroundDim) ? Math.min(.9, Math.max(0, input.backgroundDim)) : .45,
221
+ composerGlass: booleanOr(input.composerGlass, DEFAULT_PREFERENCES.composerGlass),
192
222
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
193
223
  cursor: input.cursor === 'system' ? 'system' : DEFAULT_PREFERENCES.cursor,
194
224
  conversationParticleDensity: enumOr(legacyDensities.conversation, PARTICLE_DENSITIES, particlePreset.conversationParticleDensity),
@@ -1,8 +1,8 @@
1
1
  export function createSettingsPage(React, meta = {}) {
2
2
  const h = React.createElement
3
3
 
4
- return function PrtsPluginSettingsPage({ useStore, updatePreference }) {
5
- const { preferences } = useStore(state => ({ preferences: state.preferences }))
4
+ return function PrtsPluginSettingsPage({ useStore, updatePreference, retryPersistence }) {
5
+ const { preferences, persistence } = useStore(state => ({ preferences: state.preferences, persistence: state.status?.settingsPersistence }))
6
6
  const safeMode = Boolean(meta.safeMode)
7
7
  const enabled = Boolean(preferences.enabled) && !safeMode
8
8
  const bootAnimation = preferences.bootAnimation !== false
@@ -40,6 +40,11 @@ export function createSettingsPage(React, meta = {}) {
40
40
  safeMode && h('p', { className: 'prts-plugin-settings__safe', role: 'status' },
41
41
  '安全模式已暂停主题。移除地址中的 ?prts-safe=1 后即可恢复。',
42
42
  ),
43
+ persistence?.phase === 'error' && h('div', { role: 'alert' },
44
+ h('p', null, '设置未保存到 DSH。请检查连接或持久化支持;重试会重新提交本次修改。'),
45
+ h('button', { type: 'button', onClick: retryPersistence }, '重试保存'),
46
+ ),
47
+ persistence?.phase === 'saving' && h('p', { role: 'status' }, '正在保存…'),
43
48
  h('div', { className: 'prts-plugin-settings__list' },
44
49
  settingRow({
45
50
  key: 'enabled',
@@ -14,6 +14,7 @@ const ROOT_ATTRIBUTES = [
14
14
  'data-prts-session-flow-palette',
15
15
  'data-prts-session-flow-speed',
16
16
  'data-prts-conversation-style',
17
+ 'data-prts-background-mode',
17
18
  ]
18
19
  const SESSION_FLOW_COLOR_PROPERTIES = {
19
20
  '--prts-session-flow-back': 'sessionFlowColorBack',
@@ -112,6 +113,12 @@ export function createThemeController({ document, window, cssText, service, curs
112
113
  }
113
114
 
114
115
  function clearThemeState() {
116
+ root.removeAttribute('data-prts-baker-loading')
117
+ root.style.removeProperty('--prts-background-shade')
118
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.removeProperty(`--prts-${name}-opacity`)
119
+ root.style.removeProperty('--prts-classic-user-opacity')
120
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.removeProperty(`--prts-${name}-blur`)
121
+ root.style.removeProperty('--prts-classic-user-blur')
115
122
  for (const attribute of ROOT_ATTRIBUTES) root.removeAttribute(attribute)
116
123
  for (const property of Object.keys(SESSION_FLOW_COLOR_PROPERTIES)) root.style.removeProperty(property)
117
124
  clearTransitionState()
@@ -734,6 +741,13 @@ export function createThemeController({ document, window, cssText, service, curs
734
741
  root.dataset.prtsSessionFlowSpeed = String(preferences.sessionFlowSpeed)
735
742
  for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences[key])
736
743
  root.dataset.prtsConversationStyle = preferences.conversationStyle
744
+ root.toggleAttribute('data-prts-baker-loading', preferences.bakerLoading === true)
745
+ root.dataset.prtsBackgroundMode = preferences.backgroundMode
746
+ root.style.setProperty('--prts-background-shade', `${preferences.backgroundDim * 100}%`)
747
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-opacity`, `${preferences[`${name}Opacity`] * 100}%`)
748
+ root.style.setProperty('--prts-classic-user-opacity', `${preferences.classicUserOpacity * 100}%`)
749
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-blur`, `${preferences[`${name}Blur`]}px`)
750
+ root.style.setProperty('--prts-classic-user-blur', `${preferences.classicUserBlur}px`)
737
751
  refresh()
738
752
  },
739
753
  sync,
@@ -1,3 +1,4 @@
1
+ import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
1
2
  import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
2
3
  import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
3
4
  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'
@@ -37,7 +38,11 @@ function previewSetting(key, label, description) {
37
38
  }
38
39
 
39
40
  function settingButtons(key, label, options) {
40
- const note = key === 'motion'
41
+ const note = key === 'composerGlass'
42
+ ? '<small data-prts-setting-note>增强输入框的背景模糊与轻微折射,关闭时使用原有材质</small>'
43
+ : key === 'bakerLoading'
44
+ ? '<small data-prts-setting-note>每轮正文生成时显示三点动画,完成后显示全文;中断时保留已收到的内容</small>'
45
+ : key === 'motion'
41
46
  ? '<small data-prts-setting-note>关闭后减少装饰动效,保留运行状态等必要反馈</small><small data-prts-setting-note data-prts-effective-motion hidden>系统当前要求减少动态</small>'
42
47
  : key === 'railDefaultHidden'
43
48
  ? '<small data-prts-setting-note>在任意窗口宽度下默认收起 P.R.T.S. 导航,可从左侧按钮临时展开</small>'
@@ -54,7 +59,7 @@ function settingButtons(key, label, options) {
54
59
  : key === 'sessionFlowPalette'
55
60
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
56
61
  : ''
57
- if (['motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
62
+ if (['composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
58
63
  <span><strong>${label}</strong>${note}</span>
59
64
  <button type="button" role="switch" aria-label="${label}" aria-checked="false" data-prts-setting-switch="${key}" ${key === 'cursor' ? 'data-prts-cursor-switch' : ''}><span aria-hidden="true"></span></button>
60
65
  </div>`
@@ -116,7 +121,8 @@ export function themeSettingsMarkup() {
116
121
  <div data-prts-settings-body>
117
122
  <div data-prts-settings-sidebar>
118
123
  <nav data-prts-settings-nav role="tablist" aria-label="设置分区">
119
- <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>
124
+ <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>
125
+ <button type="button" data-prts-settings-jump="background" role="tab" id="prts-tab-background" aria-controls="prts-page-background" aria-selected="false" tabindex="-1">会话背景</button>
120
126
  <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>
121
127
  <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>
122
128
  <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>
@@ -124,7 +130,7 @@ export function themeSettingsMarkup() {
124
130
  </nav>
125
131
  <footer data-prts-settings-actions aria-label="全局主题设置操作">
126
132
  <div data-prts-persistence-error hidden role="alert">
127
- <span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
133
+ <span><strong>设置未保存</strong><small>未能保存到 DSH,可能存在连接中断、配置冲突或当前访问不支持持久保存。重试将重新提交本次修改。</small></span>
128
134
  <button type="button" data-prts-retry-save>重试保存</button>
129
135
  </div>
130
136
  <div data-prts-reset-zone>
@@ -148,9 +154,14 @@ export function themeSettingsMarkup() {
148
154
  </div>
149
155
  <div data-prts-classic-options>${settingButtons('conversationEmblem', '正文徽记', [])}</div>
150
156
  ${avatarMarkup()}
151
- <div data-prts-baker-options>${settingButtons('bakerGroupAvatars', '合并过程回复头像', [])}</div>
157
+ <div data-prts-baker-options>${settingButtons('bakerGroupAvatars', '合并过程回复头像', [])}${settingButtons('bakerLoading', '回复完成前显示加载动画', [])}</div>
152
158
  </section>
153
159
  </section>
160
+ <section data-prts-settings-section="background" role="tabpanel" id="prts-page-background" aria-labelledby="prts-tab-background" tabindex="0" hidden>
161
+ ${backgroundMarkup()}
162
+ <div data-prts-composer-glass-setting>${settingButtons('composerGlass', '输入框增强毛玻璃', [])}</div>
163
+ <small data-prts-composer-glass-required hidden>自定义背景下,输入框增强毛玻璃始终开启。</small>
164
+ </section>
154
165
  <section data-prts-settings-common data-prts-settings-section="interaction" role="tabpanel" id="prts-page-interaction" aria-labelledby="prts-tab-interaction" tabindex="0" hidden>
155
166
  <div data-prts-settings-common-grid>
156
167
  ${COMMON_CONTROLS.filter(([key]) => ['cursor', 'railDefaultHidden', 'motion'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
@@ -222,7 +233,7 @@ export function createThemeSettingsOverlay({
222
233
  trigger,
223
234
  panel,
224
235
  backdrop,
225
- customEmblemStore, avatarStore,
236
+ customEmblemStore, avatarStore, backgroundStore,
226
237
  onOpen = () => {},
227
238
  onPreferenceChange = () => {},
228
239
  onPreferencePreview = () => {},
@@ -230,6 +241,7 @@ export function createThemeSettingsOverlay({
230
241
  onRetrySave = () => {},
231
242
  }) {
232
243
  const window = document?.defaultView
244
+ const backgroundEditor = mountBackgroundSettings({panel,window,store:backgroundStore,onChange:onPreferenceChange})
233
245
  const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
234
246
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
235
247
  const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
@@ -474,8 +486,8 @@ export function createThemeSettingsOverlay({
474
486
  if (phase === 'saved' && revision === renderedPersistenceRevision) return
475
487
  if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
476
488
  savedTimer = undefined
477
- status.hidden = phase !== 'saved'
478
- status.textContent = phase === 'saved' ? '已保存' : ''
489
+ status.hidden = phase !== 'saved' && phase !== 'saving'
490
+ status.textContent = phase === 'saved' ? '已保存' : phase === 'saving' ? '正在保存…' : ''
479
491
  if (phase === 'saved') {
480
492
  renderedPersistenceRevision = revision
481
493
  savedTimer = window?.setTimeout?.(() => { status.hidden = true; savedTimer = undefined }, reducedMotion() ? 500 : 1200)
@@ -707,6 +719,10 @@ export function createThemeSettingsOverlay({
707
719
  panel.querySelector('[data-prts-classic-options]').hidden = preferences?.conversationStyle === 'deck-chat'
708
720
  panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
709
721
  panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
722
+ const customBackground = preferences.backgroundMode !== 'off'
723
+ panel.querySelector('[data-prts-composer-glass-setting]').hidden = customBackground
724
+ panel.querySelector('[data-prts-composer-glass-required]').hidden = !customBackground
725
+ backgroundEditor.update(preferences)
710
726
  currentPreferences = preferences
711
727
  currentStatus = status
712
728
  currentPersistence = persistenceState ?? { phase: 'idle', revision: 0 }
@@ -723,6 +739,7 @@ export function createThemeSettingsOverlay({
723
739
  dispose() {
724
740
  customEditor.dispose()
725
741
  avatarEditor.dispose()
742
+ backgroundEditor.dispose()
726
743
  isOpen = false
727
744
  hidePanel()
728
745
  if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { createBackgroundService, registerBackgroundApi } from './server/backgrounds.js'
2
+ import Schema from '@deepseek-ai/schemastery'
3
+ import { DEFAULT_PREFERENCES } from './client/preferences.js'
1
4
  import { createAvatarService, registerAvatarApi } from './server/avatars.js'
2
5
  import { join } from 'node:path'
3
6
  import { homedir } from 'node:os'
@@ -9,11 +12,22 @@ export const PACKAGE_ID = '@ash-qw/dsh-theme-prts'
9
12
  export const THEME_PLUGIN_ID = 'dsh-theme-prts'
10
13
  export const inject = ['tools', 'attachments', 'connection', 'webServer']
11
14
 
15
+ export const Config = Schema.object(Object.fromEntries([
16
+ ...Object.entries(DEFAULT_PREFERENCES).map(([key, value]) => [key,
17
+ (typeof value === 'boolean' ? Schema.boolean() : typeof value === 'number' ? Schema.number() : Schema.string()).default(value).volatile(),
18
+ ]),
19
+ ['preferencesInitialized', Schema.boolean().default(false).volatile()],
20
+ ]))
21
+
12
22
  export async function apply(ctx) {
23
+ ctx.inject(['settings'], settingsCtx => {
24
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
25
+ })
13
26
  const directory=join(process.env.DSH_HOME?.trim() || join(homedir(),'.dsh'),'plugins','dsh-theme-prts','emblems')
14
27
  const service=await createEmblemService(directory)
28
+ const backgrounds=await createBackgroundService(join(directory,'..','backgrounds'))
15
29
  const avatars=await createAvatarService(join(directory,'..','avatars'))
16
30
  const disposers=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments)].map(tool=>ctx.tools.register(tool))
17
- disposers.push(registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars))
18
- ctx.effect(()=>async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose()})
31
+ disposers.push(registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars),registerBackgroundApi(ctx,backgrounds))
32
+ ctx.effect(()=>async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose();await backgrounds.dispose()})
19
33
  }
@@ -0,0 +1,97 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import sharp from 'sharp'
5
+ import { body, json, isBase64 } from './api.js'
6
+ import { fault } from './emblems.js'
7
+
8
+ export const BACKGROUND_API = '/prts-theme-api/backgrounds'
9
+ const BACKGROUND_SLOTS = ['full', 'sidebar', 'conversation']
10
+ export async function createBackgroundService(directory) {
11
+ await mkdir(directory, { recursive: true, mode: 0o700 })
12
+ const configurationPath = join(directory, 'configuration.json')
13
+ let state = { revision: 0, full: null, sidebar: null, conversation: null }
14
+ try { state = JSON.parse(await readFile(configurationPath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
15
+ let queue = Promise.resolve()
16
+ const listeners = new Set()
17
+ const snapshot = () => structuredClone(state)
18
+ return {
19
+ snapshot,
20
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
21
+ async image(id) {
22
+ if (!BACKGROUND_SLOTS.some(slot => state[slot]?.id === id)) throw fault(404, '背景不存在')
23
+ return readFile(join(directory, id + '.webp'))
24
+ },
25
+ save(data) {
26
+ const task = queue.then(async () => {
27
+ if (!BACKGROUND_SLOTS.includes(data?.slot)) throw fault(400, '背景区域无效')
28
+ if (data.revision !== state.revision) throw fault(409, '背景已在其他页面更新,请重新操作')
29
+ let entry = null, output
30
+ if (data.image !== null) {
31
+ if (!isBase64(data.image) || data.image.length > 14 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
32
+ const bytes = Buffer.from(data.image, 'base64')
33
+ if (!bytes.length || bytes.length > 10 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
34
+ try {
35
+ const input = sharp(bytes, { limitInputPixels: 40000000, animated: false })
36
+ const metadata = await input.metadata()
37
+ if (!['png', 'jpeg', 'webp', 'avif'].includes(metadata.format)) throw Error('format')
38
+ const result = await input.rotate().resize({ width: 3840, height: 2160, fit: 'inside', withoutEnlargement: true }).webp({ quality: 86 }).toBuffer({ resolveWithObject: true })
39
+ output = result.data
40
+ entry = { id: randomUUID(), width: result.info.width, height: result.info.height }
41
+ } catch { throw fault(400, '无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)') }
42
+ }
43
+ const next = { ...state, revision: state.revision + 1, [data.slot]: entry }
44
+ const temporary = configurationPath + '.' + randomUUID() + '.tmp'
45
+ try {
46
+ if (entry) await writeFile(join(directory, entry.id + '.webp'), output, { mode: 0o600 })
47
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
48
+ await rename(temporary, configurationPath)
49
+ } catch (error) {
50
+ if (entry) await rm(join(directory, entry.id + '.webp'), { force: true }).catch(() => {})
51
+ throw error
52
+ } finally { await rm(temporary, { force: true }).catch(() => {}) }
53
+ const previous = state[data.slot]
54
+ state = next
55
+ if (previous) await rm(join(directory, previous.id + '.webp'), { force: true }).catch(() => {})
56
+ for (const listener of listeners) { try { listener(state.revision) } catch {} }
57
+ return snapshot()
58
+ })
59
+ queue = task.catch(() => {})
60
+ return task
61
+ },
62
+ async dispose() { listeners.clear(); await queue },
63
+ }
64
+ }
65
+
66
+ export function registerBackgroundApi(ctx, service) {
67
+ const connection = ctx.connection
68
+ let disposed = false
69
+ const streams = new Set()
70
+ const unregister = ctx.webServer.register({ kind: 'prefix', path: BACKGROUND_API, handler: async (req, res) => {
71
+ try {
72
+ if (disposed) throw fault(503, '主题服务正在重新加载')
73
+ if ('rejection' in connection.admit(req)) throw fault(401, '请先登录 DSH')
74
+ const path = new URL(req.url, 'http://localhost').pathname.slice(BACKGROUND_API.length)
75
+ if (req.method === 'GET' && path === '/events') {
76
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' })
77
+ streams.add(res)
78
+ const send = revision => res.write('data: ' + JSON.stringify({ revision }) + '\n\n')
79
+ const unsubscribe = service.subscribe(send)
80
+ send(service.snapshot().revision)
81
+ const timer = setInterval(() => res.write(': heartbeat\n\n'), 25000)
82
+ res.on('close', () => { clearInterval(timer); unsubscribe(); streams.delete(res) })
83
+ return
84
+ }
85
+ if (req.method === 'GET' && /^\/image\/[a-f0-9-]{36}$/.test(path)) {
86
+ const bytes = await service.image(path.slice(7))
87
+ res.writeHead(200, { 'content-type': 'image/webp', 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff' }); res.end(bytes); return
88
+ }
89
+ if (path !== '/configuration') throw fault(404, '接口不存在')
90
+ if (req.method === 'GET') { json(res, 200, service.snapshot()); return }
91
+ if (req.method !== 'POST') throw fault(405, '请求方法不支持')
92
+ if (req.headers['x-prts-request'] !== '1') throw fault(403, '缺少请求标识')
93
+ json(res, 200, await service.save(await body(req)))
94
+ } catch (error) { if (!res.headersSent) json(res, error.status || 500, { error: error.message }); else res.end() }
95
+ } })
96
+ return () => { disposed = true; unregister(); for (const stream of streams) stream.end(); streams.clear() }
97
+ }
@@ -16,10 +16,12 @@
16
16
  html[data-dsh-prts][data-prts-cursor="prts"] :is(textarea, input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="url"], input[type="password"], input[type="number"], input[type="tel"], [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]) {
17
17
  cursor: var(--prts-text-cursor), text;
18
18
  }
19
+ html[data-dsh-prts][data-prts-cursor="prts"] [data-prts-opacity-dial] svg,
19
20
  html[data-dsh-prts][data-prts-cursor="prts"] [data-emblem-key],
20
21
  html[data-dsh-prts][data-prts-cursor="prts"] :is(.dsh-partner-pendant-hit, .dsh-partner-pendant-anchor, [data-prts-emblem-reorder]):enabled {
21
22
  cursor: url("__PRTS_GRAB__") 22 19, grab;
22
23
  }
24
+ html[data-dsh-prts][data-prts-cursor="prts"] [data-prts-opacity-control][data-dragging] svg,
23
25
  html[data-dsh-prts][data-prts-cursor="prts"] :is(.dsh-partner-pendant-hit, .dsh-partner-pendant-anchor, [data-prts-emblem-reorder]):enabled:active {
24
26
  cursor: var(--prts-sort-cursor);
25
27
  }