dsh-home-sync 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/README.zh-CN.md +110 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +107 -0
- package/lib/sync.js +374 -0
- package/lib/ui.js +841 -0
- package/package.json +23 -0
package/lib/ui.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
// Draggable, keyboard-accessible synchronization panel with bounded scrolling.
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
if (window.__dshHomeSyncCleanup) window.__dshHomeSyncCleanup()
|
|
5
|
+
const API = '/dsh-home-sync/api'
|
|
6
|
+
const NS = 'dhs8'
|
|
7
|
+
const POS_KEY = 'dshome-sync-pos'
|
|
8
|
+
const globalListeners = []
|
|
9
|
+
const actionButtons = new Set()
|
|
10
|
+
let pendingActions = 0
|
|
11
|
+
const listen = (el, type, fn) => { el.addEventListener(type, fn); globalListeners.push(() => el.removeEventListener(type, fn)) }
|
|
12
|
+
|
|
13
|
+
const PALETTES = {
|
|
14
|
+
light: {
|
|
15
|
+
accent: '#2f6feb', accentFg: '#ffffff', text: '#1f2328', muted: '#5f6b7a',
|
|
16
|
+
border: '#c9d2dc', panelBg: '#ffffff', headBg: '#f6f8fa', fieldBg: '#f3f5f7',
|
|
17
|
+
hoverBg: '#e8f0fe', danger: '#d1242f', logBg: '#f6f8fa', dotRing: '#ffffff',
|
|
18
|
+
shadow: 'rgba(16,24,40,.16)',
|
|
19
|
+
},
|
|
20
|
+
dark: {
|
|
21
|
+
accent: '#6ea0ff', accentFg: '#0b1220', text: '#e6e9f0', muted: '#9aa4b2',
|
|
22
|
+
border: '#3a414c', panelBg: '#20242b', headBg: '#262b34', fieldBg: '#161a20',
|
|
23
|
+
hoverBg: '#2c3547', danger: '#ff7b72', logBg: '#15181d', dotRing: '#20242b',
|
|
24
|
+
shadow: 'rgba(0,0,0,.55)',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let currentPalette = PALETTES.light
|
|
29
|
+
const pal = Object.fromEntries(Object.keys(PALETTES.light).map(key => [key, 'var(--dhs-' + key + ')']))
|
|
30
|
+
let historyRows = []
|
|
31
|
+
let design = { accent: '', radius: 10, font: '' }
|
|
32
|
+
let dirtyCount = 0
|
|
33
|
+
let pos = { x: 48, y: null, docked: false }
|
|
34
|
+
let docWired = false
|
|
35
|
+
let observerRef = null
|
|
36
|
+
let ctx = null // open popover context
|
|
37
|
+
let followId = null
|
|
38
|
+
|
|
39
|
+
const EDGE = 90
|
|
40
|
+
const GAP = 10
|
|
41
|
+
|
|
42
|
+
function q(id) { return document.getElementById(id) }
|
|
43
|
+
|
|
44
|
+
// ---------- theme / design ----------
|
|
45
|
+
function luminance(rgb) { return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255 }
|
|
46
|
+
function parseRgb(str) {
|
|
47
|
+
const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?/.exec(str || '')
|
|
48
|
+
if (!m) return null
|
|
49
|
+
const a = m[4] === undefined ? 1 : Number(m[4])
|
|
50
|
+
return a < 0.5 ? null : [Number(m[1]), Number(m[2]), Number(m[3])]
|
|
51
|
+
}
|
|
52
|
+
function detectTheme() {
|
|
53
|
+
const hay =
|
|
54
|
+
((document.documentElement.getAttribute('data-theme') || '') + ' ' + (document.documentElement.className || '') +
|
|
55
|
+
' ' + (document.body.getAttribute('data-theme') || '') + ' ' + (document.body.className || '')).toLowerCase()
|
|
56
|
+
if (/\bdark\b/.test(hay)) return 'dark'
|
|
57
|
+
if (/\blight\b/.test(hay)) return 'light'
|
|
58
|
+
for (const el of [document.body, document.documentElement]) {
|
|
59
|
+
const rgb = parseRgb(getComputedStyle(el).backgroundColor)
|
|
60
|
+
if (rgb) return luminance(rgb) < 0.35 ? 'dark' : 'light'
|
|
61
|
+
}
|
|
62
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
63
|
+
}
|
|
64
|
+
function readVar(names) {
|
|
65
|
+
const cs = getComputedStyle(document.documentElement)
|
|
66
|
+
for (const n of names) {
|
|
67
|
+
const v = (cs.getPropertyValue(n) || '').trim()
|
|
68
|
+
if (v && v !== 'inherit' && v !== 'unset') return v
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
function hexOk(v) { return /^#?[0-9a-f]{6}$/i.test(String(v).trim()) }
|
|
73
|
+
function sampleDesign() {
|
|
74
|
+
let accent = readVar(['--accent', '--accent-color', '--brand', '--primary', '--dsh-accent', '--color-accent'])
|
|
75
|
+
if (!hexOk(accent)) {
|
|
76
|
+
accent = ''
|
|
77
|
+
for (const el of Array.from(document.querySelectorAll('button'))) {
|
|
78
|
+
if (el.id?.startsWith(NS + '-')) continue
|
|
79
|
+
const rgb = parseRgb(getComputedStyle(el).backgroundColor)
|
|
80
|
+
if (rgb && luminance(rgb) < 0.55 && luminance(rgb) > 0.08) { accent = 'rgb(' + rgb.join(',') + ')'; break }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const radius = parseInt(String(readVar(['--radius', '--radius-lg', '--border-radius', '--dsh-radius']) || '').replace(/px$/, ''), 10)
|
|
84
|
+
design = { accent, radius: isNaN(radius) ? 10 : radius, font: getComputedStyle(document.body).fontFamily || '' }
|
|
85
|
+
}
|
|
86
|
+
function accent() { return pal.accent }
|
|
87
|
+
function applyPalette(el) {
|
|
88
|
+
if (!el) return
|
|
89
|
+
for (const [key, value] of Object.entries(currentPalette)) el.style.setProperty('--dhs-' + key, key === 'accent' ? design.accent || value : value)
|
|
90
|
+
}
|
|
91
|
+
function radius() { return isNaN(design.radius) || !design.radius ? 10 : design.radius }
|
|
92
|
+
function uiFont() { return design.font || 'system-ui,sans-serif' }
|
|
93
|
+
|
|
94
|
+
function loadPos() {
|
|
95
|
+
try {
|
|
96
|
+
const raw = localStorage.getItem(POS_KEY)
|
|
97
|
+
if (raw) {
|
|
98
|
+
const p = JSON.parse(raw)
|
|
99
|
+
if (typeof p.x === 'number' && typeof p.y === 'number') pos = { x: p.x, y: p.y, docked: !!p.docked }
|
|
100
|
+
}
|
|
101
|
+
} catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
function savePos() {
|
|
104
|
+
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)) } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- fab ----------
|
|
108
|
+
function setThemeColors() {
|
|
109
|
+
for (const suffix of ['fab', 'fb', 'pop', 'toast']) applyPalette(q(NS + '-' + suffix))
|
|
110
|
+
const fab = q(NS + '-fab')
|
|
111
|
+
if (!fab) return
|
|
112
|
+
fab.style.background = pal.panelBg
|
|
113
|
+
fab.style.borderColor = pal.border
|
|
114
|
+
fab.style.color = pal.text
|
|
115
|
+
const ico = q(NS + '-ico')
|
|
116
|
+
if (ico && ico.setAttribute) ico.setAttribute('stroke', accent())
|
|
117
|
+
const dot = q(NS + '-dot')
|
|
118
|
+
if (dot) { dot.style.background = pal.danger; dot.style.borderColor = pal.dotRing }
|
|
119
|
+
}
|
|
120
|
+
function applyPos(animate) {
|
|
121
|
+
const fab = q(NS + '-fab')
|
|
122
|
+
if (!fab) return
|
|
123
|
+
const w = fab.offsetWidth || 84
|
|
124
|
+
const h = fab.offsetHeight || 34
|
|
125
|
+
let y = pos.y
|
|
126
|
+
if (y === null) y = Math.round(innerHeight / 2 - h / 2)
|
|
127
|
+
y = Math.max(8, Math.min(innerHeight - h - 8, y))
|
|
128
|
+
const x = Math.max(4, Math.min(innerWidth - w - 4, pos.x))
|
|
129
|
+
fab.style.transition = animate ? 'left .24s cubic-bezier(.2,.8,.3,1), top .24s cubic-bezier(.2,.8,.3,1)' : 'none'
|
|
130
|
+
fab.style.left = x + 'px'
|
|
131
|
+
fab.style.top = y + 'px'
|
|
132
|
+
pos.x = x
|
|
133
|
+
pos.y = y
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pressDown() {
|
|
137
|
+
const fab = q(NS + '-fab')
|
|
138
|
+
if (!fab || !fab.animate) return
|
|
139
|
+
try { fab.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.93)' }], { duration: 80, easing: 'ease-out' }) } catch { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
function pressUp() {
|
|
142
|
+
const fab = q(NS + '-fab')
|
|
143
|
+
if (!fab || !fab.animate) return
|
|
144
|
+
try {
|
|
145
|
+
fab.animate(
|
|
146
|
+
[{ transform: 'scale(0.93)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
|
|
147
|
+
{ duration: 260, easing: 'cubic-bezier(.34,1.56,.64,1)' },
|
|
148
|
+
)
|
|
149
|
+
} catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- api ----------
|
|
153
|
+
async function api(path, body) {
|
|
154
|
+
const mutating = body !== undefined
|
|
155
|
+
if (mutating) { pendingActions++; actionButtons.forEach(b => { b.disabled = true }) }
|
|
156
|
+
const controller = new AbortController()
|
|
157
|
+
const timeout = setTimeout(() => controller.abort(), 300000)
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(API + path, {
|
|
160
|
+
method: mutating ? 'POST' : 'GET',
|
|
161
|
+
headers: mutating ? { 'Content-Type': 'application/json' } : {},
|
|
162
|
+
body: mutating ? JSON.stringify(body) : undefined,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
})
|
|
165
|
+
const result = await res.json().catch(() => ({ ok: false, error: '响应格式错误(HTTP ' + res.status + ')' }))
|
|
166
|
+
if (!res.ok) return { ...result, ok: false, error: result.error || result.message || 'HTTP ' + res.status }
|
|
167
|
+
return result
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timeout)
|
|
170
|
+
if (mutating) { pendingActions--; actionButtons.forEach(b => { b.disabled = pendingActions > 0 }) }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Floating toast feedback (replaces the old inline log box).
|
|
174
|
+
function showToast(txt, ok) {
|
|
175
|
+
let el = q(NS + '-toast')
|
|
176
|
+
if (!el) {
|
|
177
|
+
el = document.createElement('div')
|
|
178
|
+
el.id = NS + '-toast'
|
|
179
|
+
document.body.appendChild(el)
|
|
180
|
+
}
|
|
181
|
+
const kind = ok ? pal.accent : pal.danger
|
|
182
|
+
el.style.cssText =
|
|
183
|
+
'position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(10px);z-index:2147483003;' +
|
|
184
|
+
'background:' + pal.panelBg + ';color:' + pal.text + ';border:1px solid ' + pal.border + ';border-left:3px solid ' + kind + ';' +
|
|
185
|
+
'padding:9px 16px;border-radius:10px;font:13px/1.4 ' + uiFont() + ';box-shadow:0 10px 28px ' + pal.shadow + ';' +
|
|
186
|
+
'opacity:0;max-width:min(420px,84vw);text-align:center'
|
|
187
|
+
applyPalette(el)
|
|
188
|
+
el.textContent = txt
|
|
189
|
+
requestAnimationFrame(() => {
|
|
190
|
+
el.style.transition = 'opacity .18s ease, transform .18s ease'
|
|
191
|
+
el.style.opacity = '1'
|
|
192
|
+
el.style.transform = 'translateX(-50%) translateY(0)'
|
|
193
|
+
})
|
|
194
|
+
clearTimeout(el.__t)
|
|
195
|
+
el.__t = setTimeout(() => {
|
|
196
|
+
if (el) {
|
|
197
|
+
el.style.transition = 'opacity .18s ease, transform .18s ease'
|
|
198
|
+
el.style.opacity = '0'
|
|
199
|
+
el.style.transform = 'translateX(-50%) translateY(10px)'
|
|
200
|
+
setTimeout(() => el.remove(), 200)
|
|
201
|
+
}
|
|
202
|
+
}, 2400)
|
|
203
|
+
}
|
|
204
|
+
function logLine(txt, ok) {
|
|
205
|
+
if (ctx && ctx.logEl) ctx.logEl.textContent = txt
|
|
206
|
+
historyRows = [{ at: new Date().toISOString(), ok: ok !== false, message: txt }, ...historyRows].slice(0, 100)
|
|
207
|
+
renderHistory()
|
|
208
|
+
showToast(txt, ok === false ? false : true)
|
|
209
|
+
}
|
|
210
|
+
function renderHistory(warning) {
|
|
211
|
+
if (!ctx?.historyEl) return
|
|
212
|
+
const labels = { init: '初始化', sync: '同步', pull: '拉取', config: '设置' }
|
|
213
|
+
ctx.historyEl.textContent = (warning ? warning + '\n\n' : '') + (historyRows.map(row =>
|
|
214
|
+
(row.at || '') + ' ' + (labels[row.kind] || '') + (row.ok ? ' 成功' : ' 失败') + '\n' + row.message +
|
|
215
|
+
(row.backupDir ? '\n备份:' + row.backupDir + '\n恢复指引:备份目录中的 MERGE.md' : '')).join('\n\n') || '暂无操作记录。')
|
|
216
|
+
}
|
|
217
|
+
function setBadge() {
|
|
218
|
+
const fab = q(NS + '-fab')
|
|
219
|
+
if (fab) fab.title = dirtyCount === null ? 'Home Sync · 状态读取失败' : dirtyCount > 0 ? 'Home Sync · ' + dirtyCount + ' 个文件待提交' : 'Home Sync · 点击打开'
|
|
220
|
+
const dot = q(NS + '-dot')
|
|
221
|
+
if (dot) dot.style.display = dirtyCount === null || dirtyCount > 0 ? 'block' : 'none'
|
|
222
|
+
}
|
|
223
|
+
async function fetchStatus() {
|
|
224
|
+
try {
|
|
225
|
+
const r = await api('/status')
|
|
226
|
+
if (Array.isArray(r.history)) historyRows = r.history
|
|
227
|
+
renderHistory(r.historyWarning)
|
|
228
|
+
dirtyCount = r.ok && Array.isArray(r.dirty) ? r.dirty.length : null
|
|
229
|
+
setBadge()
|
|
230
|
+
return r
|
|
231
|
+
} catch (error) {
|
|
232
|
+
dirtyCount = null; setBadge()
|
|
233
|
+
return { ok: false, error: '状态读取失败:' + error.message }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function renderStatus(r) {
|
|
237
|
+
if (!ctx) return
|
|
238
|
+
const el = ctx.statusEl
|
|
239
|
+
el.innerHTML = ''
|
|
240
|
+
const grid = document.createElement('div')
|
|
241
|
+
grid.style.cssText = 'display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font:12px/1.6 ' + uiFont() + ';color:' + pal.text
|
|
242
|
+
const add = (k, v) => {
|
|
243
|
+
const a = document.createElement('div'); a.textContent = k; a.style.color = pal.muted
|
|
244
|
+
const b = document.createElement('div'); b.textContent = v
|
|
245
|
+
b.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; b.title = v
|
|
246
|
+
grid.append(a, b)
|
|
247
|
+
}
|
|
248
|
+
add('分支', r.branch || '-')
|
|
249
|
+
add('远端', r.remote || '未设置')
|
|
250
|
+
add('待提交', Array.isArray(r.dirty) ? String(r.dirty.length) : '未知')
|
|
251
|
+
if (r.ahead !== null && r.ahead !== undefined) add('待推送 / 待拉取', r.ahead + ' / ' + r.behind)
|
|
252
|
+
if (r.branchMatches === false) add('分支不一致', '请切换到 ' + r.config.branch)
|
|
253
|
+
if (!r.ok) add('状态错误', r.error || r.reason || '无法读取仓库')
|
|
254
|
+
if (r.operation) add('正在执行', r.operation.kind)
|
|
255
|
+
if (r.lastResult && !r.lastResult.ok) add('最近失败', r.lastResult.message || r.lastResult.reason)
|
|
256
|
+
el.append(grid)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------- popover layout (follows pill, direction aware, smooth) ----------
|
|
260
|
+
function relayout(smooth) {
|
|
261
|
+
if (!ctx) return
|
|
262
|
+
const fab = ctx.anchor || q(NS + '-fab') || q(NS + '-fb')
|
|
263
|
+
if (!fab) return
|
|
264
|
+
const pop = ctx.host
|
|
265
|
+
const card = ctx.card
|
|
266
|
+
const W = 320
|
|
267
|
+
card.style.maxHeight = 'none'
|
|
268
|
+
card.style.overflow = 'visible'
|
|
269
|
+
const need = Math.max(120, card.offsetHeight)
|
|
270
|
+
const r = fab.getBoundingClientRect()
|
|
271
|
+
const vw = innerWidth
|
|
272
|
+
const vh = innerHeight
|
|
273
|
+
const belowSpace = vh - r.bottom - GAP
|
|
274
|
+
const aboveSpace = r.top - GAP
|
|
275
|
+
const rightSpace = vw - r.right - GAP
|
|
276
|
+
const leftSpace = r.left - GAP
|
|
277
|
+
|
|
278
|
+
// Cap the card at a fixed maximum height; overflow scrolls INSIDE the
|
|
279
|
+
// card (wheel) when content exceeds it.
|
|
280
|
+
const MAX = Math.max(240, Math.min(460, vh - 24))
|
|
281
|
+
// Direction decision must use the CAPPED height (what will actually be
|
|
282
|
+
// shown), not the full content height — overflow scrolls anyway.
|
|
283
|
+
const want = Math.min(need, MAX)
|
|
284
|
+
|
|
285
|
+
// Re-evaluate direction on EVERY layout from the pill's LIVE position:
|
|
286
|
+
// near an edge -> open sideways; otherwise default below, flipping above
|
|
287
|
+
// only when the bottom is too tight. Using proximity (not the persisted
|
|
288
|
+
// docked flag) keeps the transition smooth while dragging off an edge.
|
|
289
|
+
const size = fab.offsetWidth || 38
|
|
290
|
+
const nearEdge = pos.x <= EDGE || pos.x >= innerWidth - size - EDGE
|
|
291
|
+
let dir
|
|
292
|
+
if (nearEdge && (rightSpace >= W || leftSpace >= W)) {
|
|
293
|
+
dir = r.left < innerWidth / 2 ? 'right' : 'left'
|
|
294
|
+
if (dir === 'right' && rightSpace < W) dir = 'left'
|
|
295
|
+
if (dir === 'left' && leftSpace < W) dir = 'right'
|
|
296
|
+
} else if (belowSpace >= want) {
|
|
297
|
+
dir = 'below'
|
|
298
|
+
} else if (aboveSpace >= want) {
|
|
299
|
+
dir = 'above'
|
|
300
|
+
} else {
|
|
301
|
+
// Neither side fits the capped height: pick the side with more room.
|
|
302
|
+
dir = belowSpace >= aboveSpace ? 'below' : 'above'
|
|
303
|
+
}
|
|
304
|
+
const ease = 'cubic-bezier(.2,.8,.3,1)'
|
|
305
|
+
pop.style.transition = smooth ? 'left .3s ' + ease + ', top .3s ' + ease : 'none'
|
|
306
|
+
card.style.transition = smooth ? 'max-height .24s ' + ease : 'none'
|
|
307
|
+
let left = 0
|
|
308
|
+
let top = 0
|
|
309
|
+
let h = want
|
|
310
|
+
if (dir === 'below') {
|
|
311
|
+
const space = Math.max(120, vh - 8 - (r.bottom + GAP))
|
|
312
|
+
h = Math.min(h, space)
|
|
313
|
+
left = Math.max(8, Math.min(r.left, vw - W - 8))
|
|
314
|
+
top = r.bottom + GAP
|
|
315
|
+
} else if (dir === 'above') {
|
|
316
|
+
const space = Math.max(120, r.top - GAP - 8)
|
|
317
|
+
h = Math.min(h, space)
|
|
318
|
+
left = Math.max(8, Math.min(r.left, vw - W - 8))
|
|
319
|
+
top = Math.max(8, r.top - GAP - h)
|
|
320
|
+
} else if (dir === 'right') {
|
|
321
|
+
h = Math.min(h, vh - 16)
|
|
322
|
+
left = r.right + GAP
|
|
323
|
+
top = Math.max(8, Math.min(r.top + r.height / 2 - h / 2, vh - h - 8))
|
|
324
|
+
} else {
|
|
325
|
+
h = Math.min(h, vh - 16)
|
|
326
|
+
left = Math.max(8, r.left - W - GAP)
|
|
327
|
+
top = Math.max(8, Math.min(r.top + r.height / 2 - h / 2, vh - h - 8))
|
|
328
|
+
}
|
|
329
|
+
pop.style.left = left + 'px'
|
|
330
|
+
pop.style.top = top + 'px'
|
|
331
|
+
card.style.maxHeight = h + 'px'
|
|
332
|
+
card.style.overflow = h < need - 0.5 ? 'auto' : 'hidden'
|
|
333
|
+
ctx.dir = dir
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Frame-level follower: while the popover is open, watch the pill every
|
|
337
|
+
// animation frame and re-lay the card whenever the pill moves (manual drag
|
|
338
|
+
// or its own snap transition), so the card never lags behind / stays put.
|
|
339
|
+
function followTick() {
|
|
340
|
+
if (!ctx) {
|
|
341
|
+
followId = null
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const fab = ctx.anchor || q(NS + '-fab') || q(NS + '-fb')
|
|
345
|
+
if (fab) {
|
|
346
|
+
const r = fab.getBoundingClientRect()
|
|
347
|
+
if (ctx.fx === undefined || Math.abs(r.left - ctx.fx) > 0.4 || Math.abs(r.top - ctx.fy) > 0.4) {
|
|
348
|
+
ctx.fx = r.left
|
|
349
|
+
ctx.fy = r.top
|
|
350
|
+
relayout(true)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
followId = requestAnimationFrame(followTick)
|
|
354
|
+
}
|
|
355
|
+
function startFollow() {
|
|
356
|
+
if (followId === null) followId = requestAnimationFrame(followTick)
|
|
357
|
+
}
|
|
358
|
+
// While a section is expanding/collapsing, re-lay every frame so the card
|
|
359
|
+
// grows (or shrinks) together with the content instead of jumping at the end.
|
|
360
|
+
function animateGrow() {
|
|
361
|
+
const t0 = performance.now()
|
|
362
|
+
const step = (now) => {
|
|
363
|
+
if (!ctx) return
|
|
364
|
+
relayout(true)
|
|
365
|
+
if (now - t0 < 400) requestAnimationFrame(step)
|
|
366
|
+
else relayout(true)
|
|
367
|
+
}
|
|
368
|
+
requestAnimationFrame(step)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------- popover build ----------
|
|
372
|
+
function makeBtn(label, kind, onClick) {
|
|
373
|
+
const b = document.createElement('button')
|
|
374
|
+
b.textContent = label
|
|
375
|
+
const base =
|
|
376
|
+
'padding:6px 12px;border-radius:' + Math.round(radius() * 0.7) + 'px;font:500 13px/1 ' + uiFont() + ';' +
|
|
377
|
+
'cursor:pointer;outline:none;white-space:nowrap;'
|
|
378
|
+
if (kind === 'primary') b.style.cssText = base + 'background:' + accent() + ';border:1px solid ' + accent() + ';color:' + pal.accentFg + ';'
|
|
379
|
+
else if (kind === 'danger') b.style.cssText = base + 'background:transparent;border:1px solid ' + pal.danger + ';color:' + pal.danger + ';'
|
|
380
|
+
else b.style.cssText = base + 'background:' + pal.panelBg + ';border:1px solid ' + pal.border + ';color:' + pal.text + ';'
|
|
381
|
+
b.disabled = pendingActions > 0
|
|
382
|
+
actionButtons.add(b)
|
|
383
|
+
b.addEventListener('click', () => { if (!b.disabled) onClick() })
|
|
384
|
+
return b
|
|
385
|
+
}
|
|
386
|
+
function field(label, input) {
|
|
387
|
+
const w = document.createElement('div')
|
|
388
|
+
w.style.cssText = 'margin:7px 0'
|
|
389
|
+
const l = document.createElement('label')
|
|
390
|
+
l.textContent = label
|
|
391
|
+
l.style.cssText = 'display:block;font:500 11.5px/1.4 ' + uiFont() + ';color:' + pal.muted + ';margin-bottom:3px'
|
|
392
|
+
w.append(l, input)
|
|
393
|
+
return w
|
|
394
|
+
}
|
|
395
|
+
function textInput(placeholder) {
|
|
396
|
+
const i = document.createElement('input')
|
|
397
|
+
i.type = 'text'
|
|
398
|
+
i.style.cssText =
|
|
399
|
+
'width:100%;box-sizing:border-box;background:' + pal.fieldBg + ';border:1px solid ' + pal.border + ';color:' + pal.text + ';' +
|
|
400
|
+
'border-radius:' + Math.round(radius() * 0.7) + 'px;padding:5px 8px;font:12.5px ' + uiFont() + ';outline:none'
|
|
401
|
+
if (placeholder) i.placeholder = placeholder
|
|
402
|
+
return i
|
|
403
|
+
}
|
|
404
|
+
// Smooth section: grid-rows 0fr <-> 1fr expansion.
|
|
405
|
+
function section(title, innerEl) {
|
|
406
|
+
const wrap = document.createElement('div')
|
|
407
|
+
wrap.style.cssText = 'margin:8px 0 0;border-top:1px solid ' + pal.border + ';padding-top:6px'
|
|
408
|
+
const head = document.createElement('button')
|
|
409
|
+
head.type = 'button'
|
|
410
|
+
head.setAttribute('aria-expanded', 'false')
|
|
411
|
+
head.style.cssText =
|
|
412
|
+
'display:flex;width:100%;align-items:center;gap:6px;background:none;border:none;cursor:pointer;' +
|
|
413
|
+
'font:600 12.5px/1.4 ' + uiFont() + ';color:' + pal.text + ';padding:4px 2px;outline:none'
|
|
414
|
+
const chev = document.createElement('span')
|
|
415
|
+
chev.textContent = '▸'
|
|
416
|
+
chev.style.cssText = 'transition:transform .22s cubic-bezier(.2,.8,.3,1);font-size:10px;color:' + pal.muted
|
|
417
|
+
const t = document.createElement('span')
|
|
418
|
+
t.textContent = title
|
|
419
|
+
head.append(chev, t)
|
|
420
|
+
const track = document.createElement('div')
|
|
421
|
+
track.style.cssText = 'display:grid;grid-template-rows:0fr;transition:grid-template-rows .26s cubic-bezier(.2,.8,.3,1)'
|
|
422
|
+
const inner = document.createElement('div')
|
|
423
|
+
inner.style.cssText = 'overflow:hidden;min-height:0'
|
|
424
|
+
inner.append(innerEl)
|
|
425
|
+
track.append(inner)
|
|
426
|
+
let open = false
|
|
427
|
+
head.addEventListener('click', () => {
|
|
428
|
+
open = !open
|
|
429
|
+
head.setAttribute('aria-expanded', String(open))
|
|
430
|
+
chev.style.transform = open ? 'rotate(90deg)' : ''
|
|
431
|
+
track.style.gridTemplateRows = open ? '1fr' : '0fr'
|
|
432
|
+
relayout(true)
|
|
433
|
+
animateGrow()
|
|
434
|
+
})
|
|
435
|
+
wrap.append(head, track)
|
|
436
|
+
return { wrapEl: wrap, open: () => open }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function wireDocument() {
|
|
440
|
+
if (docWired) return
|
|
441
|
+
docWired = true
|
|
442
|
+
listen(document, 'keydown', (e) => { if (e.key === 'Escape') closePop() })
|
|
443
|
+
listen(document, 'click', (e) => {
|
|
444
|
+
const pop = ctx && ctx.host
|
|
445
|
+
const anchor = ctx && ctx.anchor
|
|
446
|
+
if (pop && !pop.contains(e.target) && !(anchor && anchor.contains(e.target))) closePop()
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
function openPop(anchor) {
|
|
450
|
+
if (ctx) return closePop()
|
|
451
|
+
wireDocument()
|
|
452
|
+
const fab = anchor || q(NS + '-fab') || q(NS + '-fb')
|
|
453
|
+
if (!fab) return
|
|
454
|
+
|
|
455
|
+
const host = document.createElement('div')
|
|
456
|
+
host.id = NS + '-pop'
|
|
457
|
+
host.setAttribute('role', 'dialog')
|
|
458
|
+
host.setAttribute('aria-label', 'Home Sync')
|
|
459
|
+
host.style.cssText = 'position:fixed;z-index:2147483002;width:320px;max-width:min(320px,88vw);visibility:hidden;opacity:0;'
|
|
460
|
+
applyPalette(host)
|
|
461
|
+
const shadow = host.attachShadow({ mode: 'open' })
|
|
462
|
+
const style = document.createElement('style')
|
|
463
|
+
style.textContent =
|
|
464
|
+
':host{all:initial}*{box-sizing:border-box;margin:0}' +
|
|
465
|
+
'.card{background:' + pal.panelBg + ';color:' + pal.text + ';border:1px solid ' + pal.border + ';' +
|
|
466
|
+
'border-radius:' + radius() + 'px;box-shadow:0 8px 28px ' + pal.shadow + ';overflow:hidden;' +
|
|
467
|
+
'font:13px/1.5 ' + uiFont() + ';animation:pop .18s cubic-bezier(.2,.9,.3,1.05)}' +
|
|
468
|
+
'@keyframes pop{from{opacity:0;transform:scale(.97) translateY(-3px)}to{opacity:1;transform:none}}' +
|
|
469
|
+
'.head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid ' + pal.border + ';background:' + pal.headBg + ';' +
|
|
470
|
+
'border-top-left-radius:' + radius() + 'px;border-top-right-radius:' + radius() + 'px;}' +
|
|
471
|
+
'.title{font:600 13px/1 ' + uiFont() + ';color:' + pal.text + '}' +
|
|
472
|
+
'.body{padding:10px 12px 12px}' +
|
|
473
|
+
'.btns{display:flex;gap:8px;flex-wrap:wrap;margin:9px 0}' +
|
|
474
|
+
'#log{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}#log:empty{display:none}' +
|
|
475
|
+
'p.hint{color:' + pal.muted + ';font:11.5px/1.5 ' + uiFont() + ';margin:4px 0}' +
|
|
476
|
+
'button:focus-visible,input:focus-visible{outline:2px solid ' + accent() + '!important;outline-offset:2px}button:disabled{opacity:.5;cursor:wait}'
|
|
477
|
+
shadow.append(style)
|
|
478
|
+
|
|
479
|
+
const card = document.createElement('div')
|
|
480
|
+
card.className = 'card'
|
|
481
|
+
const head = document.createElement('div')
|
|
482
|
+
head.className = 'head'
|
|
483
|
+
const ic = document.createElement('span'); ic.textContent = '⟳'; ic.style.cssText = 'color:' + accent()
|
|
484
|
+
const tt = document.createElement('span'); tt.className = 'title'; tt.textContent = 'Home Sync'
|
|
485
|
+
const bs = document.createElement('span'); bs.id = NS + '-busy'; bs.textContent = '…'
|
|
486
|
+
bs.style.cssText = 'display:none;color:' + accent()
|
|
487
|
+
const x = document.createElement('button')
|
|
488
|
+
x.textContent = '✕'
|
|
489
|
+
x.title = '关闭 (Esc)'
|
|
490
|
+
x.style.cssText = 'margin-left:auto;background:none;border:none;color:' + pal.muted + ';cursor:pointer;font-size:13px;padding:1px 6px;border-radius:6px'
|
|
491
|
+
x.addEventListener('click', closePop)
|
|
492
|
+
head.append(ic, tt, bs, x)
|
|
493
|
+
card.append(head)
|
|
494
|
+
|
|
495
|
+
const body = document.createElement('div')
|
|
496
|
+
body.className = 'body'
|
|
497
|
+
const status = document.createElement('div')
|
|
498
|
+
body.append(status)
|
|
499
|
+
const log = document.createElement('div')
|
|
500
|
+
log.id = 'log'
|
|
501
|
+
log.setAttribute('role', 'status')
|
|
502
|
+
log.setAttribute('aria-live', 'polite')
|
|
503
|
+
body.append(log)
|
|
504
|
+
|
|
505
|
+
const btns = document.createElement('div')
|
|
506
|
+
btns.className = 'btns'
|
|
507
|
+
const act = (name, p) => () => {
|
|
508
|
+
bs.style.display = 'inline'
|
|
509
|
+
api(p.path, p.body)
|
|
510
|
+
.then((r) => {
|
|
511
|
+
const okR = !!(r && r.ok)
|
|
512
|
+
logLine(name + ' → ' + (okR ? '成功' + (r.message ? ':' + r.message : '') : '失败:' + ((r && (r.error || r.message || r.reason)) || '无响应')), okR)
|
|
513
|
+
if (p.path !== '/config') fetchStatus().then((st) => st && renderStatus(st))
|
|
514
|
+
})
|
|
515
|
+
.catch((e) => logLine(name + ' 异常: ' + e, false))
|
|
516
|
+
.finally(() => { bs.style.display = 'none' })
|
|
517
|
+
}
|
|
518
|
+
btns.append(
|
|
519
|
+
makeBtn('拉取', '', act('拉取', { path: '/pull', body: {} })),
|
|
520
|
+
makeBtn('同步到远端', 'primary', act('推送', { path: '/push', body: {} })),
|
|
521
|
+
makeBtn('刷新', '', () => fetchStatus().then((st) => { if (st) renderStatus(st) })),
|
|
522
|
+
)
|
|
523
|
+
body.append(btns)
|
|
524
|
+
|
|
525
|
+
// Settings section
|
|
526
|
+
const row = (input, label) => {
|
|
527
|
+
const w = document.createElement('div')
|
|
528
|
+
w.style.cssText = 'display:flex;gap:7px;align-items:center;margin:5px 0;font:12.5px ' + uiFont() + ';color:' + pal.text
|
|
529
|
+
const s = document.createElement('span')
|
|
530
|
+
s.textContent = label
|
|
531
|
+
w.append(input, s)
|
|
532
|
+
return w
|
|
533
|
+
}
|
|
534
|
+
const as2 = document.createElement('input'); as2.type = 'checkbox'; as2.style.cssText = 'accent-color:' + accent()
|
|
535
|
+
const ap = document.createElement('input'); ap.type = 'checkbox'; ap.style.cssText = 'accent-color:' + accent()
|
|
536
|
+
const msg = textInput('commit message')
|
|
537
|
+
const br = textInput('main')
|
|
538
|
+
const applyConfig = config => {
|
|
539
|
+
if (!config) return
|
|
540
|
+
as2.checked = !!(config.autoSync ?? config.autoSyncOnStartup)
|
|
541
|
+
ap.checked = !!config.autoPullOnStartup
|
|
542
|
+
msg.value = config.commitMessage || ''
|
|
543
|
+
br.value = config.branch || 'main'
|
|
544
|
+
}
|
|
545
|
+
const cfgInner = document.createElement('div')
|
|
546
|
+
cfgInner.append(
|
|
547
|
+
row(as2, '自动同步(定期拉取并推送改动)'),
|
|
548
|
+
row(ap, '启动时仅拉取'),
|
|
549
|
+
field('commit 信息', msg),
|
|
550
|
+
field('分支', br),
|
|
551
|
+
)
|
|
552
|
+
const saveBtn = makeBtn('保存', '', () => {
|
|
553
|
+
const p = {
|
|
554
|
+
path: '/config',
|
|
555
|
+
body: {
|
|
556
|
+
autoSync: as2.checked,
|
|
557
|
+
autoPullOnStartup: ap.checked,
|
|
558
|
+
commitMessage: msg.value.trim() || 'sync: home config & memory',
|
|
559
|
+
branch: br.value.trim() || 'main',
|
|
560
|
+
},
|
|
561
|
+
}
|
|
562
|
+
bs.style.display = 'inline'
|
|
563
|
+
api(p.path, p.body)
|
|
564
|
+
.then((r) => {
|
|
565
|
+
logLine('设置 → ' + (r && r.ok ? '已保存' : '失败:' + ((r && (r.error || r.message || r.reason)) || '')), !!(r && r.ok))
|
|
566
|
+
fetchStatus().then((st) => st && renderStatus(st))
|
|
567
|
+
})
|
|
568
|
+
.catch((e) => logLine('设置 异常: ' + e, false))
|
|
569
|
+
.finally(() => { bs.style.display = 'none' })
|
|
570
|
+
})
|
|
571
|
+
cfgInner.append(saveBtn)
|
|
572
|
+
body.append(section('设置', cfgInner).wrapEl)
|
|
573
|
+
|
|
574
|
+
// Init section
|
|
575
|
+
const mkMode = (value, label) => {
|
|
576
|
+
const lab = document.createElement('label')
|
|
577
|
+
lab.style.cssText = 'display:flex;gap:6px;align-items:flex-start;margin:2px 0;font:12px/1.4 ' + uiFont() + ';color:' + pal.text + ';cursor:pointer'
|
|
578
|
+
const inp = document.createElement('input')
|
|
579
|
+
inp.type = 'radio'
|
|
580
|
+
inp.name = NS + '-init-mode'
|
|
581
|
+
inp.value = value
|
|
582
|
+
inp.style.cssText = 'accent-color:' + pal.accent + ';margin-top:2px'
|
|
583
|
+
const sp = document.createElement('span')
|
|
584
|
+
sp.textContent = label
|
|
585
|
+
lab.append(inp, sp)
|
|
586
|
+
return lab
|
|
587
|
+
}
|
|
588
|
+
const modeSel = document.createElement('div')
|
|
589
|
+
modeSel.style.cssText = 'margin:6px 0'
|
|
590
|
+
modeSel.append(
|
|
591
|
+
mkMode('reset', '全新覆盖:以远端为准(适合新装机器)'),
|
|
592
|
+
mkMode('merge', '迁移合并:先备份本机内容再重置,随后手动合并'),
|
|
593
|
+
)
|
|
594
|
+
const selReset = modeSel.querySelector('input[value="reset"]')
|
|
595
|
+
if (selReset) selReset.checked = true
|
|
596
|
+
const remote = textInput('git@… 或 https://…')
|
|
597
|
+
const ck = document.createElement('input'); ck.type = 'checkbox'; ck.style.cssText = 'accent-color:' + pal.danger
|
|
598
|
+
const ckw = document.createElement('label')
|
|
599
|
+
ckw.style.cssText = 'display:flex;gap:6px;align-items:center;margin:4px 0;font:12.5px ' + uiFont() + ';color:' + pal.text
|
|
600
|
+
const ckl = document.createElement('span'); ckl.textContent = '我确认执行'
|
|
601
|
+
ckw.append(ck, ckl)
|
|
602
|
+
const hint = document.createElement('p'); hint.className = 'hint'
|
|
603
|
+
hint.textContent = '先检查远端文件范围,再备份受影响文件和 Git 历史后覆盖。备份保存在主目录旁,初始化后自动任务暂停。迁移合并需手动比较备份并保留所需内容。'
|
|
604
|
+
const initInner = document.createElement('div')
|
|
605
|
+
initInner.append(hint, modeSel, field('远端 URL', remote), ckw)
|
|
606
|
+
const initBtn = makeBtn('执行', 'danger', () => {
|
|
607
|
+
const rv = remote.value.trim()
|
|
608
|
+
const modeIn = modeSel.querySelector('input:checked')
|
|
609
|
+
const mode = modeIn ? modeIn.value : 'reset'
|
|
610
|
+
if (!rv) return logLine('请填写远端 URL', false)
|
|
611
|
+
if (!ck.checked) return logLine('请先勾选确认', false)
|
|
612
|
+
ck.checked = false
|
|
613
|
+
bs.style.display = 'inline'
|
|
614
|
+
api('/init', { remote: rv, branch: br.value.trim() || 'main', confirm: true, mode })
|
|
615
|
+
.then((r) => {
|
|
616
|
+
bs.style.display = 'none'
|
|
617
|
+
if (r && r.ok) {
|
|
618
|
+
// Initialization is an explicit configuration reset; discard stale form switches immediately.
|
|
619
|
+
applyConfig(r.config || { autoSync: false, autoPullOnStartup: false, commitMessage: msg.value, branch: br.value })
|
|
620
|
+
if (mode === 'merge') {
|
|
621
|
+
logLine('已备份并重置。备份:' + (r.backupDir || '') + '(含 MERGE.md 指引)——请按指引合并本机旧内容后重启。', true)
|
|
622
|
+
} else {
|
|
623
|
+
logLine('初始化完成。备份:' + (r.backupDir || '') + '。自动任务已暂停,请检查后重启 dsh web 并安装插件依赖。', true)
|
|
624
|
+
}
|
|
625
|
+
fetchStatus().then((st) => st && renderStatus(st))
|
|
626
|
+
} else {
|
|
627
|
+
logLine('初始化失败:' + ((r && (r.error || r.message || r.reason || r.step)) || '无响应'), false)
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
.catch((e) => logLine('初始化 异常: ' + e, false))
|
|
631
|
+
.finally(() => { bs.style.display = 'none' })
|
|
632
|
+
})
|
|
633
|
+
initInner.append(initBtn)
|
|
634
|
+
body.append(section('设备初始化', initInner).wrapEl)
|
|
635
|
+
const history = document.createElement('div')
|
|
636
|
+
history.id = 'history'
|
|
637
|
+
history.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;user-select:text;max-height:220px;overflow:auto;background:' + pal.logBg + ';color:' + pal.text + ';padding:8px;font:12px/1.5 ' + uiFont()
|
|
638
|
+
body.append(section('操作记录', history).wrapEl)
|
|
639
|
+
|
|
640
|
+
card.append(body)
|
|
641
|
+
shadow.append(card)
|
|
642
|
+
document.body.appendChild(host)
|
|
643
|
+
|
|
644
|
+
ctx = { host, card, statusEl: status, logEl: log, historyEl: history, anchor: fab, dir: null, fx: undefined, fy: undefined }
|
|
645
|
+
renderHistory()
|
|
646
|
+
fab.setAttribute('aria-expanded', 'true')
|
|
647
|
+
x.focus?.()
|
|
648
|
+
relayout(false)
|
|
649
|
+
host.style.visibility = 'visible'
|
|
650
|
+
// Re-trigger the open animation reliably (hidden->visible may otherwise
|
|
651
|
+
// swallow the first run).
|
|
652
|
+
card.style.animation = 'none'
|
|
653
|
+
void card.offsetWidth
|
|
654
|
+
card.style.animation = 'pop .18s cubic-bezier(.2,.9,.3,1.05)'
|
|
655
|
+
requestAnimationFrame(() => { host.style.opacity = '1' })
|
|
656
|
+
startFollow()
|
|
657
|
+
|
|
658
|
+
const owner = ctx
|
|
659
|
+
fetchStatus().then((st) => {
|
|
660
|
+
if (!st || ctx !== owner) return
|
|
661
|
+
renderStatus(st)
|
|
662
|
+
applyConfig(st.config)
|
|
663
|
+
relayout(true)
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function closePop() {
|
|
668
|
+
if (!ctx) return
|
|
669
|
+
const host = ctx.host
|
|
670
|
+
host.removeAttribute('id')
|
|
671
|
+
const anchor = ctx.anchor
|
|
672
|
+
const card = ctx.card
|
|
673
|
+
ctx = null // stop follower
|
|
674
|
+
actionButtons.clear()
|
|
675
|
+
anchor?.setAttribute('aria-expanded', 'false')
|
|
676
|
+
anchor?.focus?.()
|
|
677
|
+
// Fade + fold out, then remove from the DOM.
|
|
678
|
+
try {
|
|
679
|
+
card.style.transition = 'opacity .13s ease, transform .15s ease'
|
|
680
|
+
card.style.animation = 'none'
|
|
681
|
+
card.style.opacity = '0'
|
|
682
|
+
card.style.transform = 'translateY(-5px) scale(.97)'
|
|
683
|
+
} catch { /* ignore */ }
|
|
684
|
+
setTimeout(() => {
|
|
685
|
+
try { host.remove() } catch { /* ignore */ }
|
|
686
|
+
}, 160)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---------- mount ----------
|
|
690
|
+
function mount() {
|
|
691
|
+
loadPos()
|
|
692
|
+
sampleDesign()
|
|
693
|
+
currentPalette = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
|
|
694
|
+
|
|
695
|
+
const fab = document.createElement('button')
|
|
696
|
+
fab.id = NS + '-fab'
|
|
697
|
+
fab.type = 'button'
|
|
698
|
+
fab.setAttribute('aria-label', '打开 Home Sync')
|
|
699
|
+
fab.setAttribute('aria-expanded', 'false')
|
|
700
|
+
fab.style.cssText =
|
|
701
|
+
'position:fixed;z-index:2147483000;display:flex;align-items:center;justify-content:center;cursor:grab;border:1px solid ' + pal.border + ';' +
|
|
702
|
+
'background:' + pal.panelBg + ';color:' + pal.text + ';width:38px;height:38px;padding:0;border-radius:50%;' +
|
|
703
|
+
'box-shadow:0 1px 3px rgba(0,0,0,.06), 0 4px 14px ' + pal.shadow + ';' +
|
|
704
|
+
'user-select:none;touch-action:none;will-change:transform,left,top;'
|
|
705
|
+
const SVGNS = 'http://www.w3.org/2000/svg'
|
|
706
|
+
const svg = document.createElementNS(SVGNS, 'svg')
|
|
707
|
+
svg.id = NS + '-ico'
|
|
708
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
|
709
|
+
svg.setAttribute('fill', 'none')
|
|
710
|
+
svg.setAttribute('stroke', accent())
|
|
711
|
+
svg.setAttribute('stroke-width', '2.2')
|
|
712
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
713
|
+
svg.setAttribute('stroke-linejoin', 'round')
|
|
714
|
+
svg.style.cssText = 'width:20px;height:20px;display:block;flex:none'
|
|
715
|
+
svg.innerHTML =
|
|
716
|
+
'<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>' +
|
|
717
|
+
'<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'
|
|
718
|
+
const dot = document.createElement('span')
|
|
719
|
+
dot.id = NS + '-dot'
|
|
720
|
+
dot.style.cssText = 'position:absolute;top:-1px;right:-1px;width:9px;height:9px;border-radius:50%;display:none;' +
|
|
721
|
+
'pointer-events:none;border:1.5px solid ' + pal.dotRing + ';background:' + pal.danger
|
|
722
|
+
fab.append(svg, dot)
|
|
723
|
+
document.body.appendChild(fab)
|
|
724
|
+
applyPalette(fab)
|
|
725
|
+
applyPos(false)
|
|
726
|
+
|
|
727
|
+
let dragging = false
|
|
728
|
+
let moved = 0
|
|
729
|
+
let sx = 0
|
|
730
|
+
let sy = 0
|
|
731
|
+
let suppressClick = false
|
|
732
|
+
fab.addEventListener('pointerdown', (e) => {
|
|
733
|
+
dragging = true
|
|
734
|
+
moved = 0
|
|
735
|
+
suppressClick = false
|
|
736
|
+
sx = e.clientX - pos.x
|
|
737
|
+
sy = e.clientY - pos.y
|
|
738
|
+
try { fab.setPointerCapture(e.pointerId) } catch { /* ignore */ }
|
|
739
|
+
fab.style.cursor = 'grabbing'
|
|
740
|
+
fab.style.transition = 'none'
|
|
741
|
+
pressDown()
|
|
742
|
+
})
|
|
743
|
+
fab.addEventListener('pointermove', (e) => {
|
|
744
|
+
if (!dragging) return
|
|
745
|
+
moved += Math.abs(e.movementX || 0) + Math.abs(e.movementY || 0)
|
|
746
|
+
pos.x = Math.max(4, Math.min(innerWidth - (fab.offsetWidth || 38) - 4, e.clientX - sx))
|
|
747
|
+
pos.y = Math.max(4, Math.min(innerHeight - (fab.offsetHeight || 38) - 4, e.clientY - sy))
|
|
748
|
+
fab.style.left = pos.x + 'px'
|
|
749
|
+
fab.style.top = pos.y + 'px'
|
|
750
|
+
// No direct relayout here: the rAF follower moves the card smoothly.
|
|
751
|
+
})
|
|
752
|
+
fab.addEventListener('pointerup', () => {
|
|
753
|
+
if (!dragging) return
|
|
754
|
+
dragging = false
|
|
755
|
+
fab.style.cursor = 'grab'
|
|
756
|
+
const w = fab.offsetWidth || 80
|
|
757
|
+
const nearL = pos.x <= EDGE
|
|
758
|
+
const nearR = pos.x >= innerWidth - w - EDGE
|
|
759
|
+
if (nearL || nearR) {
|
|
760
|
+
pos.docked = true
|
|
761
|
+
pos.x = nearL ? 4 : Math.max(4, innerWidth - (fab.offsetWidth || 38) - 4)
|
|
762
|
+
} else {
|
|
763
|
+
pos.docked = false
|
|
764
|
+
}
|
|
765
|
+
savePos()
|
|
766
|
+
applyPos(true)
|
|
767
|
+
if (ctx) relayout(true)
|
|
768
|
+
pressUp()
|
|
769
|
+
suppressClick = moved >= 5
|
|
770
|
+
})
|
|
771
|
+
fab.addEventListener('click', () => {
|
|
772
|
+
if (suppressClick) { suppressClick = false; return }
|
|
773
|
+
openPop(fab)
|
|
774
|
+
})
|
|
775
|
+
fab.addEventListener('pointercancel', () => { if (dragging) { dragging = false; fab.style.cursor = 'grab' } })
|
|
776
|
+
fab.addEventListener('dblclick', () => {
|
|
777
|
+
pos.docked = !pos.docked
|
|
778
|
+
if (pos.docked) pos.x = pos.x < innerWidth / 2 ? 4 : Math.max(4, innerWidth - (fab.offsetWidth || 38) - 4)
|
|
779
|
+
savePos()
|
|
780
|
+
applyPos(true)
|
|
781
|
+
if (ctx) relayout(true)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
wireDocument()
|
|
785
|
+
if (!observerRef) {
|
|
786
|
+
observerRef = new MutationObserver(() => {
|
|
787
|
+
const next = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
|
|
788
|
+
currentPalette = next
|
|
789
|
+
sampleDesign()
|
|
790
|
+
setThemeColors()
|
|
791
|
+
})
|
|
792
|
+
observerRef.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] })
|
|
793
|
+
observerRef.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] })
|
|
794
|
+
const media = window.matchMedia?.('(prefers-color-scheme: dark)')
|
|
795
|
+
if (media?.addEventListener) listen(media, 'change', () => {
|
|
796
|
+
currentPalette = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
|
|
797
|
+
sampleDesign(); setThemeColors()
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
listen(window, 'resize', () => { applyPos(false); if (ctx) relayout(false) })
|
|
802
|
+
setBadge()
|
|
803
|
+
fetchStatus()
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function createFallback() {
|
|
807
|
+
if (q(NS + '-fb')) return
|
|
808
|
+
const b = document.createElement('button')
|
|
809
|
+
b.id = NS + '-fb'
|
|
810
|
+
b.textContent = '⟳ 同步'
|
|
811
|
+
b.style.cssText =
|
|
812
|
+
'position:fixed;left:16px;top:50%;transform:translateY(-50%);z-index:2147483647;background:#2f6feb;color:#fff;' +
|
|
813
|
+
'border:none;border-radius:999px;padding:9px 16px;font:600 14px/1 system-ui,sans-serif;cursor:pointer;' +
|
|
814
|
+
'box-shadow:0 4px 14px rgba(16,24,40,.3)'
|
|
815
|
+
b.setAttribute('aria-expanded', 'false')
|
|
816
|
+
b.addEventListener('click', () => { try { openPop(b) } catch (e) { console.error(e) } })
|
|
817
|
+
document.body.appendChild(b)
|
|
818
|
+
console.error('[dsh-home-sync] fallback trigger created')
|
|
819
|
+
}
|
|
820
|
+
function runMount() {
|
|
821
|
+
try {
|
|
822
|
+
if (!q(NS + '-fab')) mount()
|
|
823
|
+
} catch (e) {
|
|
824
|
+
console.error('[dsh-home-sync] mount failed:', e)
|
|
825
|
+
createFallback()
|
|
826
|
+
}
|
|
827
|
+
if (!q(NS + '-fab') && !q(NS + '-fb')) createFallback()
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (document.readyState === 'loading') listen(document, 'DOMContentLoaded', runMount)
|
|
831
|
+
else runMount()
|
|
832
|
+
listen(window, 'load', () => { if (!q(NS + '-fab') && !q(NS + '-fb')) runMount() })
|
|
833
|
+
const mountTimer = setInterval(() => { if (!q(NS + '-fab') && !q(NS + '-fb')) runMount() }, 2500)
|
|
834
|
+
const statusTimer = setInterval(() => fetchStatus().then(st => { if (ctx) renderStatus(st) }), 15000)
|
|
835
|
+
window.__dshHomeSyncCleanup = () => {
|
|
836
|
+
closePop(); clearInterval(mountTimer); clearInterval(statusTimer)
|
|
837
|
+
observerRef?.disconnect()
|
|
838
|
+
for (const remove of globalListeners) remove()
|
|
839
|
+
for (const suffix of ['fab', 'fb', 'pop', 'toast']) q(NS + '-' + suffix)?.remove()
|
|
840
|
+
}
|
|
841
|
+
})()
|