@ash-qw/dsh-theme-prts 2.3.1 → 2.5.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,48 @@
1
+ const extensions = {
2
+ 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'],
3
+ 'image/avif': ['.avif'], 'image/gif': ['.gif'], 'application/json': ['.json'],
4
+ }
5
+
6
+ // Feed the existing input/change flow so picker and drop share validation and saving.
7
+ export function mountFileDrop({ area, input, enabled = () => true, onError }) {
8
+ let depth = 0
9
+ const getInput = () => typeof input === 'function' ? input() : input
10
+ const clear = () => { depth = 0; area.removeAttribute('data-prts-drop-active') }
11
+ const available = () => enabled() && !getInput()?.disabled && !area.closest('[hidden], [inert]')
12
+ const isFile = event => Array.from(event.dataTransfer?.types ?? []).includes('Files')
13
+ || Boolean(event.dataTransfer?.files?.length)
14
+ const enter = event => {
15
+ if (!isFile(event)) return
16
+ event.preventDefault(); event.stopPropagation()
17
+ depth++
18
+ if (available()) area.setAttribute('data-prts-drop-active', '')
19
+ }
20
+ const over = event => {
21
+ if (!isFile(event)) return
22
+ event.preventDefault(); event.stopPropagation()
23
+ event.dataTransfer.dropEffect = available() ? 'copy' : 'none'
24
+ if (!available()) clear()
25
+ }
26
+ const leave = event => {
27
+ if (!depth) return
28
+ event.stopPropagation()
29
+ if (--depth <= 0) clear()
30
+ }
31
+ const drop = event => {
32
+ if (!isFile(event)) return
33
+ event.preventDefault(); event.stopPropagation(); clear()
34
+ if (!available()) return
35
+ const files = event.dataTransfer.files, target = getInput()
36
+ if (files.length !== 1) { onError('一次只能添加一个文件'); return }
37
+ const file = files[0], name = file.name.toLowerCase()
38
+ const accepts = target.accept.toLowerCase().split(',').map(value => value.trim()).filter(Boolean)
39
+ const matches = accepts.some(type => type.startsWith('.') ? name.endsWith(type)
40
+ : file.type.toLowerCase() === type || (!file.type && extensions[type]?.some(ext => name.endsWith(ext))))
41
+ if (accepts.length && !matches) { onError('文件类型不支持,请选择此处支持的图片或文件'); return }
42
+ target.files = files
43
+ target.dispatchEvent(new area.ownerDocument.defaultView.Event('change', { bubbles: true }))
44
+ }
45
+ const handlers = { dragenter: enter, dragover: over, dragleave: leave, drop, dragend: clear }
46
+ for (const [name, handler] of Object.entries(handlers)) area.addEventListener(name, handler)
47
+ return { dispose() { clear(); for (const [name, handler] of Object.entries(handlers)) area.removeEventListener(name, handler) } }
48
+ }
@@ -78,7 +78,12 @@ export function createHostGeometryAdapter({ document, window }) {
78
78
  shouldMeasure = true
79
79
  }
80
80
  }
81
- if (shouldMeasure || !entries.length) scheduleMeasure()
81
+ // Keep shared wallpaper geometry in the same paint as the resized sidebar.
82
+ if (shouldMeasure || !entries.length) {
83
+ if (frameRequest !== undefined) window?.cancelAnimationFrame?.(frameRequest)
84
+ frameRequest = undefined
85
+ measure()
86
+ }
82
87
  }
83
88
 
