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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ // Reveal the temporarily selected card before mounting the destination conversation.
2
+ // Keep the destination stable through unrelated reflow; retarget smoothly only when needed.
3
+ export function createSessionCardReveal({window, root, sessionId, onDone, onStart}) {
4
+ let row, scroller, frame, timer, started = false, disposed = false, geometry, destination, stableSince, lastPosition
5
+ const findRow = () => [...root.querySelectorAll('[data-row-key], [data-session-id]')].find(el =>
6
+ el.getAttribute('data-row-key') === `session:${sessionId}` || el.getAttribute('data-session-id') === sessionId)
7
+ const instant = top => {
8
+ try { scroller.scrollTo({top, behavior: 'instant'}) }
9
+ catch { scroller.scrollTop = top }
10
+ }
11
+ function cancel() {
12
+ if (disposed) return
13
+ disposed = true
14
+ if (scroller && destination !== undefined) instant(scroller.scrollTop)
15
+ destination = undefined
16
+ window.cancelAnimationFrame(frame)
17
+ window.clearTimeout(timer)
18
+ resize?.disconnect()
19
+ mutations.disconnect()
20
+ root.removeEventListener('transitionend', schedule)
21
+ root.removeEventListener('animationend', schedule)
22
+ scroller?.removeEventListener('scrollend', scrollEnded)
23
+ }
24
+ function scrollEnded() {
25
+ if (destination !== undefined && Math.abs(scroller.scrollTop - destination) <= 1) destination = undefined
26
+ schedule()
27
+ }
28
+ function schedule() {
29
+ if (!disposed && frame === undefined) frame = window.requestAnimationFrame(() => { frame = undefined; check() })
30
+ }
31
+ function check(final = false) {
32
+ if (disposed) return
33
+ const nextRow = findRow()
34
+ if (!nextRow || (nextRow.getAttribute('data-prts-selection-target') !== 'true' && nextRow.getAttribute('aria-selected') !== 'true')) {
35
+ stableSince = undefined
36
+ if (started && !final) schedule()
37
+ return
38
+ }
39
+ const remounted = nextRow !== row
40
+ if (remounted) {
41
+ row = nextRow
42
+ const previousScroller = scroller
43
+ scroller?.removeEventListener('scrollend', scrollEnded)
44
+ resize?.disconnect()
45
+ scroller = undefined
46
+ for (let node = row.parentElement; node && node !== root.parentElement; node = node.parentElement) {
47
+ if (/^(auto|scroll)$/.test(window.getComputedStyle(node).overflowY) && node.clientHeight > 0) { scroller = node; break }
48
+ }
49
+ if (scroller !== previousScroller) destination = undefined
50
+ for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
51
+ for (const animation of node.getAnimations?.() ?? []) {
52
+ const timing = animation.effect?.getComputedTiming?.()
53
+ if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animation.finished.then(schedule, schedule)
54
+ }
55
+ }
56
+ resize?.observe(root)
57
+ if (scroller) { resize?.observe(scroller); scroller.addEventListener('scrollend', scrollEnded) }
58
+ }
59
+ let settled = true
60
+ const first = !started
61
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
62
+ || root.ownerDocument.documentElement.getAttribute('data-prts-motion') === 'reduced'
63
+ if (scroller) {
64
+ // Read all geometry before writing scrollTop. Offset is independent of smooth-scroll progress.
65
+ const card = row.getBoundingClientRect(), box = scroller.getBoundingClientRect()
66
+ const current = scroller.scrollTop, height = scroller.clientHeight
67
+ const top = card.top - box.top - scroller.clientTop, bottom = card.bottom - box.top - scroller.clientTop
68
+ const next = [top + current, card.height, height, scroller.scrollHeight]
69
+ const changed = remounted || !geometry || next.some((v, i) => Math.abs(v - geometry[i]) > 1)
70
+ if (first || changed || final) {
71
+ // Evaluate visibility at the pending destination, not at an intermediate animation frame.
72
+ // Rows below the target may change scrollHeight without invalidating that destination.
73
+ const base = destination ?? current
74
+ const offset = next[0], bottomOffset = offset + card.height
75
+ const limit = Math.max(0, next[3] - height)
76
+ const nearest = position => Math.max(0, Math.min(limit,
77
+ offset < position ? offset : bottomOffset > position + height ? bottomOffset - height : position))
78
+ // A stale endpoint can lie on the other side of a card that is already visible.
79
+ // Preserve valid endpoints, but correct invalid ones from the actual viewport.
80
+ const target = Math.abs(nearest(base) - base) > 1 ? nearest(current) : base
81
+ if (final) {
82
+ // End any native animation at the nearest visible position before disconnecting.
83
+ instant(nearest(current))
84
+ destination = undefined
85
+ } else if (Math.abs(target - base) > 1) {
86
+ destination = target
87
+ if (!reduced && typeof scroller.scrollTo === 'function') scroller.scrollTo({top: target, behavior: 'smooth'})
88
+ else instant(target)
89
+ }
90
+ }
91
+ settled = !changed && Math.abs(current - (lastPosition ?? current)) <= 1
92
+ && (destination === undefined || Math.abs(scroller.scrollTop - destination) <= 1)
93
+ lastPosition = current
94
+ geometry = next
95
+ } else if (first || remounted || final) {
96
+ row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:'instant'})
97
+ }
98
+ for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
99
+ if ((node.getAnimations?.() ?? []).some(animation => animation.playState === 'running'
100
+ && Number.isFinite(animation.effect?.getComputedTiming?.().endTime))) settled = false
101
+ }
102
+ if (first) {
103
+ started = true
104
+ onStart?.()
105
+ // Bound the pre-navigation wait; finish any remaining scroll before opening.
106
+ timer = window.setTimeout(() => { check(true); cancel(); onDone() }, 1000)
107
+ }
108
+ if (!final) {
109
+ const now = window.performance.now()
110
+ if (!settled) stableSince = undefined
111
+ else stableSince ??= now
112
+ if (stableSince !== undefined && now - stableSince >= 80) {
113
+ cancel()
114
+ onDone()
115
+ } else schedule()
116
+ }
117
+ }
118
+ const resize = typeof window.ResizeObserver === 'function' ? new window.ResizeObserver(schedule) : undefined
119
+ const mutations = new window.MutationObserver(schedule)
120
+ mutations.observe(root, {childList:true, subtree:true, attributes:true, attributeFilter:['aria-selected','aria-expanded','data-prts-selection-target']})
121
+ root.addEventListener('transitionend', schedule)
122
+ root.addEventListener('animationend', schedule)
123
+ schedule()
124
+ return {cancel, interrupt: cancel}
125
+ }
@@ -1,3 +1,4 @@
1
+ import { createSessionCardReveal } from './session-card-reveal.js'
1
2
  import { createSessionSummaryPreview, scrollSummaryTitle } from './session-summary-preview.js'
