@qwqweeo123/dsh-wallpaper 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/README.zh.md +35 -0
- package/client.js +1902 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/index.js +606 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +57 -0
package/client.js
ADDED
|
@@ -0,0 +1,1902 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of @local/dsh-wallpaper.
|
|
3
|
+
*
|
|
4
|
+
* Applies a global wallpaper to the shell frame (frame-transparent +
|
|
5
|
+
* translucent surfaces, the same model as codeg's workspace background) and
|
|
6
|
+
* owns the Settings › 壁纸 page: enable/opacity, the current wallpaper, local
|
|
7
|
+
* uploads, and the wallhaven.cc marketplace rendered inline. All network and
|
|
8
|
+
* disk access goes through the bundle's Host routes under
|
|
9
|
+
* `/dsh-wallpaper/api/*`; this file never touches the upstream CDN directly.
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: '@local/dsh-wallpaper',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
const { useCallback, useEffect, useRef, useState, useSyncExternalStore } = React
|
|
16
|
+
const h = React.createElement
|
|
17
|
+
|
|
18
|
+
const NS = 'wallpaper'
|
|
19
|
+
const API = '/dsh-wallpaper'
|
|
20
|
+
// DSH's client module system claims every untagged <style> for the next
|
|
21
|
+
// activating plugin and deletes it with that entry — which wipes this
|
|
22
|
+
// plugin's CSS. Tagging the element keeps it ours.
|
|
23
|
+
const STYLE_ATTRS = { 'data-plugin': '@local/dsh-wallpaper' }
|
|
24
|
+
|
|
25
|
+
// ─── Locale dictionaries (flat dotted keys, {name} interpolation) ───────
|
|
26
|
+
|
|
27
|
+
const zh = {
|
|
28
|
+
'wallpaper.title': '壁纸设置',
|
|
29
|
+
'wallpaper.enable': '启用壁纸',
|
|
30
|
+
'wallpaper.opacity': '面板不透明度',
|
|
31
|
+
'wallpaper.fill': '填充方式',
|
|
32
|
+
'wallpaper.fill.cover': '覆盖',
|
|
33
|
+
'wallpaper.fill.contain': '包含',
|
|
34
|
+
'wallpaper.fill.center': '居中',
|
|
35
|
+
'wallpaper.fill.tile': '平铺',
|
|
36
|
+
'wallpaper.mask': '遮罩不透明度',
|
|
37
|
+
'wallpaper.blur': '图片模糊',
|
|
38
|
+
'wallpaper.panel': '面板不透明度',
|
|
39
|
+
'settings.fillHint': '图片铺满窗口的方式:覆盖裁切、包含留白、居中原始大小、平铺重复。',
|
|
40
|
+
'settings.maskHint': '数值越高,图片越向主题背景色淡化,文字对比越清晰。',
|
|
41
|
+
'settings.blurHint': '模糊背景图片本身(0 表示不模糊),不影响面板与文字。',
|
|
42
|
+
'settings.panelHint': '侧栏、面板与标签栏的不透明程度。越低,透出的图片越多。',
|
|
43
|
+
'wallpaper.upload': '上传本地图片…',
|
|
44
|
+
'wallpaper.remove': '移除壁纸',
|
|
45
|
+
'wallpaper.searchPlaceholder': '搜索壁纸…',
|
|
46
|
+
'wallpaper.applied': '使用中',
|
|
47
|
+
'wallpaper.unavailable': '不可用',
|
|
48
|
+
'wallpaper.appliedToast': '壁纸已应用',
|
|
49
|
+
'wallpaper.downloadFailed': '下载失败',
|
|
50
|
+
'wallpaper.tooLarge': '超过上限({actual} / 上限 {limit})',
|
|
51
|
+
'wallpaper.empty': '没有找到壁纸',
|
|
52
|
+
'wallpaper.pageInfo': '第 {page} / {lastPage} 页',
|
|
53
|
+
'wallpaper.prevPage': '上一页',
|
|
54
|
+
'wallpaper.nextPage': '下一页',
|
|
55
|
+
'wallpaper.refresh': '刷新',
|
|
56
|
+
'wallpaper.credit': '来自 wallhaven.cc',
|
|
57
|
+
'wallpaper.cardLabel': '壁纸 {id}',
|
|
58
|
+
'wallpaper.categories.all': '全部',
|
|
59
|
+
'wallpaper.categories.general': '常规',
|
|
60
|
+
'wallpaper.categories.anime': '动漫',
|
|
61
|
+
'wallpaper.categories.people': '人物',
|
|
62
|
+
'wallpaper.market': '壁纸市场',
|
|
63
|
+
'wallpaper.marketHint': '点击卡片即可下载并应用为全局背景',
|
|
64
|
+
'wallpaper.current': '当前壁纸',
|
|
65
|
+
'wallpaper.none': '尚未设置壁纸',
|
|
66
|
+
'wallpaper.localImage': '本地图片',
|
|
67
|
+
'settings.nav': '壁纸',
|
|
68
|
+
'settings.wallpaper': '壁纸',
|
|
69
|
+
'settings.wallpaperHint': '为整个界面应用全局背景',
|
|
70
|
+
'settings.opacityHint': '面板在壁纸上方的不透明度,越低壁纸越明显',
|
|
71
|
+
'settings.enabledHint': '关闭后界面恢复默认背景,壁纸文件保留',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const en = {
|
|
75
|
+
'wallpaper.title': 'Wallpaper',
|
|
76
|
+
'wallpaper.enable': 'Enable wallpaper',
|
|
77
|
+
'wallpaper.opacity': 'Panel opacity',
|
|
78
|
+
'wallpaper.fill': 'Fill mode',
|
|
79
|
+
'wallpaper.fill.cover': 'Cover',
|
|
80
|
+
'wallpaper.fill.contain': 'Contain',
|
|
81
|
+
'wallpaper.fill.center': 'Center',
|
|
82
|
+
'wallpaper.fill.tile': 'Tile',
|
|
83
|
+
'wallpaper.mask': 'Mask opacity',
|
|
84
|
+
'wallpaper.blur': 'Image blur',
|
|
85
|
+
'wallpaper.panel': 'Panel opacity',
|
|
86
|
+
'settings.fillHint': 'How the image fills the window: cover crops, contain letterboxes, center keeps its size, tile repeats.',
|
|
87
|
+
'settings.maskHint': 'Higher values fade the image toward the theme background and keep text contrast.',
|
|
88
|
+
'settings.blurHint': 'Blurs the background image itself (0 = sharp). Panels and text are untouched.',
|
|
89
|
+
'settings.panelHint': 'Opacity of the sidebar, panels and tab strips. Lower values reveal more of the image.',
|
|
90
|
+
'wallpaper.upload': 'Upload a local image…',
|
|
91
|
+
'wallpaper.remove': 'Remove wallpaper',
|
|
92
|
+
'wallpaper.searchPlaceholder': 'Search wallpapers…',
|
|
93
|
+
'wallpaper.applied': 'In use',
|
|
94
|
+
'wallpaper.unavailable': 'Unavailable',
|
|
95
|
+
'wallpaper.appliedToast': 'Wallpaper applied',
|
|
96
|
+
'wallpaper.downloadFailed': 'Download failed',
|
|
97
|
+
'wallpaper.tooLarge': 'Over the limit ({actual} / limit {limit})',
|
|
98
|
+
'wallpaper.empty': 'No wallpapers found',
|
|
99
|
+
'wallpaper.pageInfo': 'Page {page} of {lastPage}',
|
|
100
|
+
'wallpaper.prevPage': 'Prev',
|
|
101
|
+
'wallpaper.nextPage': 'Next',
|
|
102
|
+
'wallpaper.refresh': 'Refresh',
|
|
103
|
+
'wallpaper.credit': 'from wallhaven.cc',
|
|
104
|
+
'wallpaper.cardLabel': 'Wallpaper {id}',
|
|
105
|
+
'wallpaper.categories.all': 'All',
|
|
106
|
+
'wallpaper.categories.general': 'General',
|
|
107
|
+
'wallpaper.categories.anime': 'Anime',
|
|
108
|
+
'wallpaper.categories.people': 'People',
|
|
109
|
+
'wallpaper.market': 'Wallpaper marketplace',
|
|
110
|
+
'wallpaper.marketHint': 'Click a card to download and apply it as the global background',
|
|
111
|
+
'wallpaper.current': 'Current wallpaper',
|
|
112
|
+
'wallpaper.none': 'No wallpaper set',
|
|
113
|
+
'wallpaper.localImage': 'Local image',
|
|
114
|
+
'settings.nav': 'Wallpaper',
|
|
115
|
+
'settings.wallpaper': 'Wallpaper',
|
|
116
|
+
'settings.wallpaperHint': 'Apply a global background to the whole interface',
|
|
117
|
+
'settings.opacityHint': 'Opacity of panels above the wallpaper; lower shows more of it',
|
|
118
|
+
'settings.enabledHint': 'Turning this off restores the default background; the file stays',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Store ──────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function createStore() {
|
|
124
|
+
const listeners = new Set()
|
|
125
|
+
const state = {
|
|
126
|
+
loaded: false,
|
|
127
|
+
config: { wallpaperEnabled: false, wallpaperOpacity: 0.85, wallpaperSourceUrl: null },
|
|
128
|
+
wallpaperVersion: 'none',
|
|
129
|
+
/** Fill mode / mask / blur / panel opacity — shared by layer + section. */
|
|
130
|
+
display: readWallpaperSettings(),
|
|
131
|
+
}
|
|
132
|
+
let snapshot = { ...state }
|
|
133
|
+
const emit = () => {
|
|
134
|
+
snapshot = { ...state }
|
|
135
|
+
for (const fn of listeners) fn()
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
subscribe(fn) {
|
|
139
|
+
listeners.add(fn)
|
|
140
|
+
return () => listeners.delete(fn)
|
|
141
|
+
},
|
|
142
|
+
getSnapshot() {
|
|
143
|
+
return snapshot
|
|
144
|
+
},
|
|
145
|
+
setLoaded(config) {
|
|
146
|
+
state.loaded = true
|
|
147
|
+
state.config = config
|
|
148
|
+
emit()
|
|
149
|
+
},
|
|
150
|
+
setConfig(config) {
|
|
151
|
+
state.config = config
|
|
152
|
+
emit()
|
|
153
|
+
},
|
|
154
|
+
setWallpaperVersion(version) {
|
|
155
|
+
if (state.wallpaperVersion !== version) {
|
|
156
|
+
state.wallpaperVersion = version
|
|
157
|
+
emit()
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
setDisplay(patch) {
|
|
161
|
+
const next = { ...state.display, ...patch }
|
|
162
|
+
state.display = next
|
|
163
|
+
try {
|
|
164
|
+
window.localStorage.setItem(WP_SETTINGS_KEY, JSON.stringify(next))
|
|
165
|
+
} catch {
|
|
166
|
+
/* private mode: settings simply do not persist */
|
|
167
|
+
}
|
|
168
|
+
emit()
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function useStore(store, selector) {
|
|
174
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
175
|
+
return selector(snapshot)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── API helpers ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
async function apiJson(path, options = {}) {
|
|
181
|
+
const res = await fetch(`${API}${path}`, {
|
|
182
|
+
headers: options.body ? { 'content-type': 'application/json' } : undefined,
|
|
183
|
+
...options,
|
|
184
|
+
})
|
|
185
|
+
const data = await res.json().catch(() => null)
|
|
186
|
+
if (!res.ok || !data || data.ok !== true) {
|
|
187
|
+
const message = data?.error?.message || `HTTP ${res.status}`
|
|
188
|
+
const err = new Error(message)
|
|
189
|
+
err.code = data?.error?.code
|
|
190
|
+
throw err
|
|
191
|
+
}
|
|
192
|
+
return data
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function apiBlobUrl(path) {
|
|
196
|
+
const res = await fetch(`${API}${path}`)
|
|
197
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
198
|
+
const blob = await res.blob()
|
|
199
|
+
return URL.createObjectURL(blob)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Per-page cache of proxied thumbnail blob URLs. */
|
|
203
|
+
function createAssetCache() {
|
|
204
|
+
const cache = new Map()
|
|
205
|
+
return {
|
|
206
|
+
load(url) {
|
|
207
|
+
let entry = cache.get(url)
|
|
208
|
+
if (!entry) {
|
|
209
|
+
entry = apiBlobUrl(url).catch((err) => {
|
|
210
|
+
cache.delete(url)
|
|
211
|
+
throw err
|
|
212
|
+
})
|
|
213
|
+
cache.set(url, entry)
|
|
214
|
+
}
|
|
215
|
+
return entry
|
|
216
|
+
},
|
|
217
|
+
dispose() {
|
|
218
|
+
for (const promise of cache.values()) {
|
|
219
|
+
promise.then((url) => URL.revokeObjectURL(url)).catch(() => {})
|
|
220
|
+
}
|
|
221
|
+
cache.clear()
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function useProxiedAsset(cache, apiPath) {
|
|
227
|
+
const [state, setState] = useState({ src: null, failed: false, key: null })
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (!apiPath) return
|
|
230
|
+
let cancelled = false
|
|
231
|
+
cache
|
|
232
|
+
.load(apiPath)
|
|
233
|
+
.then((url) => {
|
|
234
|
+
if (!cancelled) setState({ src: url, failed: false, key: apiPath })
|
|
235
|
+
})
|
|
236
|
+
.catch(() => {
|
|
237
|
+
if (!cancelled) setState({ src: null, failed: true, key: apiPath })
|
|
238
|
+
})
|
|
239
|
+
return () => {
|
|
240
|
+
cancelled = true
|
|
241
|
+
}
|
|
242
|
+
}, [apiPath, cache])
|
|
243
|
+
if (!apiPath) return { src: null, loading: false, failed: false }
|
|
244
|
+
if (state.key === apiPath) return { src: state.src, loading: false, failed: state.failed }
|
|
245
|
+
return { src: null, loading: true, failed: false }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Shared UI atoms (theme tokens only) ────────────────────────────────
|
|
249
|
+
|
|
250
|
+
const styles = `
|
|
251
|
+
.dshwp-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-button-tool-bar-fill); color: var(--dsw-alias-label-primary); font-size: 12px; cursor: pointer; }
|
|
252
|
+
.dshwp-btn:hover { background: var(--dsw-alias-button-tool-bar-hover); }
|
|
253
|
+
.dshwp-btn:disabled { opacity: 0.5; cursor: default; }
|
|
254
|
+
.dshwp-btn-primary { background: var(--dsw-alias-button-primary-fill); border-color: transparent; color: var(--dsw-alias-label-primary-foreground); }
|
|
255
|
+
.dshwp-btn-primary:hover { background: var(--dsw-alias-button-primary-hover); }
|
|
256
|
+
.dshwp-btn-sm { padding: 3px 9px; font-size: 11px; }
|
|
257
|
+
.dshwp-muted { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
258
|
+
.dshwp-error { color: var(--dsw-alias-label-error); font-size: 12px; }
|
|
259
|
+
.dshwp-ok { color: var(--dsw-alias-state-success-primary); font-size: 12px; }
|
|
260
|
+
.dshwp-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l2); border-top-color: var(--dsw-alias-brand-primary); animation: dshwp-spin 0.8s linear infinite; }
|
|
261
|
+
@keyframes dshwp-spin { to { transform: rotate(360deg); } }
|
|
262
|
+
.dshwp-range { accent-color: var(--dsw-alias-brand-primary); }
|
|
263
|
+
.dshwp-switch { position: relative; width: 32px; height: 18px; border-radius: 9px; border: 0; background: var(--dsw-alias-border-l2); cursor: pointer; transition: background 0.15s; padding: 0; }
|
|
264
|
+
.dshwp-switch[data-on='true'] { background: var(--dsw-alias-brand-primary); }
|
|
265
|
+
.dshwp-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--dsw-alias-switch-thumb, #fff); transition: left 0.15s; }
|
|
266
|
+
.dshwp-switch[data-on='true']::after { left: 16px; }
|
|
267
|
+
/* Settings page: host-consistent section, groups, rows and market grid. */
|
|
268
|
+
.dshwp-section { display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: 860px; color: var(--dsw-alias-label-primary); }
|
|
269
|
+
.dshwp-group { display: flex; flex-direction: column; }
|
|
270
|
+
.dshwp-group-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
|
271
|
+
.dshwp-group-title { margin: 0; font-size: 14px; font-weight: 500; line-height: 22px; }
|
|
272
|
+
.dshwp-intro { margin: 0 0 10px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }
|
|
273
|
+
.dshwp-srow { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 0; border-bottom: .5px solid var(--dsw-alias-border-l2); }
|
|
274
|
+
.dshwp-srow[data-last='true'] { border-bottom: none; }
|
|
275
|
+
.dshwp-srow-title { font-size: 14px; line-height: 20px; }
|
|
276
|
+
.dshwp-srow-desc { margin-top: 4px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
277
|
+
.dshwp-srow-control { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
278
|
+
.dshwp-toolbar32 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
279
|
+
.dshwp-input32 { height: 32px; padding: 0 10px; border: .5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md, 8px); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
|
|
280
|
+
.dshwp-input32:focus { border-color: var(--dsw-alias-brand-primary); }
|
|
281
|
+
.dshwp-chip { height: 28px; padding: 0 10px; border: .5px solid var(--dsw-alias-border-l4); border-radius: 14px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; cursor: pointer; }
|
|
282
|
+
.dshwp-chip:hover { color: var(--dsw-alias-label-primary); }
|
|
283
|
+
.dshwp-chip[data-active='true'] { border-color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); }
|
|
284
|
+
.dshwp-btn32 { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: .5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md, 8px); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 20px; cursor: pointer; }
|
|
285
|
+
.dshwp-btn32:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-3); }
|
|
286
|
+
.dshwp-btn32:disabled { opacity: .6; cursor: default; }
|
|
287
|
+
.dshwp-btn32-primary { border-color: transparent; background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
|
|
288
|
+
.dshwp-btn32-primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
|
|
289
|
+
.dshwp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 10px; }
|
|
290
|
+
.dshwp-card { position: relative; aspect-ratio: 16/10; border-radius: var(--dsw-radius-lg, 12px); border: .5px solid var(--dsw-alias-border-l3); background: var(--dsw-alias-bg-layer-2); overflow: hidden; cursor: pointer; padding: 0; }
|
|
291
|
+
.dshwp-card:disabled { cursor: default; }
|
|
292
|
+
.dshwp-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
293
|
+
.dshwp-card-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
|
294
|
+
.dshwp-tag { position: absolute; padding: 1px 6px; border-radius: 4px; font-size: 10px; line-height: 16px; background: rgb(0 0 0 / 55%); color: #fff; }
|
|
295
|
+
.dshwp-shade { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 35%); opacity: 0; transition: opacity 0.12s; color: #fff; }
|
|
296
|
+
.dshwp-card:hover:not(:disabled) .dshwp-shade { opacity: 1; }
|
|
297
|
+
.dshwp-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; }
|
|
298
|
+
.dshwp-current { display: flex; align-items: center; gap: 12px; }
|
|
299
|
+
.dshwp-current-thumb { width: 104px; height: 64px; flex: none; border: .5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md, 8px); overflow: hidden; background: var(--dsw-alias-bg-layer-3); }
|
|
300
|
+
.dshwp-current-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
301
|
+
.dshwp-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 22px; border: .5px dashed var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 12px); color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 20px; }
|
|
302
|
+
`
|
|
303
|
+
|
|
304
|
+
function Switch({ on, onChange, label }) {
|
|
305
|
+
return h('button', {
|
|
306
|
+
type: 'button',
|
|
307
|
+
className: 'dshwp-switch',
|
|
308
|
+
role: 'switch',
|
|
309
|
+
'aria-checked': on ? 'true' : 'false',
|
|
310
|
+
'aria-label': label,
|
|
311
|
+
'data-on': on ? 'true' : 'false',
|
|
312
|
+
onClick: () => onChange(!on),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function Spinner() {
|
|
317
|
+
return h('span', { className: 'dshwp-spinner', role: 'status' })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ImageIcon({ size = 16 }) {
|
|
321
|
+
return h(
|
|
322
|
+
'svg',
|
|
323
|
+
{ viewBox: '0 0 64 64', width: size, height: size, 'aria-hidden': true },
|
|
324
|
+
h('rect', { x: 6, y: 10, width: 52, height: 40, rx: 5, fill: 'none', stroke: 'currentColor', strokeWidth: 5 }),
|
|
325
|
+
h('circle', { cx: 22, cy: 24, r: 5, fill: 'currentColor' }),
|
|
326
|
+
h('path', { d: 'M8 44l14-14 10 10 9-11 15 15v6H8z', fill: 'currentColor' })
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function SettingsRow({ title, description, last, children }) {
|
|
331
|
+
return h(
|
|
332
|
+
'div',
|
|
333
|
+
{ className: 'dshwp-srow', 'data-last': last ? 'true' : undefined },
|
|
334
|
+
h(
|
|
335
|
+
'div',
|
|
336
|
+
null,
|
|
337
|
+
h('div', { className: 'dshwp-srow-title' }, title),
|
|
338
|
+
description ? h('div', { className: 'dshwp-srow-desc' }, description) : null
|
|
339
|
+
),
|
|
340
|
+
h('div', { className: 'dshwp-srow-control' }, children)
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Keeps one crashing child from tearing down the styles and the rest. */
|
|
345
|
+
class Boundary extends React.Component {
|
|
346
|
+
constructor(props) {
|
|
347
|
+
super(props)
|
|
348
|
+
this.state = { failed: false }
|
|
349
|
+
}
|
|
350
|
+
static getDerivedStateFromError() {
|
|
351
|
+
return { failed: true }
|
|
352
|
+
}
|
|
353
|
+
componentDidCatch(error) {
|
|
354
|
+
console.error('dsh-wallpaper: part crashed', error)
|
|
355
|
+
}
|
|
356
|
+
render() {
|
|
357
|
+
if (this.state.failed) return null
|
|
358
|
+
return this.props.children
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Marketplace ────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
const SEARCH_DEBOUNCE = 300
|
|
365
|
+
const MAX_WP_BYTES_CLIENT = 16 * 1024 * 1024
|
|
366
|
+
const MAX_WP_PIXELS_CLIENT = 40_000_000
|
|
367
|
+
const WP_CATEGORIES = ['all', 'general', 'anime', 'people']
|
|
368
|
+
|
|
369
|
+
function wallpaperBlocker(w) {
|
|
370
|
+
if (w.fileSizeBytes > MAX_WP_BYTES_CLIENT) return 'tooManyBytes'
|
|
371
|
+
if (w.width * w.height > MAX_WP_PIXELS_CLIENT) return 'tooManyPixels'
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function formatBytes(bytes) {
|
|
376
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function formatPixels(pixels) {
|
|
380
|
+
return `${(pixels / 1_000_000).toFixed(1)} MP`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function WallpaperCard({ wallpaper, t, assetCache, applied, downloading, busy, onApply }) {
|
|
384
|
+
const thumb = useProxiedAsset(
|
|
385
|
+
assetCache,
|
|
386
|
+
`/api/market/asset?url=${encodeURIComponent(wallpaper.thumbUrl)}`
|
|
387
|
+
)
|
|
388
|
+
const blocker = wallpaperBlocker(wallpaper)
|
|
389
|
+
const blockerHint =
|
|
390
|
+
blocker === 'tooManyBytes'
|
|
391
|
+
? t('wallpaper.tooLarge', { actual: formatBytes(wallpaper.fileSizeBytes), limit: formatBytes(MAX_WP_BYTES_CLIENT) })
|
|
392
|
+
: blocker === 'tooManyPixels'
|
|
393
|
+
? t('wallpaper.tooLarge', {
|
|
394
|
+
actual: formatPixels(wallpaper.width * wallpaper.height),
|
|
395
|
+
limit: formatPixels(MAX_WP_PIXELS_CLIENT),
|
|
396
|
+
})
|
|
397
|
+
: null
|
|
398
|
+
const resolution = wallpaper.width > 0 && wallpaper.height > 0 ? `${wallpaper.width}×${wallpaper.height}` : ''
|
|
399
|
+
return h(
|
|
400
|
+
'button',
|
|
401
|
+
{
|
|
402
|
+
type: 'button',
|
|
403
|
+
className: 'dshwp-card',
|
|
404
|
+
disabled: busy || blocker !== null,
|
|
405
|
+
style: blocker ? { opacity: 0.5 } : undefined,
|
|
406
|
+
title: blockerHint || resolution || wallpaper.id,
|
|
407
|
+
'aria-label': [t('wallpaper.cardLabel', { id: wallpaper.id }), resolution, applied ? t('wallpaper.applied') : null, blockerHint]
|
|
408
|
+
.filter(Boolean)
|
|
409
|
+
.join(' · '),
|
|
410
|
+
onClick: () => onApply(wallpaper),
|
|
411
|
+
},
|
|
412
|
+
thumb.src
|
|
413
|
+
? h('img', { src: thumb.src, alt: '', loading: 'lazy' })
|
|
414
|
+
: h('span', { className: 'dshwp-card-empty' }, thumb.failed ? '×' : h(Spinner)),
|
|
415
|
+
(blocker === 'tooManyBytes' ? formatBytes(wallpaper.fileSizeBytes) : resolution)
|
|
416
|
+
? h(
|
|
417
|
+
'span',
|
|
418
|
+
{ className: 'dshwp-tag', style: { left: 6, bottom: 6 } },
|
|
419
|
+
blocker === 'tooManyBytes' ? formatBytes(wallpaper.fileSizeBytes) : resolution
|
|
420
|
+
)
|
|
421
|
+
: null,
|
|
422
|
+
applied
|
|
423
|
+
? h('span', { className: 'dshwp-tag', style: { right: 6, top: 6, background: 'var(--dsw-alias-state-success-primary)' } }, t('wallpaper.applied'))
|
|
424
|
+
: blocker
|
|
425
|
+
? h('span', { className: 'dshwp-tag', style: { right: 6, top: 6 } }, t('wallpaper.unavailable'))
|
|
426
|
+
: null,
|
|
427
|
+
!blocker
|
|
428
|
+
? h(
|
|
429
|
+
'span',
|
|
430
|
+
{ className: 'dshwp-shade', style: downloading ? { opacity: 1 } : undefined },
|
|
431
|
+
downloading ? h(Spinner) : '↓'
|
|
432
|
+
)
|
|
433
|
+
: null
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The wallhaven.cc market, inline in the settings page (and reused wherever
|
|
439
|
+
* else the surface is needed). Single-flight downloads: two concurrent
|
|
440
|
+
* writes would race on the same background file.
|
|
441
|
+
*/
|
|
442
|
+
function WallpaperMarket({ store, t }) {
|
|
443
|
+
const assetCacheRef = useRef(null)
|
|
444
|
+
if (!assetCacheRef.current) assetCacheRef.current = createAssetCache()
|
|
445
|
+
const assetCache = assetCacheRef.current
|
|
446
|
+
useEffect(() => () => assetCache.dispose(), [assetCache])
|
|
447
|
+
|
|
448
|
+
const config = useStore(store, (s) => s.config)
|
|
449
|
+
const [searchInput, setSearchInput] = useState('')
|
|
450
|
+
const [query, setQuery] = useState('')
|
|
451
|
+
const [category, setCategory] = useState('all')
|
|
452
|
+
const [page, setPage] = useState(1)
|
|
453
|
+
const [shownPage, setShownPage] = useState(1)
|
|
454
|
+
const [items, setItems] = useState([])
|
|
455
|
+
const [lastPage, setLastPage] = useState(1)
|
|
456
|
+
const [loading, setLoading] = useState(false)
|
|
457
|
+
const [error, setError] = useState(null)
|
|
458
|
+
const [notice, setNotice] = useState(null)
|
|
459
|
+
const [downloadingId, setDownloadingId] = useState(null)
|
|
460
|
+
const downloadingRef = useRef(null)
|
|
461
|
+
const seqRef = useRef(0)
|
|
462
|
+
const committedQuery = useRef('')
|
|
463
|
+
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
const handle = setTimeout(() => {
|
|
466
|
+
const next = searchInput.trim()
|
|
467
|
+
if (next === committedQuery.current) return
|
|
468
|
+
committedQuery.current = next
|
|
469
|
+
setQuery(next)
|
|
470
|
+
setPage(1)
|
|
471
|
+
}, SEARCH_DEBOUNCE)
|
|
472
|
+
return () => clearTimeout(handle)
|
|
473
|
+
}, [searchInput])
|
|
474
|
+
|
|
475
|
+
const load = useCallback(async (q, c, p) => {
|
|
476
|
+
const seq = ++seqRef.current
|
|
477
|
+
setLoading(true)
|
|
478
|
+
setError(null)
|
|
479
|
+
setShownPage(p)
|
|
480
|
+
try {
|
|
481
|
+
const params = new URLSearchParams({ query: q, category: c, page: String(p) })
|
|
482
|
+
const data = await apiJson(`/api/market/search?${params}`)
|
|
483
|
+
if (seq !== seqRef.current) return
|
|
484
|
+
setItems(data.items)
|
|
485
|
+
setLastPage(data.lastPage)
|
|
486
|
+
setShownPage(data.page)
|
|
487
|
+
// Upstream clamps out-of-range pages; pull the cursor back (never forward).
|
|
488
|
+
if (data.page < p) setPage(data.page)
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (seq !== seqRef.current) return
|
|
491
|
+
setItems([])
|
|
492
|
+
setLastPage(1)
|
|
493
|
+
setError(err.message)
|
|
494
|
+
} finally {
|
|
495
|
+
if (seq === seqRef.current) setLoading(false)
|
|
496
|
+
}
|
|
497
|
+
}, [])
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
void load(query, category, page)
|
|
501
|
+
}, [query, category, page, load])
|
|
502
|
+
|
|
503
|
+
const onCardApply = async (wallpaper) => {
|
|
504
|
+
if (downloadingRef.current !== null) return
|
|
505
|
+
downloadingRef.current = wallpaper.id
|
|
506
|
+
setDownloadingId(wallpaper.id)
|
|
507
|
+
setNotice(null)
|
|
508
|
+
setError(null)
|
|
509
|
+
try {
|
|
510
|
+
await apiJson('/api/market/download', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
body: JSON.stringify({ url: wallpaper.fullUrl, sourceUrl: wallpaper.sourceUrl }),
|
|
513
|
+
})
|
|
514
|
+
const data = await apiJson('/api/config', {
|
|
515
|
+
method: 'PUT',
|
|
516
|
+
body: JSON.stringify({ wallpaperEnabled: true, wallpaperSourceUrl: wallpaper.sourceUrl }),
|
|
517
|
+
})
|
|
518
|
+
store.setConfig(data.config)
|
|
519
|
+
const v = await apiJson('/api/version')
|
|
520
|
+
store.setWallpaperVersion(v.version)
|
|
521
|
+
setNotice(t('wallpaper.appliedToast'))
|
|
522
|
+
} catch (err) {
|
|
523
|
+
setError(`${t('wallpaper.downloadFailed')}: ${err.message}`)
|
|
524
|
+
} finally {
|
|
525
|
+
downloadingRef.current = null
|
|
526
|
+
setDownloadingId(null)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return h(
|
|
531
|
+
'div',
|
|
532
|
+
{ className: 'dshwp-group' },
|
|
533
|
+
h(
|
|
534
|
+
'div',
|
|
535
|
+
{ className: 'dshwp-group-head' },
|
|
536
|
+
h('h3', { className: 'dshwp-group-title' }, t('wallpaper.market')),
|
|
537
|
+
h('span', { className: 'dshwp-muted' },
|
|
538
|
+
`${t('wallpaper.credit')}${loading ? '' : ` · ${t('wallpaper.pageInfo', { page: shownPage, lastPage })}`}`
|
|
539
|
+
)
|
|
540
|
+
),
|
|
541
|
+
h('p', { className: 'dshwp-intro' }, t('wallpaper.marketHint')),
|
|
542
|
+
h(
|
|
543
|
+
'div',
|
|
544
|
+
{ className: 'dshwp-toolbar32' },
|
|
545
|
+
h('input', {
|
|
546
|
+
className: 'dshwp-input32',
|
|
547
|
+
style: { width: 220 },
|
|
548
|
+
value: searchInput,
|
|
549
|
+
placeholder: t('wallpaper.searchPlaceholder'),
|
|
550
|
+
'aria-label': t('wallpaper.searchPlaceholder'),
|
|
551
|
+
onChange: (event) => setSearchInput(event.target.value),
|
|
552
|
+
}),
|
|
553
|
+
WP_CATEGORIES.map((c) =>
|
|
554
|
+
h(
|
|
555
|
+
'button',
|
|
556
|
+
{
|
|
557
|
+
key: c,
|
|
558
|
+
type: 'button',
|
|
559
|
+
className: 'dshwp-chip',
|
|
560
|
+
'data-active': category === c ? 'true' : undefined,
|
|
561
|
+
onClick: () => {
|
|
562
|
+
setCategory(c)
|
|
563
|
+
setPage(1)
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
t(`wallpaper.categories.${c}`)
|
|
567
|
+
)
|
|
568
|
+
),
|
|
569
|
+
h(
|
|
570
|
+
'button',
|
|
571
|
+
{ type: 'button', className: 'dshwp-btn32', disabled: loading, onClick: () => void load(query, category, page) },
|
|
572
|
+
loading ? h(Spinner) : t('wallpaper.refresh')
|
|
573
|
+
)
|
|
574
|
+
),
|
|
575
|
+
error ? h('p', { className: 'dshwp-error' }, error) : null,
|
|
576
|
+
notice ? h('p', { className: 'dshwp-ok' }, notice) : null,
|
|
577
|
+
loading && items.length === 0
|
|
578
|
+
? h('div', { style: { display: 'flex', justifyContent: 'center', padding: 40 } }, h(Spinner))
|
|
579
|
+
: items.length === 0 && !error
|
|
580
|
+
? h('div', { className: 'dshwp-empty' }, h(ImageIcon, { size: 24 }), h('span', null, t('wallpaper.empty')))
|
|
581
|
+
: h(
|
|
582
|
+
'div',
|
|
583
|
+
{ className: 'dshwp-grid', style: loading ? { opacity: 0.6 } : undefined },
|
|
584
|
+
items.map((w) =>
|
|
585
|
+
h(WallpaperCard, {
|
|
586
|
+
key: w.id,
|
|
587
|
+
wallpaper: w,
|
|
588
|
+
t,
|
|
589
|
+
assetCache,
|
|
590
|
+
applied: config.wallpaperSourceUrl === w.sourceUrl,
|
|
591
|
+
downloading: downloadingId === w.id,
|
|
592
|
+
busy: downloadingId !== null,
|
|
593
|
+
onApply: (item) => void onCardApply(item),
|
|
594
|
+
})
|
|
595
|
+
)
|
|
596
|
+
),
|
|
597
|
+
h(
|
|
598
|
+
'div',
|
|
599
|
+
{ className: 'dshwp-pager' },
|
|
600
|
+
h('span', { className: 'dshwp-muted' }, t('wallpaper.pageInfo', { page: shownPage, lastPage })),
|
|
601
|
+
h(
|
|
602
|
+
'div',
|
|
603
|
+
{ style: { display: 'flex', gap: 8 } },
|
|
604
|
+
h(
|
|
605
|
+
'button',
|
|
606
|
+
{
|
|
607
|
+
type: 'button',
|
|
608
|
+
className: 'dshwp-btn32',
|
|
609
|
+
disabled: shownPage <= 1 || loading,
|
|
610
|
+
onClick: () => setPage(Math.max(1, shownPage - 1)),
|
|
611
|
+
},
|
|
612
|
+
t('wallpaper.prevPage')
|
|
613
|
+
),
|
|
614
|
+
h(
|
|
615
|
+
'button',
|
|
616
|
+
{
|
|
617
|
+
type: 'button',
|
|
618
|
+
className: 'dshwp-btn32',
|
|
619
|
+
disabled: shownPage >= lastPage || loading,
|
|
620
|
+
onClick: () => setPage(shownPage + 1),
|
|
621
|
+
},
|
|
622
|
+
t('wallpaper.nextPage')
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ─── Display settings (localStorage, codeg's model) ─────────────────────
|
|
630
|
+
//
|
|
631
|
+
// The image bytes belong to the Host, but the cheap display knobs stay in
|
|
632
|
+
// the browser: dragging a slider repaints instantly with no round trip, and
|
|
633
|
+
// the Host schema never has to grow for them. Defaults mirror codeg.
|
|
634
|
+
|
|
635
|
+
const WP_SETTINGS_KEY = 'dsh.wallpaper.display'
|
|
636
|
+
const WP_FILL_MODES = ['cover', 'contain', 'center', 'tile']
|
|
637
|
+
const WP_FILL_STYLE = {
|
|
638
|
+
cover: { size: 'cover', repeat: 'no-repeat' },
|
|
639
|
+
contain: { size: 'contain', repeat: 'no-repeat' },
|
|
640
|
+
center: { size: 'auto', repeat: 'no-repeat' },
|
|
641
|
+
tile: { size: 'auto', repeat: 'repeat' },
|
|
642
|
+
}
|
|
643
|
+
const WP_DEFAULTS = { fillMode: 'cover', maskOpacity: 0.82, imageBlur: 0, panelOpacity: 0.3 }
|
|
644
|
+
const WP_RANGE = {
|
|
645
|
+
maskOpacity: { min: 0, max: 0.99, step: 0.01 },
|
|
646
|
+
imageBlur: { min: 0, max: 24, step: 1 },
|
|
647
|
+
panelOpacity: { min: 0, max: 1, step: 0.05 },
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Frost for the chrome that keeps a glass look. codeg uses 8px for every
|
|
651
|
+
* structural surface; the old 20px on the sidebar read as mush over a busy
|
|
652
|
+
* image, so the sidebar and the top bar share this value.
|
|
653
|
+
*/
|
|
654
|
+
const WP_FROST = 'blur(8px) saturate(140%)'
|
|
655
|
+
|
|
656
|
+
function clampSetting(key, value) {
|
|
657
|
+
const num = Number(value)
|
|
658
|
+
if (!Number.isFinite(num)) return WP_DEFAULTS[key]
|
|
659
|
+
const range = WP_RANGE[key]
|
|
660
|
+
return Math.min(range.max, Math.max(range.min, num))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function readWallpaperSettings() {
|
|
664
|
+
try {
|
|
665
|
+
const raw = window.localStorage.getItem(WP_SETTINGS_KEY)
|
|
666
|
+
if (!raw) return { ...WP_DEFAULTS }
|
|
667
|
+
const parsed = JSON.parse(raw)
|
|
668
|
+
return {
|
|
669
|
+
fillMode: WP_FILL_MODES.includes(parsed?.fillMode) ? parsed.fillMode : WP_DEFAULTS.fillMode,
|
|
670
|
+
maskOpacity: clampSetting('maskOpacity', parsed?.maskOpacity ?? WP_DEFAULTS.maskOpacity),
|
|
671
|
+
imageBlur: clampSetting('imageBlur', parsed?.imageBlur ?? WP_DEFAULTS.imageBlur),
|
|
672
|
+
panelOpacity: clampSetting('panelOpacity', parsed?.panelOpacity ?? WP_DEFAULTS.panelOpacity),
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
return { ...WP_DEFAULTS }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ─── Wallpaper layer (the global background itself) ─────────────────────
|
|
680
|
+
|
|
681
|
+
/** Rewrite a computed colour literal with a different alpha. */
|
|
682
|
+
function withAlpha(css, alpha) {
|
|
683
|
+
if (/\/\s*[\d.]+%?\s*\)\s*$/.test(css)) return css.replace(/\/\s*[\d.]+%?\s*\)\s*$/, `/ ${alpha})`)
|
|
684
|
+
const rgb = css.match(/^rgba?\(([^)]+)\)$/i)
|
|
685
|
+
if (rgb) {
|
|
686
|
+
const parts = rgb[1].split(/[,/\s]+/).filter(Boolean).slice(0, 3).join(', ')
|
|
687
|
+
return `rgba(${parts}, ${alpha})`
|
|
688
|
+
}
|
|
689
|
+
return css
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Alpha of a computed CSS color, 1 when opaque. Handles `rgb()/rgba()` and
|
|
694
|
+
* the `color(srgb ... / a)` form Chrome returns for `color-mix()`.
|
|
695
|
+
*/
|
|
696
|
+
function colorAlpha(css) {
|
|
697
|
+
if (!css) return 0
|
|
698
|
+
if (css === 'transparent') return 0
|
|
699
|
+
const rgb = css.match(/rgba?\(([^)]+)\)/i)
|
|
700
|
+
if (rgb) {
|
|
701
|
+
const parts = rgb[1].split(/[,/\s]+/).filter((p) => p !== '')
|
|
702
|
+
if (parts.length >= 4) {
|
|
703
|
+
const v = Number(parts[3])
|
|
704
|
+
return Number.isFinite(v) ? v : 1
|
|
705
|
+
}
|
|
706
|
+
return 1
|
|
707
|
+
}
|
|
708
|
+
const slash = css.match(/\/\s*([\d.]+%?)\s*\)/)
|
|
709
|
+
if (slash) {
|
|
710
|
+
const raw = slash[1]
|
|
711
|
+
const v = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw)
|
|
712
|
+
return Number.isFinite(v) ? v : 1
|
|
713
|
+
}
|
|
714
|
+
return 1
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Apply the wallpaper to the shell frame: find the top-level app frame by
|
|
719
|
+
* walking up from our own overlay node (robust against hashed CSS-module
|
|
720
|
+
* class names), make it transparent, mount our own image div behind its
|
|
721
|
+
* content, then repaint the frame's large surfaces as translucent
|
|
722
|
+
* `color-mix` overlays — codeg's "background + panel alpha" model.
|
|
723
|
+
*
|
|
724
|
+
* Two rules keep the effect stable and complete:
|
|
725
|
+
* - Each surface's alpha is always computed from the color captured on the
|
|
726
|
+
* first touch, never from the already-mixed computed value, so moving the
|
|
727
|
+
* opacity slider cannot compound transparency away.
|
|
728
|
+
* - Coverage is classified by geometry, not by one area threshold: the
|
|
729
|
+
* frame, the columns (including the narrow left sidebar and the wide top
|
|
730
|
+
* bar), and the deeper panels each get a treatment. The sidebar and bar
|
|
731
|
+
* also get a frosted-glass backdrop so they read as distinct chrome.
|
|
732
|
+
*
|
|
733
|
+
* Everything is restored on cleanup.
|
|
734
|
+
*/
|
|
735
|
+
function useWallpaperLayer(hostRef, enabled, settings, version) {
|
|
736
|
+
const repaintRef = useRef(null)
|
|
737
|
+
const settingsRef = useRef(settings)
|
|
738
|
+
settingsRef.current = settings
|
|
739
|
+
|
|
740
|
+
// Mount/unmount the layer and repaint surfaces (settings read via a ref so
|
|
741
|
+
// slider drags never re-fetch the image or re-mount the layer).
|
|
742
|
+
useEffect(() => {
|
|
743
|
+
const host = hostRef.current
|
|
744
|
+
if (!host) return
|
|
745
|
+
if (!enabled) return
|
|
746
|
+
let disposed = false
|
|
747
|
+
let observer = null
|
|
748
|
+
let scanTimer = null
|
|
749
|
+
let wallpaperUrl = null
|
|
750
|
+
let bodyBackground = null
|
|
751
|
+
let backgroundStyle = null
|
|
752
|
+
let frameOriginalPosition = null
|
|
753
|
+
/** el -> {backgroundColor, backgroundImage, backdropFilter, base, kind} */
|
|
754
|
+
const originals = new Map()
|
|
755
|
+
|
|
756
|
+
const findFrame = () => {
|
|
757
|
+
let node = host
|
|
758
|
+
const root = host.closest('#root')
|
|
759
|
+
if (!root) return null
|
|
760
|
+
while (node && node.parentElement !== root) node = node.parentElement
|
|
761
|
+
return node
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const frame = findFrame()
|
|
765
|
+
if (!frame) {
|
|
766
|
+
console.warn('dsh-wallpaper: app frame not found; layer idle')
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
// The image covers the whole window (not just the frame's box): a fixed
|
|
770
|
+
// body-level layer also shows behind a titlebar row that the desktop
|
|
771
|
+
// shell may render outside #root.
|
|
772
|
+
const mountRoot = document.body
|
|
773
|
+
if (getComputedStyle(frame).position === 'static') {
|
|
774
|
+
frameOriginalPosition = ''
|
|
775
|
+
frame.style.position = 'relative'
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const classify = (el, frameRect) => {
|
|
779
|
+
if (el === frame) return 'frame'
|
|
780
|
+
const rect = el.getBoundingClientRect()
|
|
781
|
+
if (rect.width <= 0 || rect.height <= 0) return null
|
|
782
|
+
const frameArea = frameRect.width * frameRect.height
|
|
783
|
+
const area = rect.width * rect.height
|
|
784
|
+
// The top bar is measured relative to the frame, not in absolute
|
|
785
|
+
// pixels: the Windows titlebar row is ~100px tall in a tall window
|
|
786
|
+
// and was missed by a fixed cap.
|
|
787
|
+
const inTopBand = rect.top <= frameRect.top + frameRect.height * 0.3
|
|
788
|
+
const wideShort =
|
|
789
|
+
rect.width >= frameRect.width * 0.5 &&
|
|
790
|
+
rect.height <= frameRect.height * 0.25 &&
|
|
791
|
+
inTopBand
|
|
792
|
+
const tallNarrow = rect.width <= frameRect.width * 0.45 && rect.height >= frameRect.height * 0.5
|
|
793
|
+
if (wideShort) return 'bar'
|
|
794
|
+
if (tallNarrow) return 'sidebar'
|
|
795
|
+
if (area >= frameArea * 0.08) return 'panel'
|
|
796
|
+
return null
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Panels that must stay opaque: settings pages and floating dialogs are
|
|
801
|
+
* content surfaces, not the canvas the wallpaper shows through. Chrome
|
|
802
|
+
* (frame, columns, bars) is exempt from this check so the sidebar keeps
|
|
803
|
+
* its glass even while a settings page is open.
|
|
804
|
+
*
|
|
805
|
+
* The settings panel can stay mounted while closed (hidden), so a plain
|
|
806
|
+
* `querySelector` match proved nothing and silently excluded the whole
|
|
807
|
+
* canvas: the slot must actually be laid out to count.
|
|
808
|
+
*/
|
|
809
|
+
const isContentPanel = (el) => {
|
|
810
|
+
const dialog = el.closest('[role="dialog"], [aria-modal="true"]')
|
|
811
|
+
if (dialog !== null && dialog.getClientRects().length > 0) return true
|
|
812
|
+
const settings = el.querySelector('[data-slot^="settings."]')
|
|
813
|
+
return settings !== null && settings.getClientRects().length > 0
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** The styles the layer wrote, so a theme switch can re-capture bases. */
|
|
817
|
+
const restoreOne = (el, orig) => {
|
|
818
|
+
el.style.backgroundColor = orig.backgroundColor
|
|
819
|
+
el.style.backgroundImage = orig.backgroundImage
|
|
820
|
+
el.style.backdropFilter = orig.backdropFilter
|
|
821
|
+
el.style.backgroundAttachment = orig.backgroundAttachment
|
|
822
|
+
el.style.backgroundSize = orig.backgroundSize
|
|
823
|
+
el.style.backgroundRepeat = orig.backgroundRepeat
|
|
824
|
+
el.style.backgroundPosition = orig.backgroundPosition
|
|
825
|
+
el.style.borderTopLeftRadius = orig.borderTopLeftRadius
|
|
826
|
+
el.style.borderRight = orig.borderRight
|
|
827
|
+
el.style.borderBottom = orig.borderBottom
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Inline `border-top-left-radius` of every element this layer squared
|
|
832
|
+
* off, keyed by element so the value can be handed back on cleanup.
|
|
833
|
+
*/
|
|
834
|
+
const flattened = new Map()
|
|
835
|
+
|
|
836
|
+
const restoreFlattened = () => {
|
|
837
|
+
for (const [el, radius] of flattened) el.style.borderTopLeftRadius = radius
|
|
838
|
+
flattened.clear()
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const restoreAll = () => {
|
|
842
|
+
for (const [el, orig] of originals) restoreOne(el, orig)
|
|
843
|
+
originals.clear()
|
|
844
|
+
restoreFlattened()
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
let themeKey = ''
|
|
848
|
+
const seen = new Set()
|
|
849
|
+
|
|
850
|
+
let warned = false
|
|
851
|
+
|
|
852
|
+
const repaint = () => {
|
|
853
|
+
if (disposed) return
|
|
854
|
+
try {
|
|
855
|
+
repaintInner()
|
|
856
|
+
} catch (error) {
|
|
857
|
+
// A throw mid-walk must never escape into the observer/interval or
|
|
858
|
+
// leave the layer half-painted: report once and keep the app alive.
|
|
859
|
+
if (!warned) {
|
|
860
|
+
warned = true
|
|
861
|
+
console.warn('dsh-wallpaper: surface repaint failed', error)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The frame's visible box. `findFrame` can land on a `display: contents`
|
|
868
|
+
* wrapper — the Windows shell nests the real grid frame inside one — and
|
|
869
|
+
* such an element measures 0x0. Geometry-driven decisions (the top band,
|
|
870
|
+
* the minimum child area) silently degrade to nothing when that happens,
|
|
871
|
+
* so fall back to the first laid-out child and then to the viewport.
|
|
872
|
+
*/
|
|
873
|
+
const measureFrameBox = () => {
|
|
874
|
+
const own = frame.getBoundingClientRect()
|
|
875
|
+
if (own.width > 0 && own.height > 0) return own
|
|
876
|
+
for (const child of frame.children) {
|
|
877
|
+
if (!(child instanceof HTMLElement)) continue
|
|
878
|
+
const rect = child.getBoundingClientRect()
|
|
879
|
+
if (rect.width > 0 && rect.height > 0) return rect
|
|
880
|
+
}
|
|
881
|
+
return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The laid-out box of the app frame. `findFrame` can land on a
|
|
886
|
+
* `display: contents` wrapper (the Windows shell nests the real grid
|
|
887
|
+
* inside one); the tint — and the empty strip above the columns — belong
|
|
888
|
+
* to the grid element inside it.
|
|
889
|
+
*/
|
|
890
|
+
const laidOutRoot = () => {
|
|
891
|
+
const own = frame.getBoundingClientRect()
|
|
892
|
+
if (own.width > 0 && own.height > 0) return frame
|
|
893
|
+
for (const child of frame.children) {
|
|
894
|
+
if (!(child instanceof HTMLElement)) continue
|
|
895
|
+
const rect = child.getBoundingClientRect()
|
|
896
|
+
if (rect.width > 0 && rect.height > 0) return child
|
|
897
|
+
}
|
|
898
|
+
return null
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* The background stack, built from two fixed pseudo-elements on <body>
|
|
903
|
+
* rather than the body background itself: an image layer plus a mask
|
|
904
|
+
* veil. `filter: blur()` on <body> would blur the whole application, and
|
|
905
|
+
* a `z-index: -1` child would be hidden by an opaque body background
|
|
906
|
+
* (exactly what bit this plugin before), so <body> is made transparent
|
|
907
|
+
* and both layers sit behind all content — codeg's model.
|
|
908
|
+
*/
|
|
909
|
+
const applyBackgroundStack = () => {
|
|
910
|
+
const tuned = settingsRef.current
|
|
911
|
+
const fill = WP_FILL_STYLE[tuned.fillMode] ?? WP_FILL_STYLE.cover
|
|
912
|
+
if (backgroundStyle === null) {
|
|
913
|
+
backgroundStyle = document.createElement('style')
|
|
914
|
+
backgroundStyle.setAttribute('data-plugin', '@local/dsh-wallpaper')
|
|
915
|
+
backgroundStyle.setAttribute('data-dshwp-bg', '1')
|
|
916
|
+
document.head.appendChild(backgroundStyle)
|
|
917
|
+
}
|
|
918
|
+
backgroundStyle.textContent = [
|
|
919
|
+
'body::before {',
|
|
920
|
+
" content: '';",
|
|
921
|
+
' position: fixed;',
|
|
922
|
+
' inset: 0;',
|
|
923
|
+
' z-index: -1;',
|
|
924
|
+
' pointer-events: none;',
|
|
925
|
+
' background-image: var(--dshwp-image);',
|
|
926
|
+
` background-size: ${fill.size};`,
|
|
927
|
+
` background-repeat: ${fill.repeat};`,
|
|
928
|
+
' background-position: center;',
|
|
929
|
+
tuned.imageBlur > 0 ? ` filter: blur(${tuned.imageBlur}px);` : '',
|
|
930
|
+
'}',
|
|
931
|
+
'body::after {',
|
|
932
|
+
" content: '';",
|
|
933
|
+
' position: fixed;',
|
|
934
|
+
' inset: 0;',
|
|
935
|
+
' z-index: -1;',
|
|
936
|
+
' pointer-events: none;',
|
|
937
|
+
` background-color: color-mix(in srgb, var(--dsw-alias-bg-base) ${Math.round(tuned.maskOpacity * 100)}%, transparent);`,
|
|
938
|
+
'}',
|
|
939
|
+
].join('\n')
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Region separators: one hairline wherever two regions meet. */
|
|
943
|
+
const line = 'color-mix(in srgb, currentColor 28%, transparent)'
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* The Windows caption strip is opaque chrome painted twice over the page:
|
|
947
|
+
* the shell's own `[data-windows-titlebar]` strip, filled with the
|
|
948
|
+
* sidebar-fill token, and the window-controls overlay above it, whose
|
|
949
|
+
* colour the shell publishes to the main process from a hidden probe
|
|
950
|
+
* element that reads the same token. Point that token at `transparent`
|
|
951
|
+
* and both follow: the strip keeps its drag region and its hairlines but
|
|
952
|
+
* lets the wallpaper through, so the top bar no longer reads as a block.
|
|
953
|
+
* Scoped to the body so the theme's own value returns on cleanup, and
|
|
954
|
+
* only in the desktop shell, whose preload marks the document — a plain
|
|
955
|
+
* browser never paints that strip.
|
|
956
|
+
*/
|
|
957
|
+
const CAPTION_FILL = '--dsw-specific-sidebar-fill'
|
|
958
|
+
const applyCaptionFill = () => {
|
|
959
|
+
if (!('windowsTitlebar' in document.documentElement.dataset)) return
|
|
960
|
+
// Writing the body's style attribute is also what makes the shell's
|
|
961
|
+
// MutationObserver re-read its probe and re-publish the overlay colour.
|
|
962
|
+
if (document.body.style.getPropertyValue(CAPTION_FILL) !== 'transparent') {
|
|
963
|
+
document.body.style.setProperty(CAPTION_FILL, 'transparent')
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* DSH's composer is a contenteditable box, not a <textarea>. Shared by
|
|
969
|
+
* `isComposerBox` and the walk.
|
|
970
|
+
*/
|
|
971
|
+
const EDITABLE_SELECTOR = 'textarea, [contenteditable="true"], [role="textbox"]'
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Is this walked box the composer?
|
|
975
|
+
*
|
|
976
|
+
* The old test keyed on the box's TOP edge (`top >= 60 % of the frame`),
|
|
977
|
+
* which is exactly what broke the moment the composer was stretched: a
|
|
978
|
+
* tall draft pushes the top edge far up the window, the test failed, and
|
|
979
|
+
* the box fell through to the generic translucent panel treatment — the
|
|
980
|
+
* conversation showed straight through the input box.
|
|
981
|
+
*
|
|
982
|
+
* The composer is bottom-anchored chrome instead, so the test is
|
|
983
|
+
* anchored to its BOTTOM edge, with a cap on how tall it may be: the
|
|
984
|
+
* conversation column is taller than 70 % of the frame and would
|
|
985
|
+
* otherwise swallow the message canvas as well. The visibility test
|
|
986
|
+
* rejects a laid-out but hidden mirror of the editor (AutoSize helpers
|
|
987
|
+
* are commonly `visibility: hidden`), and an already-booked composer
|
|
988
|
+
* stays one, so a passing geometry change can never drop the recipe
|
|
989
|
+
* mid-expansion.
|
|
990
|
+
*
|
|
991
|
+
* `box` is the frame rect measured by the current repaint pass.
|
|
992
|
+
*/
|
|
993
|
+
const isComposerBox = (el, rect, box) => {
|
|
994
|
+
if (originals.get(el)?.kind === 'composer') return true
|
|
995
|
+
if (el.querySelector(EDITABLE_SELECTOR) === null) return false
|
|
996
|
+
if (rect.height <= 0 || rect.height > box.height * 0.7) return false
|
|
997
|
+
if (rect.bottom < box.top + box.height * 0.6) return false
|
|
998
|
+
const cs = getComputedStyle(el)
|
|
999
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') return false
|
|
1000
|
+
return Number(cs.opacity) > 0.05
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const repaintInner = () => {
|
|
1004
|
+
// A theme switch changes every surface's base color; captured bases
|
|
1005
|
+
// would otherwise keep painting the previous palette.
|
|
1006
|
+
const nextThemeKey = `${document.documentElement.style.colorScheme}|${document.body.dataset.dsDarkTheme ?? ''}`
|
|
1007
|
+
if (nextThemeKey !== themeKey) {
|
|
1008
|
+
restoreAll()
|
|
1009
|
+
themeKey = nextThemeKey
|
|
1010
|
+
}
|
|
1011
|
+
seen.clear()
|
|
1012
|
+
applyCaptionFill()
|
|
1013
|
+
if (wallpaperUrl !== null) applyBackgroundStack()
|
|
1014
|
+
const frameRect = measureFrameBox()
|
|
1015
|
+
const frameArea = Math.max(1, frameRect.width * frameRect.height)
|
|
1016
|
+
const tuned = settingsRef.current
|
|
1017
|
+
const alpha = Math.round(tuned.panelOpacity * 100)
|
|
1018
|
+
/**
|
|
1019
|
+
* The chrome keeps the same alpha as the panels (codeg drives every
|
|
1020
|
+
* structural surface from one slider) — the frost is what separates
|
|
1021
|
+
* the sidebar from the canvas, not a second opacity.
|
|
1022
|
+
*/
|
|
1023
|
+
const chromeAlpha = alpha
|
|
1024
|
+
|
|
1025
|
+
/** Region separators: one hairline wherever two regions meet. */
|
|
1026
|
+
const line = 'color-mix(in srgb, currentColor 28%, transparent)'
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Square off the top-left corner of every big surface in the top band
|
|
1030
|
+
* of the window. On Windows the shell rounds the content column's
|
|
1031
|
+
* corner (`--dsh-windows-content-radius`, 16px) against the sidebar
|
|
1032
|
+
* fill; once the surfaces above the wallpaper are translucent that
|
|
1033
|
+
* radius reads as a wedge of bare frame colour next to the wallpaper.
|
|
1034
|
+
*
|
|
1035
|
+
* This is its own pass rather than part of `apply`: the corner belongs
|
|
1036
|
+
* to the content column, which the paint walk may skip entirely as a
|
|
1037
|
+
* content panel, and the fix must not depend on that classification.
|
|
1038
|
+
* Only wall-sized boxes qualify, so buttons and cards keep their shape.
|
|
1039
|
+
*/
|
|
1040
|
+
const flattenTopLeftCorners = () => {
|
|
1041
|
+
const minArea = frameArea * 0.04
|
|
1042
|
+
const topLimit = frameRect.top + (frameRect.height || window.innerHeight) * 0.35
|
|
1043
|
+
const desired = new Set()
|
|
1044
|
+
let frontier = [...frame.children].filter((el) => el instanceof HTMLElement)
|
|
1045
|
+
for (let depth = 0; depth < 20 && frontier.length > 0; depth++) {
|
|
1046
|
+
const next = []
|
|
1047
|
+
for (const el of frontier) {
|
|
1048
|
+
if (host.contains(el)) continue
|
|
1049
|
+
const rect = el.getBoundingClientRect()
|
|
1050
|
+
// Prune: nothing inside a box smaller than the threshold can
|
|
1051
|
+
// itself be big enough, so the walk stays a few dozen elements.
|
|
1052
|
+
// Zero-area elements are exempt — a `display: contents` wrapper
|
|
1053
|
+
// measures 0x0 while holding the whole frame below it.
|
|
1054
|
+
if (rect.width * rect.height > 0 && rect.width * rect.height < minArea) continue
|
|
1055
|
+
const cs = getComputedStyle(el)
|
|
1056
|
+
const radius = Number.parseFloat(cs.borderTopLeftRadius) || 0
|
|
1057
|
+
const already = flattened.has(el)
|
|
1058
|
+
if ((radius >= 8 || already) && rect.top <= topLimit) {
|
|
1059
|
+
desired.add(el)
|
|
1060
|
+
if (!already) {
|
|
1061
|
+
flattened.set(el, el.style.borderTopLeftRadius)
|
|
1062
|
+
el.style.borderTopLeftRadius = '0px'
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
next.push(...el.children)
|
|
1066
|
+
}
|
|
1067
|
+
frontier = next.filter((el) => el instanceof HTMLElement)
|
|
1068
|
+
}
|
|
1069
|
+
for (const [el, radius] of [...flattened]) {
|
|
1070
|
+
if (!desired.has(el) || !el.isConnected) {
|
|
1071
|
+
el.style.borderTopLeftRadius = radius
|
|
1072
|
+
flattened.delete(el)
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const apply = (el, kind) => {
|
|
1078
|
+
if (!(el instanceof HTMLElement)) return
|
|
1079
|
+
if (host.contains(el)) return
|
|
1080
|
+
seen.add(el)
|
|
1081
|
+
const cs = getComputedStyle(el)
|
|
1082
|
+
const barLike = kind === 'bar' || kind === 'bar-child' || kind === 'bar-inner'
|
|
1083
|
+
// The walk decides this (see `isComposerBox`) and books the box as
|
|
1084
|
+
// 'composer'; `apply` only has to honour it rather than re-derive a
|
|
1085
|
+
// guess of its own.
|
|
1086
|
+
const isComposer = kind === 'composer'
|
|
1087
|
+
let entry = originals.get(el)
|
|
1088
|
+
if (!entry) {
|
|
1089
|
+
const bgColor = cs.backgroundColor
|
|
1090
|
+
const bgImage = cs.backgroundImage
|
|
1091
|
+
const hasImage = bgImage && bgImage !== 'none'
|
|
1092
|
+
const opaque = colorAlpha(bgColor) >= 0.9
|
|
1093
|
+
// Runtime images (a plugin's blob: sprite, a chat attachment) are
|
|
1094
|
+
// content, never a paint surface. Only true chrome may be treated
|
|
1095
|
+
// through its background image.
|
|
1096
|
+
const chrome = kind === 'frame' || kind === 'sidebar' || barLike
|
|
1097
|
+
// `inner` and `composer` bypass the opacity gate: the visible
|
|
1098
|
+
// "blocks" are exactly the boxes with a *translucent* dark
|
|
1099
|
+
// background, and the composer box is one of them — a >= 0.9
|
|
1100
|
+
// gate can never reach them, which is why clearing them never
|
|
1101
|
+
// worked before.
|
|
1102
|
+
const bypass = kind === 'inner' || kind === 'composer'
|
|
1103
|
+
if (!bypass) {
|
|
1104
|
+
if (!opaque && !(chrome && hasImage)) return
|
|
1105
|
+
if (!opaque && bgImage.includes('blob:')) return
|
|
1106
|
+
} else if (kind === 'inner' && !opaque && !hasImage && cs.backdropFilter === 'none') {
|
|
1107
|
+
// Booking thousands of already-transparent boxes costs a repaint
|
|
1108
|
+
// pass per change for nothing: they have nothing to clear.
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
entry = {
|
|
1112
|
+
backgroundColor: el.style.backgroundColor,
|
|
1113
|
+
backgroundImage: el.style.backgroundImage,
|
|
1114
|
+
backdropFilter: el.style.backdropFilter,
|
|
1115
|
+
backgroundAttachment: el.style.backgroundAttachment,
|
|
1116
|
+
backgroundSize: el.style.backgroundSize,
|
|
1117
|
+
backgroundRepeat: el.style.backgroundRepeat,
|
|
1118
|
+
backgroundPosition: el.style.backgroundPosition,
|
|
1119
|
+
borderTopLeftRadius: el.style.borderTopLeftRadius,
|
|
1120
|
+
borderRight: el.style.borderRight,
|
|
1121
|
+
borderBottom: el.style.borderBottom,
|
|
1122
|
+
base: opaque ? bgColor : 'var(--dsw-alias-bg-base)',
|
|
1123
|
+
kind,
|
|
1124
|
+
}
|
|
1125
|
+
originals.set(el, entry)
|
|
1126
|
+
}
|
|
1127
|
+
// The role changes between passes (a panel that moves into the chrome
|
|
1128
|
+
// band, a box that becomes the composer): the entry keeps the box's
|
|
1129
|
+
// ORIGINAL paint, never the role it was booked with. A stale role
|
|
1130
|
+
// would leave the wallpaper recipe painted on a box that is no
|
|
1131
|
+
// longer the composer.
|
|
1132
|
+
entry.kind = kind
|
|
1133
|
+
const useAlpha = kind === 'sidebar' || barLike ? chromeAlpha : alpha
|
|
1134
|
+
if (isComposer) {
|
|
1135
|
+
/**
|
|
1136
|
+
* The composer sits above the conversation, so plain transparency
|
|
1137
|
+
* would show the text behind it. Paint the wallpaper *itself* here
|
|
1138
|
+
* with `fixed` attachment so it lines up pixel-for-pixel with the
|
|
1139
|
+
* canvas — and then reproduce every layer the canvas shows above
|
|
1140
|
+
* that image, in paint order: the mask veil, then each ancestor
|
|
1141
|
+
* surface the walk made translucent.
|
|
1142
|
+
*
|
|
1143
|
+
* Painting the image under the veil alone is what left the input
|
|
1144
|
+
* box reading brighter/clearer than the conversation around it:
|
|
1145
|
+
* the canvas is the same image *and* the frame/column/panel tints
|
|
1146
|
+
* on top, so the composer showed about (1 - panelOpacity)^n more
|
|
1147
|
+
* of the wallpaper than the surface it sits on. The ancestor
|
|
1148
|
+
* colours are read from computed style — the exact pixels the
|
|
1149
|
+
* canvas composites — so the two recipes can never drift apart.
|
|
1150
|
+
*/
|
|
1151
|
+
const tuned = settingsRef.current
|
|
1152
|
+
const fill = WP_FILL_STYLE[tuned.fillMode] ?? WP_FILL_STYLE.cover
|
|
1153
|
+
const veil = `color-mix(in srgb, var(--dsw-alias-bg-base) ${Math.round(tuned.maskOpacity * 100)}%, transparent)`
|
|
1154
|
+
const covers = []
|
|
1155
|
+
for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
1156
|
+
// A nested composer wrapper already paints this same recipe;
|
|
1157
|
+
// stacking it again would composite the image onto itself.
|
|
1158
|
+
if (originals.get(node)?.kind === 'composer') continue
|
|
1159
|
+
const above = getComputedStyle(node).backgroundColor
|
|
1160
|
+
// Innermost ancestor first: that one paints last, on top.
|
|
1161
|
+
if (colorAlpha(above) > 0) covers.push(`linear-gradient(${above}, ${above})`)
|
|
1162
|
+
if (node === frame) break
|
|
1163
|
+
}
|
|
1164
|
+
const layers = covers.length + 2
|
|
1165
|
+
el.style.backgroundColor = 'transparent'
|
|
1166
|
+
el.style.backgroundImage = [...covers, `linear-gradient(${veil}, ${veil})`, 'var(--dshwp-image)'].join(', ')
|
|
1167
|
+
el.style.backgroundSize = [...covers.map(() => 'auto'), 'auto', fill.size].join(', ')
|
|
1168
|
+
el.style.backgroundRepeat = [...covers.map(() => 'no-repeat'), 'no-repeat', fill.repeat].join(', ')
|
|
1169
|
+
el.style.backgroundPosition = Array.from({ length: layers }, () => 'center').join(', ')
|
|
1170
|
+
// Only the wallpaper itself needs viewport-anchored painting (it
|
|
1171
|
+
// has to line up with the canvas); the tints and the veil are flat
|
|
1172
|
+
// colours, so keeping them out of fixed attachment spares the
|
|
1173
|
+
// compositor a stack of viewport-sized layers.
|
|
1174
|
+
el.style.backgroundAttachment = [...covers.map(() => 'scroll'), 'scroll', 'fixed'].join(', ')
|
|
1175
|
+
el.style.backdropFilter = 'none'
|
|
1176
|
+
} else if (kind === 'inner') {
|
|
1177
|
+
// Chrome descendants: colourless, so only the region tint and the
|
|
1178
|
+
// separator lines remain.
|
|
1179
|
+
el.style.backgroundColor = 'transparent'
|
|
1180
|
+
} else if (barLike) {
|
|
1181
|
+
// The top strip is fully transparent (codeg's ws-transparent-bg):
|
|
1182
|
+
// the image shows straight through, and the hairline below keeps
|
|
1183
|
+
// it readable instead of a frost. The descendants that actually
|
|
1184
|
+
// paint the strip's colour go transparent too — but they never
|
|
1185
|
+
// get a border, which is what produced stray hairlines before.
|
|
1186
|
+
el.style.backgroundColor = 'transparent'
|
|
1187
|
+
} else {
|
|
1188
|
+
el.style.backgroundColor = `color-mix(in srgb, ${entry.base} ${useAlpha}%, transparent)`
|
|
1189
|
+
}
|
|
1190
|
+
if (!isComposer && cs.backgroundImage !== 'none') el.style.backgroundImage = 'none'
|
|
1191
|
+
if (kind === 'sidebar') {
|
|
1192
|
+
// Frost without the dark tint: the blur keeps the sidebar
|
|
1193
|
+
// readable over the image, and without a colour-mix layer it no
|
|
1194
|
+
// longer reads as one big block (the user's "色块" was exactly
|
|
1195
|
+
// that tint over a bright wallpaper).
|
|
1196
|
+
el.style.backgroundColor = 'transparent'
|
|
1197
|
+
el.style.backdropFilter = WP_FROST
|
|
1198
|
+
el.style.borderRight = `1px solid ${line}`
|
|
1199
|
+
} else if (kind === 'bar') {
|
|
1200
|
+
el.style.backdropFilter = 'none'
|
|
1201
|
+
el.style.borderBottom = `1px solid ${line}`
|
|
1202
|
+
} else {
|
|
1203
|
+
el.style.backdropFilter = 'none'
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* One column's translucent tint stack: the surfaces that span the
|
|
1209
|
+
* column and sit above the base tint. Collapsed into a single colour
|
|
1210
|
+
* — the innermost layer's colour at the combined alpha — because the
|
|
1211
|
+
* strip needs one hard-edged stop per column, not a stack.
|
|
1212
|
+
*/
|
|
1213
|
+
const columnTint = (col) => {
|
|
1214
|
+
const colRect = col.getBoundingClientRect()
|
|
1215
|
+
let alpha = 0
|
|
1216
|
+
let css = null
|
|
1217
|
+
let frontier = [col]
|
|
1218
|
+
for (let depth = 0; depth < 6 && frontier.length > 0; depth++) {
|
|
1219
|
+
const next = []
|
|
1220
|
+
for (const el of frontier) {
|
|
1221
|
+
if (!(el instanceof HTMLElement)) continue
|
|
1222
|
+
const entry = originals.get(el)
|
|
1223
|
+
const rect = el.getBoundingClientRect()
|
|
1224
|
+
// Only a surface that spans the whole column counts: a card or
|
|
1225
|
+
// a message row is content, never the column's own tint.
|
|
1226
|
+
if (entry && rect.width >= colRect.width * 0.95 && (entry.kind === 'panel' || entry.kind === 'frame')) {
|
|
1227
|
+
const above = getComputedStyle(el).backgroundColor
|
|
1228
|
+
const a = colorAlpha(above)
|
|
1229
|
+
if (a > 0) {
|
|
1230
|
+
alpha = 1 - (1 - alpha) * (1 - a)
|
|
1231
|
+
css = above
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
next.push(...el.children)
|
|
1235
|
+
}
|
|
1236
|
+
frontier = next
|
|
1237
|
+
}
|
|
1238
|
+
return css === null || alpha <= 0.001 ? null : withAlpha(css, Number(alpha.toFixed(3)))
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* The window's top strip — the titlebar band above the first laid-out
|
|
1243
|
+
* column — has no element of its own, so it showed only the base tint
|
|
1244
|
+
* and the wallpaper read brighter there than in the column beneath it.
|
|
1245
|
+
* Paint the base surface as a horizontal gradient that carries each
|
|
1246
|
+
* column's tint stack up across the strip above it: the top bar
|
|
1247
|
+
* continues whichever column it sits on instead of forming a band.
|
|
1248
|
+
* The base colour stays the element's own background-color, so
|
|
1249
|
+
* nothing below the strip changes.
|
|
1250
|
+
*/
|
|
1251
|
+
const applyColumnTints = () => {
|
|
1252
|
+
const root = laidOutRoot()
|
|
1253
|
+
if (root === null) return
|
|
1254
|
+
const rootRect = root.getBoundingClientRect()
|
|
1255
|
+
// One layer per tinted column, hard-edged to that column's x-range:
|
|
1256
|
+
// the grid also holds full-width overlays and a thin splitter, and a
|
|
1257
|
+
// single shared gradient would let those split the stop order.
|
|
1258
|
+
const columns = []
|
|
1259
|
+
for (const col of root.children) {
|
|
1260
|
+
if (!(col instanceof HTMLElement)) continue
|
|
1261
|
+
const rect = col.getBoundingClientRect()
|
|
1262
|
+
// Columns only: a short child is a row (the top bar itself).
|
|
1263
|
+
if (rect.width <= 0 || rect.height < rootRect.height * 0.2 || rect.top <= rootRect.top) continue
|
|
1264
|
+
columns.push({ col, rect })
|
|
1265
|
+
}
|
|
1266
|
+
if (columns.length === 0) {
|
|
1267
|
+
root.style.backgroundImage = ''
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
// Only the strip above the highest column is empty of paint: below
|
|
1271
|
+
// that line every column draws its own stack.
|
|
1272
|
+
const stripHeight = Math.max(0, Math.round(Math.min(...columns.map((c) => c.rect.top)) - rootRect.top))
|
|
1273
|
+
const layers = []
|
|
1274
|
+
for (const { col, rect } of columns) {
|
|
1275
|
+
const tint = columnTint(col)
|
|
1276
|
+
if (tint === null || stripHeight <= 0) continue
|
|
1277
|
+
const from = Math.round(rect.left - rootRect.left)
|
|
1278
|
+
const to = Math.round(rect.right - rootRect.left)
|
|
1279
|
+
layers.push(
|
|
1280
|
+
`linear-gradient(90deg, transparent 0 ${from}px, ${tint} ${from}px ${to}px, transparent ${to}px 100%)`
|
|
1281
|
+
)
|
|
1282
|
+
}
|
|
1283
|
+
root.style.backgroundImage = layers.join(', ')
|
|
1284
|
+
root.style.backgroundSize = layers.map(() => `100% ${stripHeight}px`).join(', ')
|
|
1285
|
+
root.style.backgroundRepeat = layers.map(() => 'no-repeat').join(', ')
|
|
1286
|
+
root.style.backgroundPosition = layers.map(() => 'left top').join(', ')
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
flattenTopLeftCorners()
|
|
1290
|
+
apply(frame, 'frame')
|
|
1291
|
+
// Breadth-first walk over the app's own tree. Everything opaque that
|
|
1292
|
+
// sits above the wallpaper has to be made translucent, so the walk
|
|
1293
|
+
// errs on the side of visiting too much rather than too little: a
|
|
1294
|
+
// single opaque panel left behind hides the wallpaper completely.
|
|
1295
|
+
const minChildArea = frameRect.width * frameRect.height * 0.05
|
|
1296
|
+
const skipped = new Set()
|
|
1297
|
+
/** element -> depth inside a top bar (1 = child, 2 = grandchild) */
|
|
1298
|
+
const barContext = new Map()
|
|
1299
|
+
/** composer subtree: colourless descendants under its wallpaper paint */
|
|
1300
|
+
const composerContext = new Map()
|
|
1301
|
+
const walk = (root, depthLimit) => {
|
|
1302
|
+
let frontier = [...root.children]
|
|
1303
|
+
for (let depth = 0; depth < depthLimit && frontier.length > 0; depth++) {
|
|
1304
|
+
const next = []
|
|
1305
|
+
for (const el of frontier) {
|
|
1306
|
+
if (!(el instanceof HTMLElement)) continue
|
|
1307
|
+
// Only the app's own tree holds paint surfaces. DOM another
|
|
1308
|
+
// plugin appended to <body> (the desktop pet's portal) is off
|
|
1309
|
+
// limits — treating it turned the pet into a blank block.
|
|
1310
|
+
if (el.closest('#root') === null) {
|
|
1311
|
+
// One exception: chrome the shell renders OUTSIDE #root. DSH's
|
|
1312
|
+
// titlebar row sits above the app tree, and nothing inside
|
|
1313
|
+
// #root can make it translucent. The shape test is deliberately
|
|
1314
|
+
// strict — full width, short, flush with the top — and `apply`
|
|
1315
|
+
// only recolours genuinely opaque boxes, so a near miss costs
|
|
1316
|
+
// nothing.
|
|
1317
|
+
const outer = el.getBoundingClientRect()
|
|
1318
|
+
const isTopStrip =
|
|
1319
|
+
outer.height > 0 &&
|
|
1320
|
+
outer.height <= 96 &&
|
|
1321
|
+
outer.top <= frameRect.top + 8 &&
|
|
1322
|
+
outer.width >= frameRect.width * 0.6
|
|
1323
|
+
if (isTopStrip) apply(el, 'bar')
|
|
1324
|
+
continue
|
|
1325
|
+
}
|
|
1326
|
+
const kind = el === frame ? 'frame' : classify(el, frameRect)
|
|
1327
|
+
const barDepth = barContext.get(el) ?? 0
|
|
1328
|
+
const composerDepth = composerContext.get(el) ?? 0
|
|
1329
|
+
const probeRect = el.getBoundingClientRect()
|
|
1330
|
+
// Pure geometry, no classification: everything that lies wholly
|
|
1331
|
+
// inside the titlebar band or the sidebar column loses its own
|
|
1332
|
+
// colour. Classification kept missing these — the row stayed
|
|
1333
|
+
// painted while `classify` returned no kind — and the user's
|
|
1334
|
+
// rule is simply "lines, not blocks".
|
|
1335
|
+
const inChromeBand =
|
|
1336
|
+
probeRect.height > 0 &&
|
|
1337
|
+
(probeRect.bottom <= frameRect.top + 96 ||
|
|
1338
|
+
probeRect.right <= frameRect.left + frameRect.width * 0.35)
|
|
1339
|
+
const regionKind = kind === 'sidebar' || kind === 'frame' || kind === 'bar'
|
|
1340
|
+
// Recognised BEFORE the prune below: a one-line composer is
|
|
1341
|
+
// smaller than the prune threshold, and pruning its subtree is
|
|
1342
|
+
// what used to leave the translucent boxes inside the editor
|
|
1343
|
+
// untouched. `isComposerBox` is anchored to the box's bottom edge
|
|
1344
|
+
// (see its comment), so a stretched composer keeps the recipe.
|
|
1345
|
+
// Settings pages and dialogs are excluded even when they happen
|
|
1346
|
+
// to end in a text box — they are content, by decision.
|
|
1347
|
+
const isComposerHere =
|
|
1348
|
+
!regionKind &&
|
|
1349
|
+
!(kind === 'panel' && isContentPanel(el)) &&
|
|
1350
|
+
isComposerBox(el, probeRect, frameRect)
|
|
1351
|
+
if (kind === 'panel' && isContentPanel(el)) {
|
|
1352
|
+
// Settings pages and dialogs stay opaque by decision.
|
|
1353
|
+
skipped.add(el)
|
|
1354
|
+
} else if (barDepth > 0 || composerDepth > 0 || (inChromeBand && !regionKind)) {
|
|
1355
|
+
// Inside the chrome or the composer: give up the colour
|
|
1356
|
+
// entirely — that removes the stray blocks. The REGION
|
|
1357
|
+
// elements themselves are exempt: the sidebar keeps its tint
|
|
1358
|
+
// and frost, the bar keeps its hairline — only inner boxes
|
|
1359
|
+
// (panels and unclassified) go colourless.
|
|
1360
|
+
apply(el, 'inner')
|
|
1361
|
+
} else {
|
|
1362
|
+
// `apply` itself only touches opaque surfaces (and chrome
|
|
1363
|
+
// that carries a background image), so the paint decision
|
|
1364
|
+
// does not need a size threshold — and must not have one:
|
|
1365
|
+
// small nested panels are exactly what used to stay opaque
|
|
1366
|
+
// and hide the wallpaper.
|
|
1367
|
+
apply(el, kind ?? 'panel')
|
|
1368
|
+
}
|
|
1369
|
+
if (isComposerHere) {
|
|
1370
|
+
// Only the outermost surface of a composer stack paints the
|
|
1371
|
+
// recipe. The wrappers nested inside it are fully covered by
|
|
1372
|
+
// that paint, so painting them too only stacked three
|
|
1373
|
+
// full-viewport wallpapers on the compositor for identical
|
|
1374
|
+
// pixels; they go transparent instead.
|
|
1375
|
+
apply(el, composerDepth === 0 ? 'composer' : 'inner')
|
|
1376
|
+
}
|
|
1377
|
+
const barDepthNext =
|
|
1378
|
+
kind === 'bar' || kind === 'sidebar' ? 1 : barDepth > 0 && barDepth < 3 ? barDepth + 1 : 0
|
|
1379
|
+
const composerDepthNext =
|
|
1380
|
+
isComposerHere ? 1 : composerDepth > 0 && composerDepth < 3 ? composerDepth + 1 : 0
|
|
1381
|
+
if (barDepthNext > 0) {
|
|
1382
|
+
for (const child of el.children) barContext.set(child, barDepthNext)
|
|
1383
|
+
}
|
|
1384
|
+
if (composerDepthNext > 0) {
|
|
1385
|
+
// Grandchildren carry translucent backgrounds too — clearing
|
|
1386
|
+
// only direct children left a second veil inside the editor,
|
|
1387
|
+
// which read as a brightness mismatch against the canvas.
|
|
1388
|
+
for (const child of el.children) composerContext.set(child, composerDepthNext)
|
|
1389
|
+
}
|
|
1390
|
+
const area = probeRect.width * probeRect.height
|
|
1391
|
+
// Prune small laid-out boxes, but never a zero-area element:
|
|
1392
|
+
// `display: contents` wrappers measure 0x0 while holding the
|
|
1393
|
+
// entire application frame below them. The top band is exempt:
|
|
1394
|
+
// a full-width 40px titlebar row is only ~4 % of the frame and
|
|
1395
|
+
// pruning it is exactly why the top bar kept its own colour.
|
|
1396
|
+
if (area > 0 && area < minChildArea && probeRect.top > frameRect.top + 96) continue
|
|
1397
|
+
next.push(...el.children)
|
|
1398
|
+
}
|
|
1399
|
+
frontier = next
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
walk(mountRoot, 30)
|
|
1403
|
+
// The window's top strip — titlebar height above the first laid-out
|
|
1404
|
+
// column — has no element of its own, so it showed the bare base tint
|
|
1405
|
+
// and the wallpaper therefore read brighter there than in the column
|
|
1406
|
+
// under it. Continue each column's own tint stack across that strip
|
|
1407
|
+
// so the top bar is part of the surface it sits on, not a band.
|
|
1408
|
+
applyColumnTints()
|
|
1409
|
+
// Only surfaces we deliberately skipped are reverted; one that merely
|
|
1410
|
+
// fell outside this pass keeps its paint (a transient miss must not
|
|
1411
|
+
// flip the whole layer on and off).
|
|
1412
|
+
for (const [el, orig] of [...originals]) {
|
|
1413
|
+
if (!el.isConnected) {
|
|
1414
|
+
originals.delete(el)
|
|
1415
|
+
} else if (skipped.has(el)) {
|
|
1416
|
+
restoreOne(el, orig)
|
|
1417
|
+
originals.delete(el)
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
repaintRef.current = repaint
|
|
1422
|
+
|
|
1423
|
+
let repaintTimer = null
|
|
1424
|
+
let frameHandle = null
|
|
1425
|
+
let lastLeadAt = 0
|
|
1426
|
+
/**
|
|
1427
|
+
* Repaint on both edges of a churn burst.
|
|
1428
|
+
*
|
|
1429
|
+
* Switching sessions replaces the conversation subtree in one commit, and
|
|
1430
|
+
* the freshly mounted panels — the "载入历史…" state included — still
|
|
1431
|
+
* carry their own opaque colours. A trailing-only debounce therefore left
|
|
1432
|
+
* the wallpaper hidden for its whole window: the flash the user sees
|
|
1433
|
+
* before it "refreshes" back. The leading repaint runs on the next frame,
|
|
1434
|
+
* before the browser paints the new content, and is throttled so a
|
|
1435
|
+
* streaming answer cannot turn into a repaint per token; the trailing one
|
|
1436
|
+
* then catches whatever mounted after it.
|
|
1437
|
+
*/
|
|
1438
|
+
const scheduleRepaint = () => {
|
|
1439
|
+
const now = Date.now()
|
|
1440
|
+
if (frameHandle === null && now - lastLeadAt >= 150) {
|
|
1441
|
+
lastLeadAt = now
|
|
1442
|
+
frameHandle = requestAnimationFrame(() => {
|
|
1443
|
+
frameHandle = null
|
|
1444
|
+
repaint()
|
|
1445
|
+
})
|
|
1446
|
+
}
|
|
1447
|
+
if (repaintTimer !== null) return
|
|
1448
|
+
repaintTimer = setTimeout(() => {
|
|
1449
|
+
repaintTimer = null
|
|
1450
|
+
repaint()
|
|
1451
|
+
}, 500)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const mount = async () => {
|
|
1455
|
+
try {
|
|
1456
|
+
const res = await fetch(`${API}/api/current`)
|
|
1457
|
+
if (!res.ok) {
|
|
1458
|
+
console.warn('dsh-wallpaper: wallpaper bytes unavailable', res.status)
|
|
1459
|
+
return
|
|
1460
|
+
}
|
|
1461
|
+
const blob = await res.blob()
|
|
1462
|
+
if (disposed) return
|
|
1463
|
+
wallpaperUrl = URL.createObjectURL(blob)
|
|
1464
|
+
// The image is a custom property consumed by the fixed body::before
|
|
1465
|
+
// layer, and <body> itself goes transparent so that layer is not
|
|
1466
|
+
// hidden behind an opaque body background.
|
|
1467
|
+
bodyBackground = {
|
|
1468
|
+
backgroundImage: mountRoot.style.backgroundImage,
|
|
1469
|
+
backgroundColor: mountRoot.style.backgroundColor,
|
|
1470
|
+
backgroundSize: mountRoot.style.backgroundSize,
|
|
1471
|
+
backgroundPosition: mountRoot.style.backgroundPosition,
|
|
1472
|
+
backgroundAttachment: mountRoot.style.backgroundAttachment,
|
|
1473
|
+
backgroundRepeat: mountRoot.style.backgroundRepeat,
|
|
1474
|
+
}
|
|
1475
|
+
document.documentElement.style.setProperty('--dshwp-image', `url("${wallpaperUrl}")`)
|
|
1476
|
+
mountRoot.style.backgroundImage = 'none'
|
|
1477
|
+
mountRoot.style.backgroundColor = 'transparent'
|
|
1478
|
+
applyBackgroundStack()
|
|
1479
|
+
repaint()
|
|
1480
|
+
const kinds = [...originals.values()].map((entry) => entry.kind)
|
|
1481
|
+
console.info('dsh-wallpaper: layer mounted', {
|
|
1482
|
+
surfaces: originals.size,
|
|
1483
|
+
kinds,
|
|
1484
|
+
settings: settingsRef.current,
|
|
1485
|
+
})
|
|
1486
|
+
// Child-list churn catches mounts (rails, panels); the interval
|
|
1487
|
+
// catches class/style changes without observing every attribute
|
|
1488
|
+
// write the chat stream produces.
|
|
1489
|
+
observer = new MutationObserver(scheduleRepaint)
|
|
1490
|
+
observer.observe(mountRoot, { childList: true, subtree: true })
|
|
1491
|
+
scanTimer = setInterval(scheduleRepaint, 3000)
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
/* wallpaper layer is best-effort; the app must never break */
|
|
1494
|
+
console.warn('dsh-wallpaper: layer mount failed', error)
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
void mount()
|
|
1499
|
+
return () => {
|
|
1500
|
+
disposed = true
|
|
1501
|
+
repaintRef.current = null
|
|
1502
|
+
if (repaintTimer !== null) clearTimeout(repaintTimer)
|
|
1503
|
+
if (frameHandle !== null) cancelAnimationFrame(frameHandle)
|
|
1504
|
+
if (scanTimer !== null) clearInterval(scanTimer)
|
|
1505
|
+
if (observer) observer.disconnect()
|
|
1506
|
+
restoreAll()
|
|
1507
|
+
originals.clear()
|
|
1508
|
+
if (bodyBackground !== null) {
|
|
1509
|
+
mountRoot.style.backgroundImage = bodyBackground.backgroundImage
|
|
1510
|
+
mountRoot.style.backgroundColor = bodyBackground.backgroundColor
|
|
1511
|
+
mountRoot.style.backgroundSize = bodyBackground.backgroundSize
|
|
1512
|
+
mountRoot.style.backgroundPosition = bodyBackground.backgroundPosition
|
|
1513
|
+
mountRoot.style.backgroundAttachment = bodyBackground.backgroundAttachment
|
|
1514
|
+
mountRoot.style.backgroundRepeat = bodyBackground.backgroundRepeat
|
|
1515
|
+
bodyBackground = null
|
|
1516
|
+
}
|
|
1517
|
+
document.documentElement.style.removeProperty('--dshwp-image')
|
|
1518
|
+
// Hand the caption strip back to the theme (the shell re-reads it on
|
|
1519
|
+
// this body mutation and restores the window-controls overlay).
|
|
1520
|
+
document.body.style.removeProperty(CAPTION_FILL)
|
|
1521
|
+
if (backgroundStyle !== null) {
|
|
1522
|
+
backgroundStyle.remove()
|
|
1523
|
+
backgroundStyle = null
|
|
1524
|
+
}
|
|
1525
|
+
if (wallpaperUrl) URL.revokeObjectURL(wallpaperUrl)
|
|
1526
|
+
if (frameOriginalPosition !== null) frame.style.position = frameOriginalPosition
|
|
1527
|
+
}
|
|
1528
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1529
|
+
}, [hostRef, enabled, version])
|
|
1530
|
+
|
|
1531
|
+
// Setting changes (opacity, mask, blur, fill) repaint in place: the image
|
|
1532
|
+
// bytes and the surface bookkeeping stay exactly as they are.
|
|
1533
|
+
useEffect(() => {
|
|
1534
|
+
if (repaintRef.current) repaintRef.current()
|
|
1535
|
+
}, [settings])
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// ─── Sync ───────────────────────────────────────────────────────────────
|
|
1539
|
+
|
|
1540
|
+
function useConfigSync(store) {
|
|
1541
|
+
useEffect(() => {
|
|
1542
|
+
let disposed = false
|
|
1543
|
+
const load = () => {
|
|
1544
|
+
apiJson('/api/config')
|
|
1545
|
+
.then((cfg) => {
|
|
1546
|
+
if (!disposed) store.setLoaded(cfg.config)
|
|
1547
|
+
})
|
|
1548
|
+
.catch(() => {
|
|
1549
|
+
if (!disposed) store.setLoaded(store.getSnapshot().config)
|
|
1550
|
+
})
|
|
1551
|
+
apiJson('/api/version')
|
|
1552
|
+
.then((data) => {
|
|
1553
|
+
if (!disposed) store.setWallpaperVersion(data.version)
|
|
1554
|
+
})
|
|
1555
|
+
.catch(() => {})
|
|
1556
|
+
}
|
|
1557
|
+
load()
|
|
1558
|
+
const onFocus = () => load()
|
|
1559
|
+
window.addEventListener('focus', onFocus)
|
|
1560
|
+
return () => {
|
|
1561
|
+
disposed = true
|
|
1562
|
+
window.removeEventListener('focus', onFocus)
|
|
1563
|
+
}
|
|
1564
|
+
}, [store])
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** Poll the host wallpaper version so other windows' changes propagate. */
|
|
1568
|
+
function useWallpaperVersionSync(store, enabled) {
|
|
1569
|
+
useEffect(() => {
|
|
1570
|
+
if (!enabled) return
|
|
1571
|
+
let disposed = false
|
|
1572
|
+
const poll = () => {
|
|
1573
|
+
apiJson('/api/version')
|
|
1574
|
+
.then((data) => {
|
|
1575
|
+
if (!disposed) store.setWallpaperVersion(data.version)
|
|
1576
|
+
})
|
|
1577
|
+
.catch(() => {})
|
|
1578
|
+
}
|
|
1579
|
+
poll()
|
|
1580
|
+
const timer = setInterval(poll, 5000)
|
|
1581
|
+
return () => {
|
|
1582
|
+
disposed = true
|
|
1583
|
+
clearInterval(timer)
|
|
1584
|
+
}
|
|
1585
|
+
}, [store, enabled])
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// ─── Settings section (the wallpaper page) ──────────────────────────────
|
|
1589
|
+
|
|
1590
|
+
function CurrentWallpaper({ store, t }) {
|
|
1591
|
+
const config = useStore(store, (s) => s.config)
|
|
1592
|
+
const version = useStore(store, (s) => s.wallpaperVersion)
|
|
1593
|
+
const hasWallpaper = version !== 'none'
|
|
1594
|
+
const [url, setUrl] = useState(null)
|
|
1595
|
+
|
|
1596
|
+
useEffect(() => {
|
|
1597
|
+
if (!hasWallpaper) {
|
|
1598
|
+
setUrl(null)
|
|
1599
|
+
return
|
|
1600
|
+
}
|
|
1601
|
+
let cancelled = false
|
|
1602
|
+
let owned = null
|
|
1603
|
+
apiBlobUrl('/api/current')
|
|
1604
|
+
.then((u) => {
|
|
1605
|
+
if (cancelled) {
|
|
1606
|
+
URL.revokeObjectURL(u)
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
owned = u
|
|
1610
|
+
setUrl(u)
|
|
1611
|
+
})
|
|
1612
|
+
.catch(() => {})
|
|
1613
|
+
return () => {
|
|
1614
|
+
cancelled = true
|
|
1615
|
+
if (owned) URL.revokeObjectURL(owned)
|
|
1616
|
+
}
|
|
1617
|
+
}, [hasWallpaper, version])
|
|
1618
|
+
|
|
1619
|
+
const source = config.wallpaperSourceUrl
|
|
1620
|
+
const label = source ? source.replace('https://wallhaven.cc/w/', '') : t('wallpaper.localImage')
|
|
1621
|
+
return h(
|
|
1622
|
+
'div',
|
|
1623
|
+
{ className: 'dshwp-current' },
|
|
1624
|
+
h(
|
|
1625
|
+
'div',
|
|
1626
|
+
{ className: 'dshwp-current-thumb' },
|
|
1627
|
+
url ? h('img', { src: url, alt: '' }) : h('div', { className: 'dshwp-card-empty' }, h(ImageIcon, { size: 20 }))
|
|
1628
|
+
),
|
|
1629
|
+
h(
|
|
1630
|
+
'div',
|
|
1631
|
+
{ style: { minWidth: 0 } },
|
|
1632
|
+
h('div', { className: 'dshwp-srow-title' }, hasWallpaper ? t('wallpaper.current') : t('wallpaper.none')),
|
|
1633
|
+
h('div', { className: 'dshwp-srow-desc' },
|
|
1634
|
+
hasWallpaper ? `${label} · ${config.wallpaperSourceUrl ? t('wallpaper.credit') : ''}`.trim() : ''
|
|
1635
|
+
)
|
|
1636
|
+
)
|
|
1637
|
+
)
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function WallpaperSection({ store, t }) {
|
|
1641
|
+
const settings = useStore(store, (s) => s.display)
|
|
1642
|
+
const updateSettings = store.setDisplay
|
|
1643
|
+
const config = useStore(store, (s) => s.config)
|
|
1644
|
+
const version = useStore(store, (s) => s.wallpaperVersion)
|
|
1645
|
+
const [error, setError] = useState(null)
|
|
1646
|
+
const [notice, setNotice] = useState(null)
|
|
1647
|
+
const [uploading, setUploading] = useState(false)
|
|
1648
|
+
const fileInputRef = useRef(null)
|
|
1649
|
+
|
|
1650
|
+
const putConfig = useCallback(
|
|
1651
|
+
(patch) =>
|
|
1652
|
+
apiJson('/api/config', { method: 'PUT', body: JSON.stringify(patch) })
|
|
1653
|
+
.then((data) => store.setConfig(data.config))
|
|
1654
|
+
.catch((err) => setError(err.message)),
|
|
1655
|
+
[store]
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
const refreshVersion = useCallback(async () => {
|
|
1659
|
+
try {
|
|
1660
|
+
const data = await apiJson('/api/version')
|
|
1661
|
+
store.setWallpaperVersion(data.version)
|
|
1662
|
+
} catch {
|
|
1663
|
+
/* the version poll covers transient failures */
|
|
1664
|
+
}
|
|
1665
|
+
}, [store])
|
|
1666
|
+
|
|
1667
|
+
const onUploadFile = async (file) => {
|
|
1668
|
+
if (!file) return
|
|
1669
|
+
setError(null)
|
|
1670
|
+
setNotice(null)
|
|
1671
|
+
setUploading(true)
|
|
1672
|
+
try {
|
|
1673
|
+
if (file.size > MAX_WP_BYTES_CLIENT + 1) {
|
|
1674
|
+
throw new Error(
|
|
1675
|
+
t('wallpaper.tooLarge', {
|
|
1676
|
+
actual: formatBytes(file.size),
|
|
1677
|
+
limit: formatBytes(MAX_WP_BYTES_CLIENT),
|
|
1678
|
+
})
|
|
1679
|
+
)
|
|
1680
|
+
}
|
|
1681
|
+
const res = await fetch(`${API}/api/upload`, {
|
|
1682
|
+
method: 'POST',
|
|
1683
|
+
headers: { 'content-type': file.type || 'application/octet-stream' },
|
|
1684
|
+
body: file,
|
|
1685
|
+
})
|
|
1686
|
+
const data = await res.json().catch(() => null)
|
|
1687
|
+
if (!res.ok || !data?.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`)
|
|
1688
|
+
await putConfig({ wallpaperEnabled: true, wallpaperSourceUrl: null })
|
|
1689
|
+
await refreshVersion()
|
|
1690
|
+
setNotice(t('wallpaper.appliedToast'))
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
setError(err.message)
|
|
1693
|
+
} finally {
|
|
1694
|
+
setUploading(false)
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
return h(
|
|
1699
|
+
'div',
|
|
1700
|
+
{ className: 'dshwp-section' },
|
|
1701
|
+
h('style', STYLE_ATTRS, styles),
|
|
1702
|
+
h(
|
|
1703
|
+
'div',
|
|
1704
|
+
{ className: 'dshwp-group' },
|
|
1705
|
+
h(
|
|
1706
|
+
SettingsRow,
|
|
1707
|
+
{ title: t('settings.wallpaper'), description: t('settings.wallpaperHint') },
|
|
1708
|
+
h(Switch, {
|
|
1709
|
+
on: config.wallpaperEnabled,
|
|
1710
|
+
onChange: (v) => putConfig({ wallpaperEnabled: v }),
|
|
1711
|
+
label: t('settings.wallpaper'),
|
|
1712
|
+
})
|
|
1713
|
+
),
|
|
1714
|
+
h(
|
|
1715
|
+
SettingsRow,
|
|
1716
|
+
{
|
|
1717
|
+
title: t('wallpaper.fill'),
|
|
1718
|
+
description: t('settings.fillHint'),
|
|
1719
|
+
},
|
|
1720
|
+
h(
|
|
1721
|
+
'select',
|
|
1722
|
+
{
|
|
1723
|
+
className: 'dshwp-input32',
|
|
1724
|
+
value: settings.fillMode,
|
|
1725
|
+
'aria-label': t('wallpaper.fill'),
|
|
1726
|
+
onChange: (event) => updateSettings({ fillMode: event.target.value }),
|
|
1727
|
+
},
|
|
1728
|
+
...WP_FILL_MODES.map((mode) =>
|
|
1729
|
+
h('option', { key: mode, value: mode }, t(`wallpaper.fill.${mode}`))
|
|
1730
|
+
)
|
|
1731
|
+
)
|
|
1732
|
+
),
|
|
1733
|
+
h(
|
|
1734
|
+
SettingsRow,
|
|
1735
|
+
{
|
|
1736
|
+
title: t('wallpaper.mask'),
|
|
1737
|
+
description: t('settings.maskHint'),
|
|
1738
|
+
},
|
|
1739
|
+
h('input', {
|
|
1740
|
+
type: 'range',
|
|
1741
|
+
min: WP_RANGE.maskOpacity.min,
|
|
1742
|
+
max: WP_RANGE.maskOpacity.max,
|
|
1743
|
+
step: WP_RANGE.maskOpacity.step,
|
|
1744
|
+
value: settings.maskOpacity,
|
|
1745
|
+
className: 'dshwp-range',
|
|
1746
|
+
style: { width: 148 },
|
|
1747
|
+
'aria-label': t('wallpaper.mask'),
|
|
1748
|
+
onChange: (event) => updateSettings({ maskOpacity: clampSetting('maskOpacity', event.target.value) }),
|
|
1749
|
+
}),
|
|
1750
|
+
h('span', { className: 'dshwp-muted' }, `${Math.round(settings.maskOpacity * 100)}%`)
|
|
1751
|
+
),
|
|
1752
|
+
h(
|
|
1753
|
+
SettingsRow,
|
|
1754
|
+
{ title: t('wallpaper.blur'), description: t('settings.blurHint') },
|
|
1755
|
+
h('input', {
|
|
1756
|
+
type: 'range',
|
|
1757
|
+
min: WP_RANGE.imageBlur.min,
|
|
1758
|
+
max: WP_RANGE.imageBlur.max,
|
|
1759
|
+
step: WP_RANGE.imageBlur.step,
|
|
1760
|
+
value: settings.imageBlur,
|
|
1761
|
+
className: 'dshwp-range',
|
|
1762
|
+
style: { width: 148 },
|
|
1763
|
+
'aria-label': t('wallpaper.blur'),
|
|
1764
|
+
onChange: (event) => updateSettings({ imageBlur: clampSetting('imageBlur', event.target.value) }),
|
|
1765
|
+
}),
|
|
1766
|
+
h('span', { className: 'dshwp-muted' }, `${settings.imageBlur}px`)
|
|
1767
|
+
),
|
|
1768
|
+
h(
|
|
1769
|
+
SettingsRow,
|
|
1770
|
+
{
|
|
1771
|
+
title: t('wallpaper.panel'),
|
|
1772
|
+
description: t('settings.panelHint'),
|
|
1773
|
+
},
|
|
1774
|
+
h('input', {
|
|
1775
|
+
type: 'range',
|
|
1776
|
+
min: WP_RANGE.panelOpacity.min,
|
|
1777
|
+
max: WP_RANGE.panelOpacity.max,
|
|
1778
|
+
step: WP_RANGE.panelOpacity.step,
|
|
1779
|
+
value: settings.panelOpacity,
|
|
1780
|
+
className: 'dshwp-range',
|
|
1781
|
+
style: { width: 148 },
|
|
1782
|
+
'aria-label': t('wallpaper.panel'),
|
|
1783
|
+
onChange: (event) => updateSettings({ panelOpacity: clampSetting('panelOpacity', event.target.value) }),
|
|
1784
|
+
}),
|
|
1785
|
+
h('span', { className: 'dshwp-muted' }, `${Math.round(settings.panelOpacity * 100)}%`)
|
|
1786
|
+
),
|
|
1787
|
+
h(
|
|
1788
|
+
SettingsRow,
|
|
1789
|
+
{ title: t('wallpaper.current'), last: true },
|
|
1790
|
+
h(
|
|
1791
|
+
'div',
|
|
1792
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
|
|
1793
|
+
h(Boundary, null, h(CurrentWallpaper, { store, t })),
|
|
1794
|
+
h(
|
|
1795
|
+
'button',
|
|
1796
|
+
{
|
|
1797
|
+
type: 'button',
|
|
1798
|
+
className: 'dshwp-btn32',
|
|
1799
|
+
disabled: uploading,
|
|
1800
|
+
onClick: () => fileInputRef.current?.click(),
|
|
1801
|
+
},
|
|
1802
|
+
uploading ? h(Spinner) : t('wallpaper.upload')
|
|
1803
|
+
),
|
|
1804
|
+
version !== 'none'
|
|
1805
|
+
? h(
|
|
1806
|
+
'button',
|
|
1807
|
+
{
|
|
1808
|
+
type: 'button',
|
|
1809
|
+
className: 'dshwp-btn32',
|
|
1810
|
+
onClick: () => {
|
|
1811
|
+
setError(null)
|
|
1812
|
+
apiJson('/api/clear', { method: 'POST' })
|
|
1813
|
+
.then(() => apiJson('/api/config'))
|
|
1814
|
+
.then((data) => {
|
|
1815
|
+
store.setConfig(data.config)
|
|
1816
|
+
return refreshVersion()
|
|
1817
|
+
})
|
|
1818
|
+
.catch((err) => setError(err.message))
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
t('wallpaper.remove')
|
|
1822
|
+
)
|
|
1823
|
+
: null
|
|
1824
|
+
)
|
|
1825
|
+
),
|
|
1826
|
+
h('input', {
|
|
1827
|
+
ref: fileInputRef,
|
|
1828
|
+
type: 'file',
|
|
1829
|
+
accept: 'image/jpeg,image/png,image/webp',
|
|
1830
|
+
style: { display: 'none' },
|
|
1831
|
+
onChange: (event) => {
|
|
1832
|
+
void onUploadFile(event.target.files?.[0])
|
|
1833
|
+
event.target.value = ''
|
|
1834
|
+
},
|
|
1835
|
+
}),
|
|
1836
|
+
error ? h('p', { className: 'dshwp-error', style: { marginTop: 8 } }, error) : null,
|
|
1837
|
+
notice ? h('p', { className: 'dshwp-ok', style: { marginTop: 8 } }, notice) : null
|
|
1838
|
+
),
|
|
1839
|
+
h(Boundary, null, h(WallpaperMarket, { store, t }))
|
|
1840
|
+
)
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ─── Overlay root (the layer itself) ────────────────────────────────────
|
|
1844
|
+
|
|
1845
|
+
function OverlayRoot({ store }) {
|
|
1846
|
+
const hostRef = useRef(null)
|
|
1847
|
+
const config = useStore(store, (s) => s.config)
|
|
1848
|
+
const version = useStore(store, (s) => s.wallpaperVersion)
|
|
1849
|
+
// The layer lives here and reads these live, so the section's sliders
|
|
1850
|
+
// repaint the running layer without going through the Host.
|
|
1851
|
+
const settings = useStore(store, (s) => s.display)
|
|
1852
|
+
|
|
1853
|
+
useConfigSync(store)
|
|
1854
|
+
useWallpaperVersionSync(store, config.wallpaperEnabled)
|
|
1855
|
+
useWallpaperLayer(hostRef, config.wallpaperEnabled, settings, version)
|
|
1856
|
+
|
|
1857
|
+
return h(
|
|
1858
|
+
'div',
|
|
1859
|
+
{ ref: hostRef, style: { display: 'contents' } },
|
|
1860
|
+
h('style', STYLE_ATTRS, styles)
|
|
1861
|
+
)
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ─── Plugin entry ───────────────────────────────────────────────────────
|
|
1865
|
+
|
|
1866
|
+
return {
|
|
1867
|
+
inject: ['slots', 'locale'],
|
|
1868
|
+
apply(ctx) {
|
|
1869
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'wallpaper: dictionaries')
|
|
1870
|
+
const t = ctx.locale.bind(NS)
|
|
1871
|
+
const store = createStore()
|
|
1872
|
+
|
|
1873
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1874
|
+
ctx.slots.register(
|
|
1875
|
+
{
|
|
1876
|
+
name: 'shell.overlay',
|
|
1877
|
+
id: 'wallpaper.overlay',
|
|
1878
|
+
locale: NS,
|
|
1879
|
+
inject: () => ({ store, t }),
|
|
1880
|
+
},
|
|
1881
|
+
OverlayRoot
|
|
1882
|
+
)
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
// Settings › 壁纸 — the whole feature lives on this page.
|
|
1886
|
+
ctx.slots.inject('settings.section', () =>
|
|
1887
|
+
ctx.slots.register(
|
|
1888
|
+
{
|
|
1889
|
+
name: 'settings.section',
|
|
1890
|
+
id: 'dsh-wallpaper',
|
|
1891
|
+
order: 41,
|
|
1892
|
+
label: () => t('settings.nav'),
|
|
1893
|
+
locale: NS,
|
|
1894
|
+
inject: () => ({ store, t }),
|
|
1895
|
+
},
|
|
1896
|
+
WallpaperSection
|
|
1897
|
+
)
|
|
1898
|
+
)
|
|
1899
|
+
},
|
|
1900
|
+
}
|
|
1901
|
+
},
|
|
1902
|
+
})
|