84
89
  function stopObservation() {
@@ -28,6 +28,13 @@ export function createNativePreferences({ storage, onChange }) {
28
28
  migrationAttempted = true
29
29
  pending = { ...legacy, ...pending }
30
30
  }
31
+ // Materialize the old shared sidebar values once, so later left-side edits
32
+ // cannot change a right-side preference that was never written by the Host.
33
+ if (s.value?.preferencesInitialized && writable(s)) {
34
+ for (const key of ['rightSidebarOpacity', 'rightSidebarBlur']) {
35
+ if (!Object.hasOwn(s.value, key) && !Object.hasOwn(pending, key) && !Object.hasOwn(inFlight, key) && !Object.hasOwn(failed, key)) pending[key] = host[key]
36
+ }
37
+ }
31
38
  current = normalizePreferences({ ...host, ...inFlight, ...pending })
32
39
  if (s.value?.preferencesInitialized) {
33
40
  legacy = null
@@ -1,7 +1,8 @@
1
1
  // Ring geometry adapted from React Bits CometDial (David Haz).
2
2
  // See src/vendor/react-bits/LICENSE.md and THIRD_PARTY_NOTICES.md.
3
3
  export const BACKGROUND_OPACITY_CONTROLS = Object.freeze([
4
- { key: 'sidebarOpacity', label: '会话列表侧栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
4
+ { key: 'sidebarOpacity', label: '左侧边栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
5
+ { key: 'rightSidebarOpacity', label: '右侧边栏', property: '--prts-right-sidebar-opacity', value: 94, blurKey: 'rightSidebarBlur', blurProperty: '--prts-right-sidebar-blur', blurDefault: 8 },
5
6
  { key: 'railOpacity', label: '设置侧栏', property: '--prts-rail-opacity', value: 70, blurKey: 'railBlur', blurProperty: '--prts-rail-blur', blurDefault: 12 },
6
7
  { key: 'bakerOpacity', label: 'Baker 气泡', property: '--prts-baker-opacity', value: 50, blurKey: 'bakerBlur', blurProperty: '--prts-baker-blur', blurDefault: 8, theme: 'deck-chat' },
7
8
  { key: 'classicUserOpacity', label: '经典用户消息', property: '--prts-classic-user-opacity', value: 50, blurKey: 'classicUserBlur', blurProperty: '--prts-classic-user-blur', blurDefault: 8, theme: 'native' },
@@ -16,7 +17,8 @@ export function opacityDialMarkup() {
16
17
  const controls = BACKGROUND_OPACITY_CONTROLS.map(({key,label,value,blurDefault}) => {
17
18
  const sample = key === 'bakerOpacity' ? '<span>这是一条回复</span><i></i>'
18
19
  : key === 'classicUserOpacity' ? '<span>这是我发送的消息</span><i></i>'
19
- : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>' : '<span>工作区</span><i></i><i></i>'
20
+ : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>'
21
+ : key === 'rightSidebarOpacity' ? '<span>侧边栏</span><i></i><i></i>' : '<span>工作区</span><i></i><i></i>'
20
22
  return `<div data-prts-opacity-control="${key}">
21
23
  <div data-prts-opacity-heading><strong>${label}</strong><button type="button" data-prts-opacity-reset data-prts-opacity-reset-tooltip="恢复到 ${value}%" aria-label="恢复${label}透明度到 ${value}%"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
22
24
  <div data-prts-opacity-dial><svg viewBox="0 0 200 200" role="slider" tabindex="0" aria-label="${label}透明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${value}" aria-valuetext="${value}%"><circle data-dial-focus cx="100" cy="100" r="97"/><path data-dial-track d="${dialArc(110,430)}"/><path data-dial-lit/><path data-dial-tail/><circle data-dial-head r="8"/></svg><label data-prts-opacity-number><input type="number" min="0" max="100" step="1" value="${value}" aria-label="${label}透明度百分比"><span>%</span></label></div>
@@ -24,8 +26,8 @@ export function opacityDialMarkup() {
24
26
  <div data-prts-blur-control hidden><label><span>模糊</span><input type="range" min="0" max="32" step="1" value="${blurDefault}" aria-label="${label}模糊度"></label><output>${blurDefault}px</output><button type="button" data-prts-blur-reset title="恢复到 ${blurDefault}px" aria-label="恢复${label}模糊度到 ${blurDefault}px"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
25
27
  <small data-prts-opacity-note></small>
26
28
  </div>`
27
- }).join('')
28
- return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls}</div></fieldset>`
29
+ })
30
+ return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls.join('')}</div></fieldset>`
29
31
  }
30
32
  export function mountOpacityDials({ section, window, onChange }) {
31
33
  const root = section.ownerDocument.documentElement
@@ -108,14 +110,14 @@ export function mountOpacityDials({ section, window, onChange }) {
108
110
  for(const [el,type,fn] of bindings)el.addEventListener(type,fn)
109
111
  paint(value)
110
112
  return {
111
- update(preferences, active) {
113
+ update(preferences, active, inactiveNote) {
112
114
  container.hidden = Boolean(definition.theme && preferences.conversationStyle !== definition.theme)
113
115
  saved = 100 - Math.round((preferences[definition.key] ?? (100 - definition.value) / 100) * 100)
114
116
  if (!grip && section.ownerDocument.activeElement !== input) paint(saved)
115
117
  savedBlur = preferences[definition.blurKey] ?? definition.blurDefault
116
118
  if (section.ownerDocument.activeElement !== blurInput) paintBlur(savedBlur)
117
119
  blurControl.hidden = !active
118
- container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : '当前未启用此材质'
120
+ container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : inactiveNote
119
121
  },
120
122
  dispose() { if(grip)finish({},true);window.clearTimeout(timer);for(const [el,type,fn] of bindings)el.removeEventListener(type,fn) },
121
123
  }
@@ -127,10 +129,13 @@ export function mountOpacityDials({ section, window, onChange }) {
127
129
  section.querySelector('[data-prts-opacity-settings]').hidden = preferences.backgroundMode === 'off'
128
130
  const conversationImage = full || (split && Boolean(state.conversation))
129
131
  const sidebarImage = full || (split && Boolean(state.sidebar))
130
- const active = [sidebarImage, sidebarImage,
132
+ const active = [sidebarImage || conversationImage, conversationImage, sidebarImage,
131
133
  conversationImage && preferences.conversationStyle === 'deck-chat',
132
134
  conversationImage && preferences.conversationStyle === 'native']
133
- controls.forEach((control,i)=>control.update(preferences,active[i]))
135
+ const missingImage = full || !split ? '上传完整背景图片后生效' : null
136
+ const inactiveNotes = [missingImage || '上传左侧边栏或正文图片后生效', missingImage || '上传正文图片后生效',
137
+ missingImage || '上传左侧边栏图片后生效', missingImage || '上传正文图片后生效', missingImage || '上传正文图片后生效']
138
+ controls.forEach((control,i)=>control.update(preferences,active[i],inactiveNotes[i]))
134
139
  },
135
140
  dispose() {for(const control of controls)control.dispose()},
136
141
  }
@@ -29,10 +29,12 @@ export const DEFAULT_PREFERENCES = Object.freeze({
29
29
  backgroundDim: .45,
30
30
  heroContrast: true,
31
31
  sidebarOpacity: .06,
32
+ rightSidebarOpacity: .06,
32
33
  railOpacity: .3,
33
34
  bakerOpacity: .5,
34
35
  classicUserOpacity: .5,
35
36
  sidebarBlur: 8,
37
+ rightSidebarBlur: 8,
36
38
  railBlur: 12,
37
39
  bakerBlur: 8,
38
40
  classicUserBlur: 8,
@@ -100,7 +102,7 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
100
102
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
101
103
  })
102
104
  export const PREFERENCE_GROUPS = Object.freeze({
103
- background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
105
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'rightSidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'rightSidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
104
106
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
105
107
  material: Object.freeze(['glass', 'composerGlass']),
106
108
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
@@ -214,10 +216,12 @@ export function normalizePreferences(value) {
214
216
  bakerLoading: input.bakerLoading === true,
215
217
  backgroundMode: ['off', 'split', 'full'].includes(input.backgroundMode) ? input.backgroundMode : 'off',
216
218
  sidebarOpacity: typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
219
+ rightSidebarOpacity: typeof input.rightSidebarOpacity === 'number' && Number.isFinite(input.rightSidebarOpacity) ? Math.min(1, Math.max(0, input.rightSidebarOpacity)) : typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
217
220
  railOpacity: typeof input.railOpacity === 'number' && Number.isFinite(input.railOpacity) ? Math.min(1, Math.max(0, input.railOpacity)) : .3,
218
221
  bakerOpacity: typeof input.bakerOpacity === 'number' && Number.isFinite(input.bakerOpacity) ? Math.min(1, Math.max(0, input.bakerOpacity)) : .5,
219
222
  classicUserOpacity: typeof input.classicUserOpacity === 'number' && Number.isFinite(input.classicUserOpacity) ? Math.min(1, Math.max(0, input.classicUserOpacity)) : .5,
220
223
  sidebarBlur: materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur),
224
+ rightSidebarBlur: materialBlurOr(input.rightSidebarBlur, materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur)),
221
225
  railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
222
226
  bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
223
227
  classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
@@ -771,8 +771,10 @@ export function createThemeController({ document, window, cssText, service, curs
771
771
  root.dataset.prtsBackgroundMode = preferences.backgroundMode
772
772
  root.style.setProperty('--prts-background-shade', `${preferences.backgroundDim * 100}%`)
773
773
  for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-opacity`, `${preferences[`${name}Opacity`] * 100}%`)
774
+ root.style.setProperty('--prts-right-sidebar-opacity', `${preferences.rightSidebarOpacity * 100}%`)
774
775
  root.style.setProperty('--prts-classic-user-opacity', `${preferences.classicUserOpacity * 100}%`)
775
776
  for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-blur`, `${preferences[`${name}Blur`]}px`)
777
+ root.style.setProperty('--prts-right-sidebar-blur', `${preferences.rightSidebarBlur}px`)
776
778
  root.style.setProperty('--prts-classic-user-blur', `${preferences.classicUserBlur}px`)
777
779
  refresh()
778
780
  },
@@ -1,29 +1,33 @@
1
- import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
1
+ import { mountFileDrop } from './file-drop.js'
2
+ import { checkTransferCancelled, readTransferFile, THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage, ignoredThemePackageFields } from './theme-transfer.js'
2
3
 
3
4
  export function themeTransferMarkup() {
4
5
  return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
5
6
  <section data-prts-transfer>
6
7
  <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
8
  <div data-prts-transfer-columns>
8
- <section data-prts-transfer-card>
9
+ <section data-prts-transfer-card="export">
9
10
  <h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
10
11
  <label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
11
12
  <small>取消勾选,仅导出主题设置。</small>
12
13
  <button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
13
14
  </section>
14
- <section data-prts-transfer-card>
15
- <h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
15
+ <section data-prts-transfer-card="import" data-prts-transfer-drop>
16
+ <h3>导入主题</h3><p>选择或拖入已导出的文件,确认内容后应用。</p>
16
17
  <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
+ <button type="button" data-prts-transfer-choose>选择主题文件 <span aria-hidden="true">↑</span></button>
18
19
  <input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
19
20
  </section>
20
21
  </div>
21
22
  <div data-prts-transfer-preview hidden>
22
23
  <strong>确认导入</strong><p data-prts-transfer-summary></p>
24
+ <p data-prts-transfer-compatibility hidden role="alert"></p>
23
25
  <small>应用前会下载当前配置备份,请保留备份文件。</small>
24
26
  <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
25
27
  </div>
26
28
  <div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
29
+ <div data-prts-transfer-progress hidden><div><span data-prts-transfer-phase></span><span data-prts-transfer-percent></span></div><progress data-prts-transfer-bar max="1" aria-label="主题文件处理进度"></progress></div>
30
+ <button type="button" data-prts-transfer-stop hidden>取消操作</button>
27
31
  <p data-prts-transfer-status role="status" aria-live="polite"></p>
28
32
  </section>
29
33
  </dialog>`
@@ -39,60 +43,98 @@ export function mountThemeTransfer({ panel, window, transfer }) {
39
43
  const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
40
44
  const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
41
45
  const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
42
- let pending, busy = false, disposed = false, generation = 0
46
+ let choosing = false, activeOperation, pending, busy = false, disposed = false, generation = 0, controller, cancellable = true
43
47
  const render = () => {
44
48
  if (disposed) return
49
+ for (const card of section.querySelectorAll('[data-prts-transfer-card]')) card.toggleAttribute('data-prts-transfer-active', card.dataset.prtsTransferCard === (busy ? activeOperation : pending || choosing ? 'import' : undefined))
45
50
  get('preview').hidden = !pending
51
+ if (!pending) get('compatibility').hidden = true
46
52
  get('recovery').hidden = !transfer.hasBackup
47
53
  for (const control of section.querySelectorAll('button,input')) control.disabled = busy
54
+ get('stop').hidden = !busy
55
+ get('stop').disabled = !busy || !cancellable || controller?.signal.aborted
48
56
  // Prevent local editors from racing a multi-service import; other clients use revision checks.
49
57
  for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
50
58
  }
51
- async function run(task) {
59
+ const progress = ({ value, label, cancellable: allowed }) => {
60
+ if (disposed) return
61
+ if (allowed === false) { cancellable = false; render() }
62
+ get('progress').hidden = false
63
+ get('phase').textContent = label
64
+ get('percent').textContent = value === null ? '' : `${Math.round(value * 100)}%`
65
+ if (value === null) get('bar').removeAttribute('value')
66
+ else get('bar').value = value
67
+ }
68
+ const paint = () => new Promise(resolve => window.setTimeout(resolve, 0))
69
+ async function run(kind, task) {
52
70
  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() }
71
+ controller = new window.AbortController(); cancellable = true
72
+ activeOperation = kind
73
+ busy = true; section.setAttribute('aria-busy', 'true'); get('status').textContent = ''; get('progress').hidden = true; render()
74
+ progress({ value: null, label: '正在准备…' })
75
+ try {
76
+ await paint()
77
+ checkTransferCancelled(controller.signal)
78
+ const message = await task(controller.signal)
79
+ if (!disposed) { progress({ value: 1, label: '处理完成' }); get('status').textContent = message }
80
+ }
81
+ catch (error) { if (!disposed) { get('progress').hidden = true; get('status').textContent = error.name === 'AbortError' ? '操作已取消。' : error.message } }
82
+ finally { busy = false; if (!pending && !disposed) get('file').value = ''; if (!disposed) section.setAttribute('aria-busy', 'false'); render() }
57
83
  }
58
84
  const change = event => {
59
85
  if (event.target !== get('file')) return
86
+ choosing = false
60
87
  const file = event.target.files?.[0], selected = ++generation
61
88
  pending = undefined; render()
62
89
  if (!file) return
63
- void run(async () => {
90
+ void run('import', async signal => {
64
91
  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
- })
92
+ const text = await readTransferFile(window, file, signal, event => progress({ value: event.lengthComputable && event.total ? event.loaded / event.total * 0.8 : null, label: '正在读取主题文件…' }))
93
+ progress({ value: 0.8, label: '正在解析并检查主题文件…' })
94
+ await paint()
95
+ checkTransferCancelled(signal)
69
96
  let value
70
97
  try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
71
98
  validateThemePackage(value)
72
99
  if (disposed || selected !== generation) return ''
73
100
  pending = value
74
101
  get('summary').textContent = describeThemePackage(value)
102
+ const ignored = ignoredThemePackageFields(value)
103
+ const compatibility = get('compatibility')
104
+ compatibility.hidden = ignored.length === 0
105
+ compatibility.textContent = ignored.length
106
+ ? `当前版本不支持以下配置,导入后将丢失:${ignored.join('、')}。请保留原文件。`
107
+ : ''
75
108
  return '已读取文件,请确认导入内容。'
76
109
  })
77
110
  }
78
111
  const click = event => {
79
112
  const button = event.target.closest?.('button')
113
+ if (button === get('stop') && busy && cancellable) {
114
+ controller.abort(); progress({ value: null, label: '正在取消,请稍候…', cancellable: false }); return
115
+ }
80
116
  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)
117
+ if (button === get('choose')) { choosing = true; render(); get('file').click() }
118
+ if (button === get('export')) void run('export', async signal => {
119
+ const value = await transfer.export(get('assets').checked, state => progress({ ...state, value: state.value * 0.9 }), signal)
120
+ progress({ value: 0.9, label: '正在生成下载文件…' })
121
+ await paint()
122
+ checkTransferCancelled(signal)
84
123
  transfer.download(value)
85
124
  return '已发起下载,请确认主题文件已保存。'
86
125
  })
87
- if (button === get('apply') && pending) void run(async () => {
88
- await transfer.import(pending); pending = undefined; get('file').value = ''
126
+ if (button === get('apply') && pending) void run('import', async signal => {
127
+ await transfer.import(pending, progress, signal); pending = undefined; get('file').value = ''
89
128
  return '主题已导入并保存。'
90
129
  })
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 '已恢复导入前配置。' })
130
+ if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; get('progress').hidden = true; render() }
131
+ if (button === get('restore')) void run('import', async signal => { await transfer.restore(progress, signal); return '已恢复导入前配置。' })
93
132
  }
133
+ const drop = mountFileDrop({ area: get('drop'), input: get('file'), enabled: () => !busy && !disposed, onError: message => { get('status').textContent = message } })
134
+ const cancelChoice = () => { choosing = false; render() }
135
+ get('file').addEventListener('cancel', cancelChoice)
94
136
  trigger?.addEventListener('click', open)
95
137
  dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
96
138
  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) } }
139
+ return { dispose() { get('file').removeEventListener('cancel', cancelChoice); drop.dispose(); disposed = true; controller?.abort(); 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
140
  }
@@ -1,12 +1,58 @@
1
1
  import { validBackgroundRegion } from '../shared/background-region.js'
2
2
  import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
3
3
 
4
+ export function checkTransferCancelled(signal) {
5
+ if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
6
+ }
7
+
8
+ export function readTransferFile(window, blob, signal, onProgress, asImage = false) {
9
+ return new Promise((resolve, reject) => {
10
+ checkTransferCancelled(signal)
11
+ const reader = new window.FileReader()
12
+ const cleanup = () => signal?.removeEventListener('abort', abort)
13
+ const abort = () => { reader.abort(); cleanup(); reject(Object.assign(new Error('操作已取消'), { name: 'AbortError' })) }
14
+ reader.onload = () => { cleanup(); resolve(asImage ? String(reader.result).split(',')[1] : reader.result) }
15
+ reader.onerror = () => { cleanup(); reject(Error('无法读取主题文件或图片')) }
16
+ reader.onprogress = onProgress
17
+ signal?.addEventListener('abort', abort, { once: true })
18
+ if (asImage) reader.readAsDataURL(blob)
19
+ else reader.readAsText(blob)
20
+ })
21
+ }
22
+
4
23
  export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
5
24
  const transferSlots = ['full', 'sidebar', 'conversation']
6
25
  const transferRoles = ['user', 'assistant']
7
26
  const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
8
27
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
9
28
  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)
29
+ const unknownFields = (value, known, prefix) => record(value)
30
+ ? Object.keys(value).filter(key => !known.includes(key)).map(key => `${prefix}.${key}`)
31
+ : []
32
+
33
+ export function ignoredThemePackageFields(value) {
34
+ const ignored = unknownFields(value.preferences, Object.keys(DEFAULT_PREFERENCES), '设置')
35
+ const assets = value.assets
36
+ if (!assets) return ignored
37
+ ignored.push(...unknownFields(assets, ['backgrounds', 'backgroundRegions', 'avatars', 'emblems'], '素材'))
38
+ ignored.push(...unknownFields(assets.backgrounds, transferSlots, '背景'))
39
+ ignored.push(...unknownFields(assets.backgroundRegions, transferSlots, '背景保护区域'))
40
+ for (const slot of transferSlots) ignored.push(...unknownFields(assets.backgroundRegions?.[slot], ['x', 'y', 'width', 'height'], `背景保护区域.${slot}`))
41
+ ignored.push(...unknownFields(assets.avatars, transferRoles, '头像'))
42
+ for (const role of transferRoles) {
43
+ ignored.push(...unknownFields(assets.avatars?.[role], ['image', 'crop'], `头像.${role}`))
44
+ ignored.push(...unknownFields(assets.avatars?.[role]?.crop, ['zoom', 'x', 'y'], `头像.${role}.crop`))
45
+ }
46
+ const emblems = assets.emblems
47
+ ignored.push(...unknownFields(emblems, ['items', 'hero', 'conversation'], '徽记'))
48
+ for (const [index, item] of (emblems?.items ?? []).entries()) {
49
+ ignored.push(...unknownFields(item, ['format', 'version', 'label', 'params', 'image'], `徽记.items[${index}]`))
50
+ ignored.push(...unknownFields(item?.params, ['mode', 'lineSource', 'cleanup', 'threshold', 'zoom', 'x', 'y'], `徽记.items[${index}].params`))
51
+ }
52
+ ignored.push(...unknownFields(emblems?.hero, ['kind', 'key', 'label', 'asset'], '徽记.hero'))
53
+ for (const [index, entry] of (emblems?.conversation ?? []).entries()) ignored.push(...unknownFields(entry, ['kind', 'key', 'label', 'asset'], `徽记.conversation[${index}]`))
54
+ return ignored
55
+ }
10
56
 
11
57
  export function validateThemePackage(value) {
12
58
  if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
@@ -57,61 +103,64 @@ export function describeThemePackage(value) {
57
103
 
58
104
  export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
59
105
  let busy = false, backup
60
- const request = async (path, data) => {
61
- const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin',
106
+ const request = async (path, data, signal) => {
107
+ checkTransferCancelled(signal)
108
+ const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin', signal,
62
109
  ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
63
110
  const result = await response.json()
64
111
  if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
65
112
  return result
66
113
  }
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) {
114
+ async function capture(includeAssets, progress = () => {}, signal) {
115
+ checkTransferCancelled(signal)
116
+ progress({ value: 0, label: '正在读取主题设置…' })
74
117
  const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
75
118
  const states = {}
76
119
  if (includeAssets) {
77
120
  const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
78
- states.backgrounds = await request('backgrounds/configuration')
121
+ progress({ value: 0.1, label: '正在读取背景…' })
122
+ states.backgrounds = await request('backgrounds/configuration', undefined, signal)
79
123
  for (const slot of transferSlots) {
80
124
  const entry = states.backgrounds[slot]
81
125
  backgroundRegions[slot] = entry?.region ?? null
82
126
  backgrounds[slot] = null
83
127
  if (entry) {
84
128
  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' })
129
+ const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin', signal })
86
130
  if (!response.ok) throw Error('无法导出背景图片')
87
- backgrounds[slot] = await readImage(await response.blob())
131
+ backgrounds[slot] = await readTransferFile(window, await response.blob(), signal, undefined, true)
88
132
  }
89
133
  }
90
- states.avatars = await request('avatars/configuration')
134
+ progress({ value: 0.35, label: '正在读取头像…' })
135
+ states.avatars = await request('avatars/configuration', undefined, signal)
91
136
  for (const role of transferRoles) {
92
- const source = await request('avatars/edit/' + role)
137
+ const source = await request('avatars/edit/' + role, undefined, signal)
93
138
  if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
94
139
  avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
95
140
  }
96
- states.emblems = await request('emblems/configuration')
141
+ progress({ value: 0.5, label: '正在读取徽记…' })
142
+ states.emblems = await request('emblems/configuration', undefined, signal)
97
143
  async function reference(entry) {
98
144
  if (!entry) return null
99
145
  if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
100
146
  if (!ids.has(entry.draftId)) {
101
147
  ids.set(entry.draftId, items.length)
102
- items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
148
+ items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export', undefined, signal))
103
149
  }
104
150
  return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
105
151
  }
106
152
  const hero = await reference(states.emblems.configuration.hero), conversation = []
107
153
  for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
108
154
  value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
155
+ progress({ value: 0.85, label: '正在核对素材…' })
109
156
  for (const kind of ['backgrounds', 'avatars', 'emblems']) {
110
- if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
157
+ if ((await request(kind + '/configuration', undefined, signal)).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
111
158
  }
112
159
  }
113
160
  if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
161
+ checkTransferCancelled(signal)
114
162
  validateThemePackage(value)
163
+ progress({ value: 1, label: '主题数据已就绪' })
115
164
  return { value, states }
116
165
  }
117
166
  async function exclusive(fn) {
@@ -127,14 +176,23 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
127
176
  window.document.body.append(link); link.click(); link.remove()
128
177
  window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
129
178
  }
130
- async function apply(value) {
179
+ async function apply(value, progress = () => {}, signal) {
180
+ checkTransferCancelled(signal)
131
181
  validateThemePackage(value)
132
182
  const preferenceRevision = checkWritable()
133
- const before = await capture(Boolean(value.assets))
183
+ const before = await capture(Boolean(value.assets), state => progress({ value: state.value * 0.3, label: '备份:' + state.label }), signal)
134
184
  backup = before.value
185
+ progress({ value: 0.3, label: '正在生成导入前备份…' })
186
+ await new Promise(resolve => window.setTimeout(resolve, 0))
187
+ checkTransferCancelled(signal)
135
188
  download(backup, 'before-import')
189
+ let completed = 0
190
+ const total = value.assets ? value.assets.emblems.items.length + 7 : 1
191
+ const step = label => progress({ value: 0.35 + 0.65 * completed / total, label })
136
192
  const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
137
193
  const write = async (kind, data, previous) => {
194
+ checkTransferCancelled(signal)
195
+ // Await writes without aborting: their confirmed revision is needed for rollback.
138
196
  const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
139
197
  revisions[kind] = result.revision
140
198
  undo.push(async () => {
@@ -146,24 +204,35 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
146
204
  if (value.assets) {
147
205
  const a = value.assets, old = before.value.assets, drafts = []
148
206
  // Generate every custom draft before changing the active configuration.
149
- for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
207
+ for (const item of a.emblems.items) {
208
+ step('正在导入徽记素材…')
209
+ checkTransferCancelled(signal)
210
+ drafts.push(await request('emblems/import', item)); completed++
211
+ }
150
212
  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
213
  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 })
214
+ for (const slot of transferSlots) { step('正在应用背景…'); await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null }); completed++ }
215
+ for (const role of transferRoles) { step('正在应用头像…'); 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 }); completed++ }
216
+ step('正在应用徽记排列…')
217
+ await write('emblems', { configuration }, { configuration: before.states.emblems.configuration }); completed++
155
218
  }
219
+ step('正在保存主题设置…')
220
+ checkTransferCancelled(signal)
221
+ progress({ value: 0.99, label: '正在保存主题设置…', cancellable: false })
156
222
  await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
223
+ progress({ value: 1, label: '主题已保存' })
157
224
  } catch (error) {
225
+ progress({ value: null, label: signal?.aborted ? '正在取消,回退素材…' : '导入失败,正在回退素材…', cancellable: false })
158
226
  let failures = 0
159
227
  for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
228
+ if (signal?.aborted && !failures && error.name === 'AbortError') throw Error('操作已取消,已回退已写入的素材。')
160
229
  throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
161
230
  }
162
231
  }
163
232
  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) }),
233
+ export: (includeAssets, progress, signal) => exclusive(async () => { checkExport(); return (await capture(includeAssets, progress, signal)).value }),
234
+ import: (value, progress, signal) => exclusive(() => apply(value, progress, signal)),
235
+ restore: (progress, signal) => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup, progress, signal) }),
167
236
  get hasBackup() { return Boolean(backup) },
168
237
  download,
169
238
  }