2
3
  import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
3
4
  import { createSessionLifelineAnimator } from './session-lifeline.js'
@@ -353,7 +354,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
353
354
  queueScan()
354
355
  }
355
356
  function interruptSelectionTarget(event) {
356
- if (event.target.closest?.('[data-prts-session-row]')) clearSelectionTarget()
357
+ if (event.target.closest?.('[data-prts-session-row]')) {
358
+ cancelPendingNavigation()
359
+ cancelReveal()
360
+ clearSelectionTarget()
361
+ }
357
362
  }
358
363
  function setSelectionTarget(id) {
359
364
  clearSelectionTarget()
@@ -362,27 +367,41 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
362
367
  document.addEventListener('pointerdown', interruptSelectionTarget, true)
363
368
  }
364
369
 
365
- let pendingReveal, revealTimer, revealFrame, revealSettleTimer
370
+ let pendingReveal, revealTimer
366
371
  let navigationFrame, navigationGeneration = 0
367
372
  function cancelPendingNavigation() {
368
373
  navigationGeneration++
369
374
  window.cancelAnimationFrame?.(navigationFrame)
370
375
  }
371
376
  function cancelReveal() {
377
+ pendingReveal?.tracker?.cancel()
372
378
  pendingReveal = undefined
373
379
  window.clearTimeout(revealTimer)
374
- window.clearTimeout(revealSettleTimer)
375
- window.cancelAnimationFrame?.(revealFrame)
380
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.removeEventListener(type, interruptReveal, true)
376
381
  }
377
- function revealSessionCard(sessionId) {
382
+ function interruptReveal(event) {
383
+ if (!event.target?.closest?.('[data-prts-region="sessions"]')) return
384
+ if (event.type === 'keydown' && !['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' '].includes(event.key)) return
385
+ cancelPendingNavigation()
378
386
  cancelReveal()
379
- pendingReveal = {sessionId, attempts: 0}
380
- revealTimer = window.setTimeout(cancelReveal, 4000)
387
+ clearSelectionTarget()
388
+ }
389
+ function revealSessionCard(sessionId, onDone) {
390
+ pendingReveal?.tracker?.interrupt()
391
+ cancelReveal()
392
+ pendingReveal = {sessionId, onDone}
393
+ revealTimer = window.setTimeout(finishReveal, 4000)
394
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.addEventListener(type, interruptReveal, {capture:true, passive:true})
381
395
  const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
382
396
  syncSessionReveal(root)
383
397
  queueScan()
384
398
  return Boolean(pendingReveal?.expanding)
385
399
  }
400
+ function finishReveal() {
401
+ const onDone = pendingReveal?.onDone
402
+ cancelReveal()
403
+ onDone?.()
404
+ }
386
405
  function syncSessionReveal(root) {
387
406
  if (!pendingReveal || !root) return
388
407
  const {sessionId} = pendingReveal
@@ -390,30 +409,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
390
409
  const row = rows.find(el => el.getAttribute('data-row-key') === `session:${sessionId}`
391
410
  || el.getAttribute('data-session-id') === sessionId)
392
411
  if (row) {
393
- if (pendingReveal.scrolling) return
394
- const request = pendingReveal
395
- request.scrolling = true
396
- // Let React finish mounting and let the host's row glide settle before scrolling.
397
- revealFrame = window.requestAnimationFrame(async () => {
398
- const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
399
- const animations = []
400
- if (!reduced) for (let node = row; node && node !== root; node = node.parentElement) {
401
- for (const animation of node.getAnimations?.() ?? []) {
402
- const timing = animation.effect?.getComputedTiming?.()
403
- if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animations.push(animation.finished)
404
- }
405
- }
406
- if (animations.length) await Promise.race([
407
- Promise.allSettled(animations),
408
- new Promise(resolve => { revealSettleTimer = window.setTimeout(resolve, 300) }),
409
- ])
410
- if (pendingReveal !== request || !row.isConnected) return
411
- row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:reduced ? 'instant' : 'smooth'})
412
- cancelReveal()
413
- })
412
+ if (!pendingReveal.tracker) {
413
+ const request = pendingReveal
414
+ request.tracker = createSessionCardReveal({window, root, sessionId, onStart: () => window.clearTimeout(revealTimer), onDone: () => {
415
+ if (pendingReveal === request) finishReveal()
416
+ }})
417
+ }
414
418
  return
415
419
  }
416
- if (++pendingReveal.attempts > 40) { cancelReveal(); return }
417
420
  const items = snapshotOf(workspaces?.list)?.items ?? []
418
421
  const owner = items.find(item => item.sessionIds?.includes(sessionId))
419
422
  const key = owner?.workspaceId ?? ''
@@ -439,7 +442,6 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
439
442
  closeNavigationPreview()
440
443
  cancelPendingNavigation()
441
444
  setSelectionTarget(sessionId)
442
- revealSessionCard(sessionId)
443
445
  const generation = navigationGeneration
444
446
  const navigate = () => {
445
447
  if (generation !== navigationGeneration || !adapterActive) return
@@ -462,11 +464,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
462
464
  if (row) row.click?.()
463
465
  else clearSelectionTarget()
464
466
  }
465
- // Paint selection and expansion before the host synchronously mounts another conversation.
466
- // Already-expanded workspaces need this too: queueScan has not applied selection yet.
467
- navigationFrame = window.requestAnimationFrame(() => {
468
- if (generation !== navigationGeneration) return
469
- navigationFrame = window.requestAnimationFrame(navigate)
467
+ // Finish sidebar motion first, then paint its final position before the host can block.
468
+ revealSessionCard(sessionId, () => {
469
+ if (generation !== navigationGeneration || !adapterActive) return
470
+ setSelectionTarget(sessionId)
471
+ navigationFrame = window.requestAnimationFrame(() => {
472
+ if (generation !== navigationGeneration) return
473
+ navigationFrame = window.requestAnimationFrame(navigate)
474
+ })
470
475
  })
471
476
  }
472
477
 
@@ -72,7 +72,7 @@ function resolveScheme(value) {
72
72
  return identity.includes('dark') || identity.includes('night') ? 'dark' : 'light'
73
73
  }
74
74
 
75
- export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {} }) {
75
+ export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {}, onSchemeChange = () => {} }) {
76
76
  if (!document || !window) {
77
77
  const gesture = { update() {}, finish() {}, cancel() {} }
78
78
  return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
@@ -153,6 +153,7 @@ export function createThemeController({ document, window, cssText, service, curs
153
153
  if (disposed) return
154
154
  const next = resolveScheme(value)
155
155
  root.dataset.prtsScheme = next
156
+ onSchemeChange(next)
156
157
  return next
157
158
  }
158
159
 
@@ -1,3 +1,4 @@
1
+ import { themeTransferMarkup, mountThemeTransfer } from './theme-transfer-panel.js'
1
2
  import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
2
3
  import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
3
4
  import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
@@ -38,7 +39,9 @@ function previewSetting(key, label, description) {
38
39
  }
39
40
 
40
41
  function settingButtons(key, label, options) {
41
- const note = key === 'composerGlass'
42
+ const note = key === 'heroContrast'
43
+ ? '<small data-prts-setting-note>为新会话徽记添加柔光衬底,使它在背景图片上更清晰。</small>'
44
+ : key === 'composerGlass'
42
45
  ? '<small data-prts-setting-note>增强输入框的背景模糊与轻微折射,关闭时使用原有材质</small>'
43
46
  : key === 'bakerLoading'
44
47
  ? '<small data-prts-setting-note>每轮正文生成时显示三点动画,完成后显示全文;中断时保留已收到的内容</small>'
@@ -59,7 +62,7 @@ function settingButtons(key, label, options) {
59
62
  : key === 'sessionFlowPalette'
60
63
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
61
64
  : ''
62
- if (['composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
65
+ if (['heroContrast', 'composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
63
66
  <span><strong>${label}</strong>${note}</span>
64
67
  <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>
65
68
  </div>`
@@ -134,9 +137,10 @@ export function themeSettingsMarkup() {
134
137
  <span><strong>设置未保存</strong><small>未能保存到 DSH,可能存在连接中断、配置冲突或当前访问不支持持久保存。重试将重新提交本次修改。</small></span>
135
138
  <button type="button" data-prts-retry-save>重试保存</button>
136
139
  </div>
140
+ <button type="button" data-prts-transfer-open data-prts-footer-action aria-haspopup="dialog" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 14V3m-3 3 3-3 3 3M14 6v11m-3-3 3 3 3-3"/></svg><span>导入与导出</span></button>
137
141
  <div data-prts-reset-zone>
138
142
 
139
- <button type="button" data-prts-reset-visual title="恢复全部主题设置" aria-label="恢复全部主题设置">恢复默认</button>
143
+ <button type="button" data-prts-reset-visual data-prts-footer-action title="恢复全部主题设置" aria-label="恢复全部主题设置"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7a6.5 6.5 0 1 1-.4 5M4 3v4h4"/></svg><span>恢复默认</span></button>
140
144
  <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部主题设置">
141
145
  <span>重置全部分类的主题设置,保留主题开关、启动动画和徽记配置。</span>
142
146
  <button type="button" data-prts-reset-confirm-action>确认恢复</button>
@@ -144,6 +148,7 @@ export function themeSettingsMarkup() {
144
148
  </div>
145
149
  </div>
146
150
  </footer>
151
+ ${themeTransferMarkup()}
147
152
  </div>
148
153
  <div data-prts-theme-settings-scroll>
149
154
  <section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
@@ -159,7 +164,7 @@ export function themeSettingsMarkup() {
159
164
  </section>
160
165
  </section>
161
166
  <section data-prts-settings-section="background" role="tabpanel" id="prts-page-background" aria-labelledby="prts-tab-background" tabindex="0" hidden>
162
- ${backgroundMarkup()}
167
+ ${backgroundMarkup(settingButtons('heroContrast', '新会话徽记增强', []))}
163
168
  <div data-prts-composer-glass-setting>${settingButtons('composerGlass', '输入框增强毛玻璃', [])}</div>
164
169
  <small data-prts-composer-glass-required hidden>自定义背景下,输入框增强毛玻璃始终开启。</small>
165
170
  </section>
@@ -262,7 +267,7 @@ export function createThemeSettingsOverlay({
262
267
  trigger,
263
268
  panel,
264
269
  backdrop,
265
- customEmblemStore, avatarStore, backgroundStore,
270
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
266
271
  onOpen = () => {},
267
272
  onPreferenceChange = () => {},
268
273
  onPreferencePreview = () => {},
@@ -270,6 +275,7 @@ export function createThemeSettingsOverlay({
270
275
  onRetrySave = () => {},
271
276
  }) {
272
277
  const window = document?.defaultView
278
+ const transferEditor = mountThemeTransfer({ panel, window, transfer: themeTransfer })
273
279
  const backgroundEditor = mountBackgroundSettings({panel,window,store:backgroundStore,onChange:onPreferenceChange})
274
280
  const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
275
281
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
@@ -749,6 +755,7 @@ export function createThemeSettingsOverlay({
749
755
  panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
750
756
  panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
751
757
  const customBackground = preferences.backgroundMode !== 'off'
758
+ panel.querySelector('[data-prts-background-hero-setting]').hidden = !customBackground
752
759
  panel.querySelector('[data-prts-composer-glass-setting]').hidden = customBackground
753
760
  panel.querySelector('[data-prts-composer-glass-required]').hidden = !customBackground
754
761
  backgroundEditor.update(preferences)
@@ -766,6 +773,7 @@ export function createThemeSettingsOverlay({
766
773
  close: () => setOpen(false),
767
774
  toggle,
768
775
  dispose() {
776
+ transferEditor.dispose()
769
777
  customEditor.dispose()
770
778
  avatarEditor.dispose()
771
779
  backgroundEditor.dispose()
@@ -0,0 +1,98 @@
1
+ import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
2
+
3
+ export function themeTransferMarkup() {
4
+ return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
5
+ <section data-prts-transfer>
6
+ <header data-prts-transfer-heading><div><small>P.R.T.S. / 主题文件</small><h2 id="prts-transfer-title">导入与导出</h2></div><button type="button" data-prts-transfer-close aria-label="关闭导入与导出">×</button></header>
7
+ <div data-prts-transfer-columns>
8
+ <section data-prts-transfer-card>
9
+ <h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
10
+ <label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
11
+ <small>取消勾选,仅导出主题设置。</small>
12
+ <button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
13
+ </section>
14
+ <section data-prts-transfer-card>
15
+ <h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
16
+ <div data-prts-transfer-file-hint><span aria-hidden="true">↑</span><small>.prts-theme.json · 最大 160 MB</small></div>
17
+ <button type="button" data-prts-transfer-choose>选择主题文件</button>
18
+ <input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
19
+ </section>
20
+ </div>
21
+ <div data-prts-transfer-preview hidden>
22
+ <strong>确认导入</strong><p data-prts-transfer-summary></p>
23
+ <small>应用前会下载当前配置备份,请保留备份文件。</small>
24
+ <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
25
+ </div>
26
+ <div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
27
+ <p data-prts-transfer-status role="status" aria-live="polite"></p>
28
+ </section>
29
+ </dialog>`
30
+ }
31
+
32
+ export function mountThemeTransfer({ panel, window, transfer }) {
33
+ const section = panel.querySelector('[data-prts-transfer]')
34
+ if (!section || !transfer) return { dispose() {} }
35
+ const dialog = panel.querySelector('[data-prts-transfer-dialog]')
36
+ const trigger = panel.querySelector('[data-prts-transfer-open]')
37
+ const open = () => { dialog.showModal(); trigger?.setAttribute('aria-expanded', 'true'); get('close').focus() }
38
+ const close = () => { if (busy) return; dialog.close(); trigger?.setAttribute('aria-expanded', 'false'); trigger?.focus() }
39
+ const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
40
+ const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
41
+ const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
42
+ let pending, busy = false, disposed = false, generation = 0
43
+ const render = () => {
44
+ if (disposed) return
45
+ get('preview').hidden = !pending
46
+ get('recovery').hidden = !transfer.hasBackup
47
+ for (const control of section.querySelectorAll('button,input')) control.disabled = busy
48
+ // Prevent local editors from racing a multi-service import; other clients use revision checks.
49
+ for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
50
+ }
51
+ async function run(task) {
52
+ if (busy) return
53
+ busy = true; get('status').textContent = '正在处理,请稍候…'; render()
54
+ try { get('status').textContent = await task() }
55
+ catch (error) { if (!disposed) get('status').textContent = error.message }
56
+ finally { busy = false; render() }
57
+ }
58
+ const change = event => {
59
+ if (event.target !== get('file')) return
60
+ const file = event.target.files?.[0], selected = ++generation
61
+ pending = undefined; render()
62
+ if (!file) return
63
+ void run(async () => {
64
+ if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
65
+ const text = await new Promise((resolve, reject) => {
66
+ const reader = new window.FileReader()
67
+ reader.onload = () => resolve(reader.result); reader.onerror = () => reject(Error('无法读取主题文件')); reader.readAsText(file)
68
+ })
69
+ let value
70
+ try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
71
+ validateThemePackage(value)
72
+ if (disposed || selected !== generation) return ''
73
+ pending = value
74
+ get('summary').textContent = describeThemePackage(value)
75
+ return '已读取文件,请确认导入内容。'
76
+ })
77
+ }
78
+ const click = event => {
79
+ const button = event.target.closest?.('button')
80
+ if (button === get('close')) close()
81
+ if (button === get('choose')) get('file').click()
82
+ if (button === get('export')) void run(async () => {
83
+ const value = await transfer.export(get('assets').checked)
84
+ transfer.download(value)
85
+ return '已发起下载,请确认主题文件已保存。'
86
+ })
87
+ if (button === get('apply') && pending) void run(async () => {
88
+ await transfer.import(pending); pending = undefined; get('file').value = ''
89
+ return '主题已导入并保存。'
90
+ })
91
+ if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
92
+ if (button === get('restore')) void run(async () => { await transfer.restore(); return '已恢复导入前配置。' })
93
+ }
94
+ trigger?.addEventListener('click', open)
95
+ dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
96
+ section.addEventListener('change', change); section.addEventListener('click', click); render()
97
+ return { dispose() { disposed = true; generation++; if (dialog.open) dialog.close(); trigger?.removeEventListener('click', open); dialog.removeEventListener('keydown', keydown); dialog.removeEventListener('cancel', cancel); section.removeEventListener('change', change); section.removeEventListener('click', click) } }
98
+ }
@@ -0,0 +1,170 @@
1
+ import { validBackgroundRegion } from '../shared/background-region.js'
2
+ import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
3
+
4
+ export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
5
+ const transferSlots = ['full', 'sidebar', 'conversation']
6
+ const transferRoles = ['user', 'assistant']
7
+ const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
9
+ const encodedImage = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= Math.ceil(limit / 3) * 4 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
10
+
11
+ export function validateThemePackage(value) {
12
+ if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
13
+ if (!record(value.preferences) || !Object.keys(value.preferences).some(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key))) throw Error('主题文件缺少设置')
14
+ if (!Number.isInteger(value.preferences.version) || value.preferences.version < 1 || value.preferences.version > DEFAULT_PREFERENCES.version) throw Error('不支持此设置版本,请更新主题后重试')
15
+ const normalized = normalizePreferences(value.preferences)
16
+ for (const [key, input] of Object.entries(value.preferences)) {
17
+ if (!Object.hasOwn(DEFAULT_PREFERENCES, key) || key === 'version') continue
18
+ if (typeof input !== typeof DEFAULT_PREFERENCES[key] || (typeof input === 'number' && !Number.isFinite(input))) throw Error(`设置 ${key} 的类型无效`)
19
+ // Current-format files must not silently turn malformed settings into defaults.
20
+ if (value.preferences.version === DEFAULT_PREFERENCES.version && normalized[key] !== input) throw Error(`设置 ${key} 的值无效`)
21
+ }
22
+ if (value.assets === undefined) return value
23
+ const a = value.assets
24
+ if (!record(a) || !record(a.backgrounds) || !record(a.avatars) || !record(a.emblems)) throw Error('主题素材数据不完整')
25
+ for (const slot of transferSlots) if (a.backgrounds[slot] !== null && !encodedImage(a.backgrounds[slot], 10 * 1024 * 1024)) throw Error('背景图片数据无效')
26
+ if (a.backgroundRegions !== undefined && (!record(a.backgroundRegions) || transferSlots.some(slot => !validBackgroundRegion(a.backgroundRegions[slot] ?? null)))) throw Error('背景保护区域无效')
27
+ for (const role of transferRoles) {
28
+ const avatar = a.avatars[role]
29
+ if (avatar === null) continue
30
+ const crop = avatar?.crop
31
+ if (!record(avatar) || !encodedImage(avatar.image, 5 * 1024 * 1024) || !record(crop)
32
+ || !Number.isFinite(crop.zoom) || crop.zoom < 1 || crop.zoom > 4
33
+ || !Number.isFinite(crop.x) || crop.x < 0 || crop.x > 1 || !Number.isFinite(crop.y) || crop.y < 0 || crop.y > 1) throw Error('头像数据或裁剪参数无效')
34
+ }
35
+ const { items, hero, conversation } = a.emblems
36
+ if (!Array.isArray(items) || items.length > 13 || !Array.isArray(conversation) || conversation.length < 1 || conversation.length > 12) throw Error('徽记数量无效')
37
+ for (const item of items) {
38
+ if (!record(item) || item.format !== 'prts-emblem' || item.version !== 1 || typeof item.label !== 'string' || !item.label.trim() || item.label.length > 100 || !record(item.params) || !encodedImage(item.image, 8 * 1024 * 1024)) throw Error('徽记素材无效')
39
+ }
40
+ const validEntry = entry => record(entry) && (entry.kind === 'builtin' ? transferBuiltins.includes(entry.key) : entry.kind === 'custom' && Number.isInteger(entry.asset) && entry.asset >= 0 && entry.asset < items.length && typeof entry.label === 'string' && entry.label.length <= 100)
41
+ if ((hero !== null && (!validEntry(hero) || hero.kind !== 'custom')) || !conversation.every(validEntry)) throw Error('徽记引用无效')
42
+ return value
43
+ }
44
+
45
+ export function mergeThemePreferences(current, imported) {
46
+ const normalized = normalizePreferences(imported)
47
+ const patch = Object.fromEntries(Object.keys(DEFAULT_PREFERENCES).filter(key => key !== 'version' && Object.hasOwn(imported, key)).map(key => [key, normalized[key]]))
48
+ return normalizePreferences({ ...current, ...patch, version: DEFAULT_PREFERENCES.version })
49
+ }
50
+
51
+ export function describeThemePackage(value) {
52
+ const count = Object.keys(value.preferences).filter(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key)).length
53
+ if (!value.assets) return `${count} 项主题设置;不包含素材,将保留当前背景、头像和徽记。`
54
+ const a = value.assets
55
+ return `${count} 项主题设置、${transferSlots.filter(k => a.backgrounds[k]).length} 张背景、${transferRoles.filter(k => a.avatars[k]).length} 个头像、${a.emblems.items.length} 个自定义徽记。将替换当前素材及徽记排列,空项恢复为默认。`
56
+ }
57
+
58
+ export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
59
+ let busy = false, backup
60
+ const request = async (path, data) => {
61
+ const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin',
62
+ ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
63
+ const result = await response.json()
64
+ if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
65
+ return result
66
+ }
67
+ const readImage = blob => new Promise((resolve, reject) => {
68
+ const reader = new window.FileReader()
69
+ reader.onload = () => resolve(String(reader.result).split(',')[1])
70
+ reader.onerror = () => reject(Error('无法读取背景图片'))
71
+ reader.readAsDataURL(blob)
72
+ })
73
+ async function capture(includeAssets) {
74
+ const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
75
+ const states = {}
76
+ if (includeAssets) {
77
+ const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
78
+ states.backgrounds = await request('backgrounds/configuration')
79
+ for (const slot of transferSlots) {
80
+ const entry = states.backgrounds[slot]
81
+ backgroundRegions[slot] = entry?.region ?? null
82
+ backgrounds[slot] = null
83
+ if (entry) {
84
+ if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
85
+ const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin' })
86
+ if (!response.ok) throw Error('无法导出背景图片')
87
+ backgrounds[slot] = await readImage(await response.blob())
88
+ }
89
+ }
90
+ states.avatars = await request('avatars/configuration')
91
+ for (const role of transferRoles) {
92
+ const source = await request('avatars/edit/' + role)
93
+ if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
94
+ avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
95
+ }
96
+ states.emblems = await request('emblems/configuration')
97
+ async function reference(entry) {
98
+ if (!entry) return null
99
+ if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
100
+ if (!ids.has(entry.draftId)) {
101
+ ids.set(entry.draftId, items.length)
102
+ items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
103
+ }
104
+ return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
105
+ }
106
+ const hero = await reference(states.emblems.configuration.hero), conversation = []
107
+ for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
108
+ value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
109
+ for (const kind of ['backgrounds', 'avatars', 'emblems']) {
110
+ if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
111
+ }
112
+ }
113
+ if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
114
+ validateThemePackage(value)
115
+ return { value, states }
116
+ }
117
+ async function exclusive(fn) {
118
+ if (busy) throw Error('正在处理主题文件,请稍候')
119
+ busy = true
120
+ try { return await fn() } finally { busy = false }
121
+ }
122
+ function download(value, name = 'theme') {
123
+ const blob = new window.Blob([JSON.stringify(value)], { type: 'application/json' })
124
+ if (blob.size > THEME_PACKAGE_LIMIT) throw Error('主题文件超过 160 MB,请减少素材后重试')
125
+ const url = window.URL.createObjectURL(blob), link = window.document.createElement('a')
126
+ link.href = url; link.download = `prts-${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.prts-theme.json`
127
+ window.document.body.append(link); link.click(); link.remove()
128
+ window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
129
+ }
130
+ async function apply(value) {
131
+ validateThemePackage(value)
132
+ const preferenceRevision = checkWritable()
133
+ const before = await capture(Boolean(value.assets))
134
+ backup = before.value
135
+ download(backup, 'before-import')
136
+ const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
137
+ const write = async (kind, data, previous) => {
138
+ const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
139
+ revisions[kind] = result.revision
140
+ undo.push(async () => {
141
+ const restored = await request(kind + '/configuration', { ...previous, revision: revisions[kind] })
142
+ revisions[kind] = restored.revision
143
+ })
144
+ }
145
+ try {
146
+ if (value.assets) {
147
+ const a = value.assets, old = before.value.assets, drafts = []
148
+ // Generate every custom draft before changing the active configuration.
149
+ for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
150
+ const entry = (ref, index) => ref === null ? null : ref.kind === 'builtin' ? ref : { kind: 'custom', key: 'import-' + index + '-' + drafts[ref.asset].draftId, label: ref.label, draftId: drafts[ref.asset].draftId }
151
+ const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
152
+ for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null })
153
+ for (const role of transferRoles) await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop })
154
+ await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
155
+ }
156
+ await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
157
+ } catch (error) {
158
+ let failures = 0
159
+ for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
160
+ throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
161
+ }
162
+ }
163
+ return {
164
+ export: includeAssets => exclusive(async () => { checkExport(); return (await capture(includeAssets)).value }),
165
+ import: value => exclusive(() => apply(value)),
166
+ restore: () => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup) }),
167
+ get hasBackup() { return Boolean(backup) },
168
+ download,
169
+ }
170
+ }