@idoall/dsh-session-colors 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/README.zh.md +192 -0
- package/cordis.patch.yml +11 -0
- package/docs/images/color-picker.png +0 -0
- package/docs/images/mobile-drawer.png +0 -0
- package/docs/images/sidebar-chips.png +0 -0
- package/package.json +87 -0
- package/src/client.js +1109 -0
- package/src/index.js +244 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `@idoall/dsh-session-colors`: per-Session colour marks.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces, both reached through public slots, so no shipped file is
|
|
5
|
+
* touched and nothing is DOM-patched:
|
|
6
|
+
*
|
|
7
|
+
* - `conversation.session.header.actions` carries the picker button and its
|
|
8
|
+
* system-style panel (saturation/value field, hue, alpha, hex, RGBA, theme
|
|
9
|
+
* swatches, saved colours).
|
|
10
|
+
* - `shell.overlay` carries the chip layer: a frame-wide, fixed, click-through
|
|
11
|
+
* surface on which one small colour chip is painted per marked Session row.
|
|
12
|
+
* The surface is registered there but rendered through a portal on
|
|
13
|
+
* `document.body`, because DSH confines the overlay to a stacking context of
|
|
14
|
+
* its own and a phone adapter may promote the sidebar above it — see
|
|
15
|
+
* `ChipsLayer` and `layerZIndexOf`.
|
|
16
|
+
*
|
|
17
|
+
* There is no Session-row slot, so a chip cannot be rendered inside a row. The
|
|
18
|
+
* layer finds rows by ARIA role and reads each row's Session id from the React
|
|
19
|
+
* fiber the renderer already attached — the only place the id exists, since the
|
|
20
|
+
* row markup carries no id attribute. That coupling is deliberate and is kept
|
|
21
|
+
* harmless: every step is guarded, an unreadable row is skipped, and the layer
|
|
22
|
+
* never accepts pointer events, so a future DSH change can at worst stop the
|
|
23
|
+
* chips from appearing.
|
|
24
|
+
*
|
|
25
|
+
* Written as a hand-authored `__ModuleLoader__` bundle (the only format the
|
|
26
|
+
* client loader accepts for a package client half), so it needs no build step.
|
|
27
|
+
*/
|
|
28
|
+
window.__ModuleLoader__.load({
|
|
29
|
+
id: '@idoall/dsh-session-colors',
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
const module = { exports: {} }
|
|
32
|
+
const exports = module.exports
|
|
33
|
+
const React = require('react')
|
|
34
|
+
/**
|
|
35
|
+
* `react-dom` is one of the DSH web shell's static modules. It is read
|
|
36
|
+
* defensively: a shell that stopped exposing it would cost the chip layer
|
|
37
|
+
* its escape from the shell overlay's stacking context (see `ChipsLayer`),
|
|
38
|
+
* not the whole plugin.
|
|
39
|
+
*/
|
|
40
|
+
let createPortal
|
|
41
|
+
try {
|
|
42
|
+
createPortal = require('react-dom').createPortal
|
|
43
|
+
} catch {
|
|
44
|
+
createPortal = undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const NS = 'sessionColor'
|
|
48
|
+
/**
|
|
49
|
+
* Build marker. Stored marks are Host-side as of this build, and the chip
|
|
50
|
+
* layer paints just above the sidebar as of the `drawer-aware` build — the
|
|
51
|
+
* change that makes a chip visible inside a phone's sidebar drawer. A device
|
|
52
|
+
* that still shows an older marker is running a cached client and must be
|
|
53
|
+
* hard-refreshed.
|
|
54
|
+
*/
|
|
55
|
+
const BUILD = 'host-routes+drawer-aware'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This plugin's own Host route. Kept in sync with the Host half's
|
|
59
|
+
* ROUTE_PREFIX; it is the durable store because DSH only host-backs the
|
|
60
|
+
* `settings` service for loopback pages.
|
|
61
|
+
*/
|
|
62
|
+
const MARKS_ROUTE = '/plugins/dsh-session-colors/marks'
|
|
63
|
+
|
|
64
|
+
/** How often an open page re-reads marks another device may have changed. */
|
|
65
|
+
const PEER_POLL_MS = 20000
|
|
66
|
+
const ACTION_ID = 'session-color-mark'
|
|
67
|
+
const OVERLAY_ID = 'session-color-chips'
|
|
68
|
+
const STYLE_ID = 'dsh-session-color-style'
|
|
69
|
+
|
|
70
|
+
/** Chip geometry: a bar in the row's 8px left padding, clear of the status dot. */
|
|
71
|
+
const CHIP_WIDTH = 4
|
|
72
|
+
const CHIP_HEIGHT = 16
|
|
73
|
+
const CHIP_OFFSET = 2
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How long to keep re-measuring after an ancestor of the rows starts moving.
|
|
77
|
+
* The phone adapter slides its sidebar in over ~250ms; 40 frames covers that
|
|
78
|
+
* and then stops on its own, so an idle page never runs a measuring loop.
|
|
79
|
+
*/
|
|
80
|
+
const SETTLE_FRAMES = 40
|
|
81
|
+
|
|
82
|
+
/** Theme swatches: the shipped macOS picker set, last one fully transparent. */
|
|
83
|
+
const THEME = [
|
|
84
|
+
['#FF3B30', 1], ['#34C759', 1], ['#007AFF', 1], ['#FFCC00', 1],
|
|
85
|
+
['#FFFFFF', 1], ['#000000', 1], ['#FFFFFF', 0],
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
const zh = {
|
|
89
|
+
mark: '颜色标记',
|
|
90
|
+
clear: '清除标记',
|
|
91
|
+
theme: '主题颜色',
|
|
92
|
+
mine: '我的颜色',
|
|
93
|
+
hex: '十六进制',
|
|
94
|
+
alpha: '透明度',
|
|
95
|
+
saturation: '饱和度与明度',
|
|
96
|
+
hue: '色相',
|
|
97
|
+
eyedrop: '吸取屏幕颜色',
|
|
98
|
+
addMine: '把当前颜色加入我的颜色',
|
|
99
|
+
custom: '自定义颜色',
|
|
100
|
+
layer: '会话颜色标记',
|
|
101
|
+
panelTitle: '选择会话颜色',
|
|
102
|
+
none: '当前会话没有颜色标记',
|
|
103
|
+
current: (hex, alpha) => `当前会话颜色:${hex} · A ${alpha}%`,
|
|
104
|
+
saved: '颜色只保存会话 ID 与颜色值:不复制、不移动会话,也不改所属工作区与排序。',
|
|
105
|
+
hint: '点「颜色标记」打开选择器。',
|
|
106
|
+
offline: '颜色标记暂时读不到宿主设置:这台设备可能还在用旧的客户端缓存,请强制刷新(Cmd+Shift+R)。',
|
|
107
|
+
chip: '已标记颜色',
|
|
108
|
+
}
|
|
109
|
+
const en = {
|
|
110
|
+
mark: 'Colour mark',
|
|
111
|
+
clear: 'Clear mark',
|
|
112
|
+
theme: 'Theme colours',
|
|
113
|
+
mine: 'My colours',
|
|
114
|
+
hex: 'Hex',
|
|
115
|
+
alpha: 'Alpha',
|
|
116
|
+
saturation: 'Saturation and brightness',
|
|
117
|
+
hue: 'Hue',
|
|
118
|
+
eyedrop: 'Pick a colour from the screen',
|
|
119
|
+
addMine: 'Save the current colour',
|
|
120
|
+
custom: 'Custom colour',
|
|
121
|
+
layer: 'Session colour marks',
|
|
122
|
+
panelTitle: 'Choose a Session colour',
|
|
123
|
+
none: 'This Session has no colour mark',
|
|
124
|
+
current: (hex, alpha) => `Session colour: ${hex} · A ${alpha}%`,
|
|
125
|
+
saved: 'A mark stores only the Session id and colour value: it never copies, moves, or reorders a Session.',
|
|
126
|
+
hint: 'Open the picker with “Colour mark”.',
|
|
127
|
+
offline: 'Colour marks cannot reach the Host settings yet: this device may still run a cached client. Hard-refresh (Cmd+Shift+R).',
|
|
128
|
+
chip: 'Colour marked',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
//#region colour maths
|
|
132
|
+
function clamp(value, min, max) {
|
|
133
|
+
return Math.min(max, Math.max(min, value))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hsvToRgb(h, s, v) {
|
|
137
|
+
const c = v * s
|
|
138
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
139
|
+
const m = v - c
|
|
140
|
+
let r = 0
|
|
141
|
+
let g = 0
|
|
142
|
+
let b = 0
|
|
143
|
+
if (h < 60) { r = c; g = x } else if (h < 120) { r = x; g = c } else if (h < 180) { g = c; b = x } else if (h < 240) { g = x; b = c } else if (h < 300) { r = x; b = c } else { r = c; b = x }
|
|
144
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function rgbToHsv(r, g, b) {
|
|
148
|
+
const red = r / 255
|
|
149
|
+
const green = g / 255
|
|
150
|
+
const blue = b / 255
|
|
151
|
+
const max = Math.max(red, green, blue)
|
|
152
|
+
const min = Math.min(red, green, blue)
|
|
153
|
+
const delta = max - min
|
|
154
|
+
let h = 0
|
|
155
|
+
if (delta !== 0) {
|
|
156
|
+
if (max === red) h = 60 * (((green - blue) / delta) % 6)
|
|
157
|
+
else if (max === green) h = 60 * ((blue - red) / delta + 2)
|
|
158
|
+
else h = 60 * ((red - green) / delta + 4)
|
|
159
|
+
}
|
|
160
|
+
if (h < 0) h += 360
|
|
161
|
+
return { h, s: max === 0 ? 0 : delta / max, v: max }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function byteToHex(value) {
|
|
165
|
+
return value.toString(16).padStart(2, '0').toUpperCase()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toHex(color) {
|
|
169
|
+
const [r, g, b] = hsvToRgb(color.h, color.s, color.v)
|
|
170
|
+
return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseHex(raw) {
|
|
174
|
+
const value = String(raw).trim().replace(/^#/, '').toUpperCase()
|
|
175
|
+
if (!/^[0-9A-F]{3}$|^[0-9A-F]{6}$|^[0-9A-F]{8}$/.test(value)) return undefined
|
|
176
|
+
const full = value.length === 3 ? value.split('').map((ch) => ch + ch).join('') : value.slice(0, 6)
|
|
177
|
+
const r = parseInt(full.slice(0, 2), 16)
|
|
178
|
+
const g = parseInt(full.slice(2, 4), 16)
|
|
179
|
+
const b = parseInt(full.slice(4, 6), 16)
|
|
180
|
+
const a = value.length === 8 ? parseInt(value.slice(6, 8), 16) / 255 : 1
|
|
181
|
+
return { ...rgbToHsv(r, g, b), a }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** CSS colour for a stored mark, including its alpha. */
|
|
185
|
+
function cssOf(color) {
|
|
186
|
+
if (color === undefined) return undefined
|
|
187
|
+
const [r, g, b] = hsvToRgb(color.h, color.s, color.v)
|
|
188
|
+
return `rgba(${r}, ${g}, ${b}, ${color.a})`
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
|
|
192
|
+
//#region preference store
|
|
193
|
+
/** One HSVA colour, as the Host route accepts it. */
|
|
194
|
+
function isColor(value) {
|
|
195
|
+
return value !== null
|
|
196
|
+
&& typeof value === 'object'
|
|
197
|
+
&& typeof value.h === 'number'
|
|
198
|
+
&& typeof value.s === 'number'
|
|
199
|
+
&& typeof value.v === 'number'
|
|
200
|
+
&& typeof value.a === 'number'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Keep only well-formed entries so one bad record cannot break the layer. */
|
|
204
|
+
function readMarks(marks) {
|
|
205
|
+
const colors = {}
|
|
206
|
+
if (marks === null || typeof marks !== 'object') return colors
|
|
207
|
+
for (const id of Object.keys(marks)) {
|
|
208
|
+
if (isColor(marks[id])) colors[id] = marks[id]
|
|
209
|
+
}
|
|
210
|
+
return colors
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Marks store backed by this plugin's own Host route.
|
|
215
|
+
*
|
|
216
|
+
* Not `settingsScope`: DSH host-backs user settings only for loopback pages
|
|
217
|
+
* (`persistence = $host.isLoopback ? "host" : "memory"`), so a page opened
|
|
218
|
+
* over the LAN or a tunnel gets a permanently `unavailable` scope. An own
|
|
219
|
+
* route is not subject to that rule, which is what makes a mark set on one
|
|
220
|
+
* device visible on another.
|
|
221
|
+
*
|
|
222
|
+
* Writes are optimistic — the UI updates at once — and the Host's answer
|
|
223
|
+
* replaces the view, so a failed write is visible rather than silent.
|
|
224
|
+
*/
|
|
225
|
+
function createStore() {
|
|
226
|
+
let snapshot = { colors: {}, ready: false, status: 'loading', writable: true, error: '' }
|
|
227
|
+
const listeners = new Set()
|
|
228
|
+
|
|
229
|
+
const publish = () => {
|
|
230
|
+
for (const listener of [...listeners]) {
|
|
231
|
+
try {
|
|
232
|
+
listener()
|
|
233
|
+
} catch {
|
|
234
|
+
// A failing consumer must not stop the others.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const describe = () => ({
|
|
240
|
+
status: snapshot.status,
|
|
241
|
+
writable: snapshot.writable,
|
|
242
|
+
marks: Object.keys(snapshot.colors).length,
|
|
243
|
+
error: snapshot.error,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
/** Adopt whatever the Host serves. */
|
|
247
|
+
const load = () => {
|
|
248
|
+
let request
|
|
249
|
+
try {
|
|
250
|
+
request = fetch(MARKS_ROUTE, { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
251
|
+
} catch (error) {
|
|
252
|
+
snapshot = { ...snapshot, ready: false, status: 'unreachable', error: String(error) }
|
|
253
|
+
publish()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
// `.then(onOk, onErr)` would leave the async handler's own throw
|
|
257
|
+
// unhandled; a trailing catch covers both the HTTP failure and the
|
|
258
|
+
// body parse.
|
|
259
|
+
request
|
|
260
|
+
.then(async (response) => {
|
|
261
|
+
if (!response.ok) throw new Error(`marks route answered ${response.status}`)
|
|
262
|
+
const payload = await response.json()
|
|
263
|
+
snapshot = {
|
|
264
|
+
colors: readMarks(payload === null || payload === undefined ? undefined : payload.marks),
|
|
265
|
+
ready: true,
|
|
266
|
+
status: 'ready',
|
|
267
|
+
writable: true,
|
|
268
|
+
error: '',
|
|
269
|
+
}
|
|
270
|
+
publish()
|
|
271
|
+
})
|
|
272
|
+
.catch((error) => {
|
|
273
|
+
snapshot = { ...snapshot, ready: false, status: 'unreachable', error: String(error) }
|
|
274
|
+
publish()
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const write = (sessionId, color) => {
|
|
279
|
+
const colors = { ...snapshot.colors }
|
|
280
|
+
if (color === undefined) delete colors[sessionId]
|
|
281
|
+
else colors[sessionId] = color
|
|
282
|
+
snapshot = { ...snapshot, colors }
|
|
283
|
+
publish()
|
|
284
|
+
let request
|
|
285
|
+
try {
|
|
286
|
+
request = fetch(MARKS_ROUTE, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
credentials: 'same-origin',
|
|
289
|
+
headers: { 'content-type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({ sessionId, color: color === undefined ? null : color }),
|
|
291
|
+
})
|
|
292
|
+
} catch (error) {
|
|
293
|
+
snapshot = { ...snapshot, error: String(error), status: 'write-failed' }
|
|
294
|
+
publish()
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
request
|
|
298
|
+
.then(async (response) => {
|
|
299
|
+
if (!response.ok) throw new Error(`marks route answered ${response.status}`)
|
|
300
|
+
const payload = await response.json()
|
|
301
|
+
snapshot = {
|
|
302
|
+
colors: readMarks(payload === null || payload === undefined ? undefined : payload.marks),
|
|
303
|
+
ready: true,
|
|
304
|
+
status: 'ready',
|
|
305
|
+
writable: true,
|
|
306
|
+
error: '',
|
|
307
|
+
}
|
|
308
|
+
publish()
|
|
309
|
+
})
|
|
310
|
+
.catch((error) => {
|
|
311
|
+
// Keep the optimistic view but say it is unsaved, then reconcile.
|
|
312
|
+
snapshot = { ...snapshot, status: 'write-failed', error: String(error) }
|
|
313
|
+
publish()
|
|
314
|
+
load()
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Another device may have changed a mark: re-read when the page becomes
|
|
319
|
+
// visible again and on a slow interval while it stays open.
|
|
320
|
+
const onVisible = () => {
|
|
321
|
+
try {
|
|
322
|
+
if (document.visibilityState === 'visible') load()
|
|
323
|
+
} catch {
|
|
324
|
+
// No document visibility: the interval still covers it.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
329
|
+
} catch {
|
|
330
|
+
// Nothing to attach.
|
|
331
|
+
}
|
|
332
|
+
const timer = setInterval(load, PEER_POLL_MS)
|
|
333
|
+
// Under a test runner this is a Node Timeout, and a live interval would
|
|
334
|
+
// hold the process open after the suite finishes. Browsers return a
|
|
335
|
+
// number, where unref does not exist and is not needed.
|
|
336
|
+
if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
|
|
337
|
+
|
|
338
|
+
load()
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
describe,
|
|
342
|
+
subscribe: (listener) => {
|
|
343
|
+
listeners.add(listener)
|
|
344
|
+
return () => { listeners.delete(listener) }
|
|
345
|
+
},
|
|
346
|
+
getSnapshot: () => snapshot,
|
|
347
|
+
set: (sessionId, color) => { write(sessionId, color) },
|
|
348
|
+
clear: (sessionId) => { write(sessionId, undefined) },
|
|
349
|
+
refresh: load,
|
|
350
|
+
dispose: () => {
|
|
351
|
+
clearInterval(timer)
|
|
352
|
+
try {
|
|
353
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
354
|
+
} catch {
|
|
355
|
+
// Nothing to detach.
|
|
356
|
+
}
|
|
357
|
+
listeners.clear()
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
|
|
363
|
+
//#region stylesheet
|
|
364
|
+
/**
|
|
365
|
+
* The picker keeps the system panel's own light palette rather than the
|
|
366
|
+
* app's dark chrome, so it reads as an attached system control. The chip
|
|
367
|
+
* layer itself is inert: fixed, full-frame and click-through.
|
|
368
|
+
*
|
|
369
|
+
* The layer's stacking level is measured per render (`layerZIndexOf`), not
|
|
370
|
+
* written here: the surface it decorates is the sidebar, and an adapter may
|
|
371
|
+
* promote that sidebar into a fixed drawer carrying a z-index of its own.
|
|
372
|
+
* The 1 below is only what the layer paints at before its first measurement.
|
|
373
|
+
*/
|
|
374
|
+
const STYLE = `
|
|
375
|
+
.dsh-sc-layer{position:fixed;inset:0;z-index:1;overflow:hidden;pointer-events:none}
|
|
376
|
+
.dsh-sc-chip{position:absolute;width:${CHIP_WIDTH}px;height:${CHIP_HEIGHT}px;border-radius:2px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.28)}
|
|
377
|
+
.dsh-sc-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(128,128,128,.5);border-radius:7px;color:inherit;background:rgba(128,128,128,.12);cursor:pointer;font-size:13px;line-height:1}
|
|
378
|
+
.dsh-sc-action:hover{border-color:rgba(128,128,128,.85);background:rgba(128,128,128,.22)}
|
|
379
|
+
.dsh-sc-action:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
|
|
380
|
+
.dsh-sc-dot{width:13px;height:13px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(128,128,128,.6)}
|
|
381
|
+
.dsh-sc-panel{position:fixed;z-index:2147483001;box-sizing:border-box;width:min(268px,calc(100vw - 16px));max-height:calc(100vh - 16px);overflow:auto;padding:12px;border:1px solid #d8d8dc;border-radius:14px;background:#f6f6f8;color:#1d1d1f;box-shadow:0 18px 48px rgba(0,0,0,.34),0 0 0 1px rgba(0,0,0,.05);font:400 13px Inter,ui-sans-serif,system-ui,-apple-system,"PingFang SC",sans-serif}
|
|
382
|
+
.dsh-sc-panel *{box-sizing:border-box}
|
|
383
|
+
.dsh-sc-title{margin:0 0 10px;color:#1d1d1f;font-size:13px;font-weight:600}
|
|
384
|
+
.dsh-sc-sv{position:relative;height:128px;border-radius:10px;overflow:hidden;cursor:crosshair;touch-action:none}
|
|
385
|
+
.dsh-sc-sv-w,.dsh-sc-sv-b{position:absolute;inset:0;pointer-events:none}
|
|
386
|
+
.dsh-sc-sv-w{background:linear-gradient(to right,#fff,transparent)}
|
|
387
|
+
.dsh-sc-sv-b{background:linear-gradient(to bottom,transparent,#000)}
|
|
388
|
+
.dsh-sc-ring{position:absolute;width:16px;height:16px;margin:-8px 0 0 -8px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.3);pointer-events:none}
|
|
389
|
+
.dsh-sc-row{display:flex;align-items:center;gap:10px;margin-top:12px}
|
|
390
|
+
.dsh-sc-drop{width:36px;height:36px;flex:none;display:grid;place-items:center;border:1px solid #d0d0d6;border-radius:10px;background:#fff;cursor:pointer;color:#3a3a3c;padding:0}
|
|
391
|
+
.dsh-sc-drop[disabled]{opacity:.45;cursor:not-allowed}
|
|
392
|
+
.dsh-sc-sliders{flex:1;display:grid;gap:8px}
|
|
393
|
+
.dsh-sc-hue,.dsh-sc-alpha{position:relative;height:12px;border-radius:999px;touch-action:none}
|
|
394
|
+
.dsh-sc-hue{background:linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
|
|
395
|
+
.dsh-sc-alpha{background-image:linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%),linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%);background-size:8px 8px;background-position:0 0,4px 4px}
|
|
396
|
+
.dsh-sc-alpha-fill{position:absolute;inset:0;border-radius:999px}
|
|
397
|
+
.dsh-sc-knob{position:absolute;top:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border:2px solid #fff;border-radius:50%;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25),0 1px 2px rgba(0,0,0,.25);pointer-events:none}
|
|
398
|
+
.dsh-sc-fields{display:grid;grid-template-columns:1.55fr repeat(4,1fr);gap:4px;margin-top:12px}
|
|
399
|
+
.dsh-sc-field{display:grid;gap:3px;min-width:0}
|
|
400
|
+
.dsh-sc-field span{color:#6e6e73;font-size:11px}
|
|
401
|
+
.dsh-sc-field input{width:100%;height:28px;padding:0 2px;border:1px solid #d0d0d6;border-radius:7px;background:#fff;color:#1d1d1f;font-size:11px;text-align:center}
|
|
402
|
+
.dsh-sc-field input:focus-visible{outline:2px solid #76a9ff;outline-offset:1px}
|
|
403
|
+
.dsh-sc-section{margin-top:12px;padding-top:10px;border-top:1px solid #e5e5ea}
|
|
404
|
+
.dsh-sc-section h4{margin:0 0 8px;color:#6e6e73;font-size:12px;font-weight:600}
|
|
405
|
+
.dsh-sc-swatches{display:flex;flex-wrap:wrap;gap:7px}
|
|
406
|
+
.dsh-sc-swatch{width:22px;height:22px;border:1px solid rgba(0,0,0,.14);border-radius:6px;padding:0;cursor:pointer;background-image:linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%),linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%);background-size:8px 8px;background-position:0 0,4px 4px}
|
|
407
|
+
.dsh-sc-swatch:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
|
|
408
|
+
.dsh-sc-swatch i{display:block;width:100%;height:100%;border-radius:5px}
|
|
409
|
+
.dsh-sc-add{width:22px;height:22px;border:1px dashed #b0b0b8;border-radius:6px;background:#fff;color:#3a3a3c;cursor:pointer;font-size:16px;line-height:1;padding:0}
|
|
410
|
+
.dsh-sc-actions{display:flex;justify-content:flex-end;gap:6px;margin-top:10px}
|
|
411
|
+
.dsh-sc-btn{height:26px;padding:0 10px;border:1px solid #d0d0d6;border-radius:7px;background:#fff;color:#1d1d1f;cursor:pointer;font-size:12px}
|
|
412
|
+
.dsh-sc-btn:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
|
|
413
|
+
`
|
|
414
|
+
|
|
415
|
+
/** Install the stylesheet once per document, and hand back its removal. */
|
|
416
|
+
function installStyle() {
|
|
417
|
+
try {
|
|
418
|
+
if (document.getElementById(STYLE_ID) !== null) return () => {}
|
|
419
|
+
const node = document.createElement('style')
|
|
420
|
+
node.id = STYLE_ID
|
|
421
|
+
node.textContent = STYLE
|
|
422
|
+
document.head.append(node)
|
|
423
|
+
return () => { node.remove() }
|
|
424
|
+
} catch {
|
|
425
|
+
return () => {}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
|
|
430
|
+
//#region row discovery
|
|
431
|
+
/**
|
|
432
|
+
* Session id of a rendered row, read from the React fiber the renderer
|
|
433
|
+
* already attached. The row markup deliberately carries no id, so the fiber
|
|
434
|
+
* is the only exact source; the walk is bounded and every failure returns
|
|
435
|
+
* undefined, which makes the caller skip that row.
|
|
436
|
+
* @param node - a Session row element.
|
|
437
|
+
*/
|
|
438
|
+
function sessionIdOf(node) {
|
|
439
|
+
try {
|
|
440
|
+
const key = Object.keys(node).find((name) => name.startsWith('__reactFiber$') || name.startsWith('__reactInternalInstance$'))
|
|
441
|
+
if (key === undefined) return undefined
|
|
442
|
+
let fiber = node[key]
|
|
443
|
+
for (let depth = 0; fiber !== null && fiber !== undefined && depth < 80; depth += 1) {
|
|
444
|
+
const props = fiber.memoizedProps
|
|
445
|
+
if (props !== null && typeof props === 'object') {
|
|
446
|
+
const candidate = props.node
|
|
447
|
+
if (candidate !== null && typeof candidate === 'object' && typeof candidate.id === 'string') return candidate.id
|
|
448
|
+
if (typeof props.sessionId === 'string') return props.sessionId
|
|
449
|
+
}
|
|
450
|
+
fiber = fiber.return
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
// An unexpected fiber shape only costs this row its chip.
|
|
454
|
+
}
|
|
455
|
+
return undefined
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Rendered Session rows, in document order. */
|
|
459
|
+
function sessionRows() {
|
|
460
|
+
try {
|
|
461
|
+
return [...document.querySelectorAll('[role="treeitem"][aria-selected]')]
|
|
462
|
+
} catch {
|
|
463
|
+
return []
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
|
|
468
|
+
//#region components
|
|
469
|
+
/**
|
|
470
|
+
* The nearest scrolling ancestor of a rendered row: the sidebar's list
|
|
471
|
+
* container. Chips are clipped to it, because a row scrolled out of that
|
|
472
|
+
* container still has a viewport-relative box and would otherwise paint a
|
|
473
|
+
* chip on top of unrelated UI outside the sidebar.
|
|
474
|
+
* @param node - a Session row element.
|
|
475
|
+
*/
|
|
476
|
+
function scrollContainerOf(node) {
|
|
477
|
+
try {
|
|
478
|
+
let current = node === null || node === undefined ? null : node.parentElement
|
|
479
|
+
for (let depth = 0; current !== null && current !== undefined && depth < 12; depth += 1) {
|
|
480
|
+
const style = getComputedStyle(current)
|
|
481
|
+
const overflow = `${style.overflowY} ${style.overflow}`
|
|
482
|
+
if (/auto|scroll|hidden/.test(overflow) && current.clientHeight > 0) return current
|
|
483
|
+
current = current.parentElement
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
// Without a container the layer falls back to clipping at the viewport.
|
|
487
|
+
}
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Viewport-relative clip rectangle for the chip layer, or 'none'. */
|
|
492
|
+
function clipFor(container) {
|
|
493
|
+
if (container === null || container === undefined) return 'none'
|
|
494
|
+
try {
|
|
495
|
+
const box = container.getBoundingClientRect()
|
|
496
|
+
const top = Math.max(0, box.top)
|
|
497
|
+
const left = Math.max(0, box.left)
|
|
498
|
+
const right = Math.max(0, window.innerWidth - Math.min(window.innerWidth, box.right))
|
|
499
|
+
const bottom = Math.max(0, window.innerHeight - Math.min(window.innerHeight, box.bottom))
|
|
500
|
+
return `inset(${top}px ${right}px ${bottom}px ${left}px)`
|
|
501
|
+
} catch {
|
|
502
|
+
return 'none'
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Stacking level the chip layer must paint at, read from the row's own
|
|
508
|
+
* ancestor chain.
|
|
509
|
+
*
|
|
510
|
+
* The layer decorates the sidebar, so it belongs immediately above the
|
|
511
|
+
* sidebar — not above the whole app, which would paint chips over dialogs.
|
|
512
|
+
* Desktop DSH keeps the sidebar in normal flow (`z-index: auto`), so the
|
|
513
|
+
* answer is 1 there. A phone adapter promotes the sidebar to a
|
|
514
|
+
* `position: fixed; z-index: 10000` drawer, and a layer left at 1 is painted
|
|
515
|
+
* behind that drawer's opaque background — which is exactly how the chips
|
|
516
|
+
* vanished on a phone while a DOM dump still showed them present.
|
|
517
|
+
*
|
|
518
|
+
* The OUTERMOST z-index wins: an inner one is already confined by the
|
|
519
|
+
* stacking context of the outer one, so the inner value is not the level
|
|
520
|
+
* that competes with the layer. An unreadable chain falls back to 1, which
|
|
521
|
+
* is where the layer sits on a stock desktop.
|
|
522
|
+
* @param row - a rendered Session row.
|
|
523
|
+
*/
|
|
524
|
+
function layerZIndexOf(row) {
|
|
525
|
+
let level = 0
|
|
526
|
+
try {
|
|
527
|
+
let node = row === null || row === undefined ? null : row.parentElement
|
|
528
|
+
for (let depth = 0; node !== null && node !== undefined && node !== document.documentElement && depth < 40; depth += 1) {
|
|
529
|
+
const style = getComputedStyle(node)
|
|
530
|
+
if (style.position !== 'static') {
|
|
531
|
+
const value = Number.parseInt(style.zIndex, 10)
|
|
532
|
+
if (Number.isFinite(value)) level = value
|
|
533
|
+
}
|
|
534
|
+
node = node.parentElement
|
|
535
|
+
}
|
|
536
|
+
} catch {
|
|
537
|
+
// No readable chain: the flow position is still better than nothing.
|
|
538
|
+
}
|
|
539
|
+
return level + 1
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** One animation frame, falling back to a timer where rAF is absent. */
|
|
543
|
+
function requestFrame(run) {
|
|
544
|
+
return typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame(run) : window.setTimeout(run, 16)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Cancel a frame handed out by `requestFrame`, whichever mechanism it used. */
|
|
548
|
+
function cancelFrame(handle) {
|
|
549
|
+
if (handle === undefined) return
|
|
550
|
+
if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(handle)
|
|
551
|
+
else window.clearTimeout(handle)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** One chip per marked row, positioned from the row's own box. */
|
|
555
|
+
function ChipsLayer(props) {
|
|
556
|
+
const { store, t } = props
|
|
557
|
+
const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
558
|
+
const [layer, setLayer] = React.useState({ items: [], clip: 'none', zIndex: 1 })
|
|
559
|
+
|
|
560
|
+
React.useEffect(() => {
|
|
561
|
+
let frame
|
|
562
|
+
let settling = 0
|
|
563
|
+
const step = () => {
|
|
564
|
+
frame = undefined
|
|
565
|
+
sync()
|
|
566
|
+
if (settling > 0) {
|
|
567
|
+
settling -= 1
|
|
568
|
+
frame = requestFrame(step)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** Follow a moving ancestor for a while, then stop measuring. */
|
|
572
|
+
const follow = () => {
|
|
573
|
+
settling = SETTLE_FRAMES
|
|
574
|
+
if (frame === undefined) frame = requestFrame(step)
|
|
575
|
+
}
|
|
576
|
+
const sync = () => {
|
|
577
|
+
const rows = sessionRows()
|
|
578
|
+
const container = rows.length === 0 ? null : scrollContainerOf(rows[0])
|
|
579
|
+
const clip = clipFor(container)
|
|
580
|
+
const bounds = container === null ? null : container.getBoundingClientRect()
|
|
581
|
+
const items = []
|
|
582
|
+
for (const row of rows) {
|
|
583
|
+
const id = sessionIdOf(row)
|
|
584
|
+
if (id === undefined) continue
|
|
585
|
+
const color = snapshot.colors[id]
|
|
586
|
+
if (color === undefined) continue
|
|
587
|
+
let box
|
|
588
|
+
try {
|
|
589
|
+
box = row.getBoundingClientRect()
|
|
590
|
+
} catch {
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
if (box.width === 0 && box.height === 0) continue
|
|
594
|
+
// Skip rows the container has scrolled out of view entirely.
|
|
595
|
+
if (bounds !== null && (box.bottom <= bounds.top || box.top >= bounds.bottom)) continue
|
|
596
|
+
items.push({
|
|
597
|
+
key: id,
|
|
598
|
+
left: box.left + CHIP_OFFSET,
|
|
599
|
+
top: box.top + (box.height - CHIP_HEIGHT) / 2,
|
|
600
|
+
background: cssOf(color),
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
setLayer({ items, clip, zIndex: rows.length === 0 ? 1 : layerZIndexOf(rows[0]) })
|
|
604
|
+
}
|
|
605
|
+
sync()
|
|
606
|
+
// Rows move with their scroll container and re-render on every list
|
|
607
|
+
// change, so follow both.
|
|
608
|
+
document.addEventListener('scroll', sync, true)
|
|
609
|
+
window.addEventListener('resize', sync)
|
|
610
|
+
// A CSS transition moves every row without any scroll, resize or DOM
|
|
611
|
+
// mutation: the phone adapter slides the whole sidebar in with a
|
|
612
|
+
// transform on the drawer while the rows themselves never change. Only
|
|
613
|
+
// transitions on an ancestor of the rows are followed, so a hover on one
|
|
614
|
+
// row does not start a measuring loop.
|
|
615
|
+
const onTransition = (event) => {
|
|
616
|
+
const target = event === null || event === undefined ? undefined : event.target
|
|
617
|
+
if (target === null || target === undefined || typeof target.contains !== 'function') return
|
|
618
|
+
const rows = sessionRows()
|
|
619
|
+
if (rows.length === 0 || rows.includes(target) || !target.contains(rows[0])) return
|
|
620
|
+
follow()
|
|
621
|
+
}
|
|
622
|
+
document.addEventListener('transitionrun', onTransition, true)
|
|
623
|
+
document.addEventListener('transitionend', onTransition, true)
|
|
624
|
+
let observer
|
|
625
|
+
try {
|
|
626
|
+
observer = new MutationObserver(sync)
|
|
627
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
628
|
+
// The phone adapter opens its drawer by toggling a class on `body`.
|
|
629
|
+
// With transitions disabled the geometry still changes, just without a
|
|
630
|
+
// transition event to follow.
|
|
631
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
|
632
|
+
} catch {
|
|
633
|
+
observer = undefined
|
|
634
|
+
}
|
|
635
|
+
return () => {
|
|
636
|
+
cancelFrame(frame)
|
|
637
|
+
document.removeEventListener('scroll', sync, true)
|
|
638
|
+
window.removeEventListener('resize', sync)
|
|
639
|
+
document.removeEventListener('transitionrun', onTransition, true)
|
|
640
|
+
document.removeEventListener('transitionend', onTransition, true)
|
|
641
|
+
if (observer !== undefined) observer.disconnect()
|
|
642
|
+
}
|
|
643
|
+
}, [snapshot])
|
|
644
|
+
|
|
645
|
+
const node = React.createElement(
|
|
646
|
+
'div',
|
|
647
|
+
{
|
|
648
|
+
className: 'dsh-sc-layer',
|
|
649
|
+
role: 'presentation',
|
|
650
|
+
'aria-hidden': 'true',
|
|
651
|
+
title: t('layer'),
|
|
652
|
+
// Readable from a DOM dump: which build this device runs, and whether
|
|
653
|
+
// the Host settings channel is live. A device without this element is
|
|
654
|
+
// running a cached client from before marks moved to the Host.
|
|
655
|
+
'data-dsh-sc-build': BUILD,
|
|
656
|
+
'data-dsh-sc-status': snapshot.status,
|
|
657
|
+
'data-dsh-sc-writable': String(snapshot.writable),
|
|
658
|
+
'data-dsh-sc-marks': String(Object.keys(snapshot.colors).length),
|
|
659
|
+
'data-dsh-sc-error': snapshot.error,
|
|
660
|
+
style: { clipPath: layer.clip, zIndex: layer.zIndex },
|
|
661
|
+
},
|
|
662
|
+
layer.items.map((chip) => React.createElement('span', {
|
|
663
|
+
key: chip.key,
|
|
664
|
+
className: 'dsh-sc-chip',
|
|
665
|
+
style: { left: `${chip.left}px`, top: `${chip.top}px`, background: chip.background },
|
|
666
|
+
})),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
// DSH confines `shell.overlay` to a stacking context of its own (`z-index:
|
|
670
|
+
// 20` on the overlay layer), and nothing inside a stacking context can be
|
|
671
|
+
// raised above a sibling of it — so a sidebar that an adapter promotes to
|
|
672
|
+
// `z-index: 10000` always painted over the chips. A portal to the document
|
|
673
|
+
// body takes the layer out of that context, and the level measured above
|
|
674
|
+
// then decides how far up it sits. Without `react-dom` the layer stays in
|
|
675
|
+
// the slot, which is the old behaviour rather than a missing surface.
|
|
676
|
+
return typeof createPortal === 'function' && document.body !== undefined && document.body !== null
|
|
677
|
+
? createPortal(node, document.body)
|
|
678
|
+
: node
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Drag helper shared by the field, hue and alpha controls. */
|
|
682
|
+
function dragOn(node, read) {
|
|
683
|
+
const move = (event) => {
|
|
684
|
+
const box = node.getBoundingClientRect()
|
|
685
|
+
if (box.width === 0 || box.height === 0) return
|
|
686
|
+
read(clamp((event.clientX - box.left) / box.width, 0, 1), clamp((event.clientY - box.top) / box.height, 0, 1))
|
|
687
|
+
}
|
|
688
|
+
const stop = () => {
|
|
689
|
+
window.removeEventListener('pointermove', move)
|
|
690
|
+
window.removeEventListener('pointerup', stop)
|
|
691
|
+
}
|
|
692
|
+
node.addEventListener('pointerdown', (event) => {
|
|
693
|
+
event.preventDefault()
|
|
694
|
+
try {
|
|
695
|
+
node.setPointerCapture(event.pointerId)
|
|
696
|
+
} catch {
|
|
697
|
+
// Capture is a nicety; the window listeners still track the drag.
|
|
698
|
+
}
|
|
699
|
+
move(event)
|
|
700
|
+
window.addEventListener('pointermove', move)
|
|
701
|
+
window.addEventListener('pointerup', stop)
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* A text field that owns its keystrokes and commits on blur or Enter.
|
|
707
|
+
* Committing per keystroke would fight the user mid-entry (a partial hex
|
|
708
|
+
* value parses as a different colour), and re-rendering straight from props
|
|
709
|
+
* would remount the input and drop focus. `parse` returns the text to show,
|
|
710
|
+
* or undefined when the entry is not a value yet.
|
|
711
|
+
*/
|
|
712
|
+
function Field(props) {
|
|
713
|
+
const { label, value, parse } = props
|
|
714
|
+
const [text, setText] = React.useState(value)
|
|
715
|
+
const editing = React.useRef(false)
|
|
716
|
+
React.useEffect(() => {
|
|
717
|
+
if (!editing.current) setText(value)
|
|
718
|
+
}, [value])
|
|
719
|
+
const commit = (raw) => {
|
|
720
|
+
const next = parse(raw)
|
|
721
|
+
setText(next === undefined ? value : next)
|
|
722
|
+
}
|
|
723
|
+
return React.createElement(
|
|
724
|
+
'label',
|
|
725
|
+
{ className: 'dsh-sc-field' },
|
|
726
|
+
React.createElement('span', null, label),
|
|
727
|
+
React.createElement('input', {
|
|
728
|
+
value: text,
|
|
729
|
+
spellCheck: false,
|
|
730
|
+
onFocus: () => { editing.current = true },
|
|
731
|
+
onChange: (event) => setText(event.target.value),
|
|
732
|
+
onBlur: (event) => { editing.current = false; commit(event.target.value) },
|
|
733
|
+
onKeyDown: (event) => {
|
|
734
|
+
if (event.key !== 'Enter') return
|
|
735
|
+
editing.current = false
|
|
736
|
+
commit(event.currentTarget.value)
|
|
737
|
+
event.currentTarget.blur()
|
|
738
|
+
},
|
|
739
|
+
}),
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The system-style picker panel, positioned against its trigger. */
|
|
744
|
+
function ColorPanel(props) {
|
|
745
|
+
const { t, value, onChange, onClose, anchor } = props
|
|
746
|
+
const [draft, setDraft] = React.useState(value)
|
|
747
|
+
const [mine, setMine] = React.useState([])
|
|
748
|
+
const panelRef = React.useRef(null)
|
|
749
|
+
const [position, setPosition] = React.useState(null)
|
|
750
|
+
|
|
751
|
+
const commit = React.useCallback((next) => {
|
|
752
|
+
setDraft(next)
|
|
753
|
+
onChange(next)
|
|
754
|
+
}, [onChange])
|
|
755
|
+
|
|
756
|
+
// Clamp against the panel's MEASURED box, not its nominal width: the
|
|
757
|
+
// border and padding make the rendered box wider than `width`, and a
|
|
758
|
+
// nominal clamp let the panel run off the right edge of a narrow screen.
|
|
759
|
+
// It stays hidden until placed so it never flashes at the wrong corner.
|
|
760
|
+
React.useEffect(() => {
|
|
761
|
+
const node = panelRef.current
|
|
762
|
+
const rect = node === null ? null : node.getBoundingClientRect()
|
|
763
|
+
const margin = 8
|
|
764
|
+
const width = rect === null || rect.width === 0 ? 268 : rect.width
|
|
765
|
+
const height = rect === null || rect.height === 0 ? 500 : rect.height
|
|
766
|
+
const viewportWidth = window.innerWidth
|
|
767
|
+
const viewportHeight = window.innerHeight
|
|
768
|
+
let anchorBox
|
|
769
|
+
try {
|
|
770
|
+
anchorBox = anchor === null || anchor === undefined ? undefined : anchor.getBoundingClientRect()
|
|
771
|
+
} catch {
|
|
772
|
+
anchorBox = undefined
|
|
773
|
+
}
|
|
774
|
+
const left = anchorBox === undefined
|
|
775
|
+
? margin
|
|
776
|
+
: clamp(anchorBox.left, margin, Math.max(margin, viewportWidth - width - margin))
|
|
777
|
+
const top = anchorBox === undefined
|
|
778
|
+
? margin
|
|
779
|
+
: clamp(anchorBox.bottom + 6, margin, Math.max(margin, viewportHeight - height - margin))
|
|
780
|
+
setPosition({ left, top })
|
|
781
|
+
}, [anchor])
|
|
782
|
+
|
|
783
|
+
React.useEffect(() => {
|
|
784
|
+
const onKey = (event) => { if (event.key === 'Escape') onClose() }
|
|
785
|
+
const onDown = (event) => {
|
|
786
|
+
const node = panelRef.current
|
|
787
|
+
if (node !== null && !node.contains(event.target) && !(anchor !== null && anchor !== undefined && anchor.contains(event.target))) onClose()
|
|
788
|
+
}
|
|
789
|
+
document.addEventListener('keydown', onKey)
|
|
790
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
791
|
+
return () => {
|
|
792
|
+
document.removeEventListener('keydown', onKey)
|
|
793
|
+
document.removeEventListener('pointerdown', onDown, true)
|
|
794
|
+
}
|
|
795
|
+
}, [anchor, onClose])
|
|
796
|
+
|
|
797
|
+
const svRef = React.useRef(null)
|
|
798
|
+
const hueRef = React.useRef(null)
|
|
799
|
+
const alphaRef = React.useRef(null)
|
|
800
|
+
// The panel mounts when it opens, so the drag handlers bind once. The
|
|
801
|
+
// latest draft and commit are read through refs: re-binding on every
|
|
802
|
+
// change would stack duplicate pointerdown listeners on the same node.
|
|
803
|
+
const draftRef = React.useRef(draft)
|
|
804
|
+
draftRef.current = draft
|
|
805
|
+
const commitRef = React.useRef(commit)
|
|
806
|
+
commitRef.current = commit
|
|
807
|
+
React.useEffect(() => {
|
|
808
|
+
const bound = [
|
|
809
|
+
[svRef.current, (x, y) => commitRef.current({ ...draftRef.current, s: x, v: 1 - y })],
|
|
810
|
+
[hueRef.current, (x) => commitRef.current({ ...draftRef.current, h: x * 360 })],
|
|
811
|
+
[alphaRef.current, (x) => commitRef.current({ ...draftRef.current, a: x })],
|
|
812
|
+
]
|
|
813
|
+
for (const [node, read] of bound) {
|
|
814
|
+
if (node !== null && node !== undefined) dragOn(node, read)
|
|
815
|
+
}
|
|
816
|
+
}, [])
|
|
817
|
+
|
|
818
|
+
const [r, g, b] = hsvToRgb(draft.h, draft.s, draft.v)
|
|
819
|
+
const hueColor = `hsl(${draft.h} 100% 50%)`
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* A text field that owns its keystrokes and commits on blur or Enter.
|
|
823
|
+
* Committing per keystroke would fight the user mid-entry (a partial hex
|
|
824
|
+
* value parses as a different colour) and would remount the input.
|
|
825
|
+
*/
|
|
826
|
+
const field = (label, key, text, parse) => React.createElement(Field, {
|
|
827
|
+
key,
|
|
828
|
+
label,
|
|
829
|
+
parse,
|
|
830
|
+
value: text,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
return React.createElement(
|
|
834
|
+
'div',
|
|
835
|
+
{
|
|
836
|
+
className: 'dsh-sc-panel',
|
|
837
|
+
ref: panelRef,
|
|
838
|
+
role: 'dialog',
|
|
839
|
+
'aria-label': t('panelTitle'),
|
|
840
|
+
style: position === null
|
|
841
|
+
? { left: '0px', top: '0px', visibility: 'hidden' }
|
|
842
|
+
: { left: `${position.left}px`, top: `${position.top}px` },
|
|
843
|
+
},
|
|
844
|
+
React.createElement('h4', { className: 'dsh-sc-title' }, t('mark')),
|
|
845
|
+
React.createElement(
|
|
846
|
+
'div',
|
|
847
|
+
{ className: 'dsh-sc-sv', ref: svRef, role: 'presentation', 'aria-label': t('saturation'), style: { background: hueColor } },
|
|
848
|
+
React.createElement('div', { className: 'dsh-sc-sv-w' }),
|
|
849
|
+
React.createElement('div', { className: 'dsh-sc-sv-b' }),
|
|
850
|
+
React.createElement('div', { className: 'dsh-sc-ring', style: { left: `${draft.s * 100}%`, top: `${(1 - draft.v) * 100}%` } }),
|
|
851
|
+
),
|
|
852
|
+
React.createElement(
|
|
853
|
+
'div',
|
|
854
|
+
{ className: 'dsh-sc-row' },
|
|
855
|
+
React.createElement(
|
|
856
|
+
'button',
|
|
857
|
+
{
|
|
858
|
+
type: 'button',
|
|
859
|
+
className: 'dsh-sc-drop',
|
|
860
|
+
title: t('eyedrop'),
|
|
861
|
+
'aria-label': t('eyedrop'),
|
|
862
|
+
disabled: typeof window.EyeDropper !== 'function',
|
|
863
|
+
onClick: async () => {
|
|
864
|
+
try {
|
|
865
|
+
const picked = await new window.EyeDropper().open()
|
|
866
|
+
const next = parseHex(picked.sRGBHex)
|
|
867
|
+
if (next !== undefined) commit({ ...next, a: 1 })
|
|
868
|
+
} catch {
|
|
869
|
+
// The user dismissed the system picker.
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
React.createElement('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8 },
|
|
874
|
+
React.createElement('path', { d: 'M3 21l6.5-6.5' }),
|
|
875
|
+
React.createElement('path', { d: 'M14.5 4.5l5 5' }),
|
|
876
|
+
React.createElement('path', { d: 'M12 7l5 5-7.5 7.5H4.5v-5L12 7z' })),
|
|
877
|
+
),
|
|
878
|
+
React.createElement(
|
|
879
|
+
'div',
|
|
880
|
+
{ className: 'dsh-sc-sliders' },
|
|
881
|
+
React.createElement('div', { className: 'dsh-sc-hue', ref: hueRef, role: 'presentation', 'aria-label': t('hue') },
|
|
882
|
+
React.createElement('div', { className: 'dsh-sc-knob', style: { left: `${(draft.h / 360) * 100}%` } })),
|
|
883
|
+
React.createElement('div', { className: 'dsh-sc-alpha', ref: alphaRef, role: 'presentation', 'aria-label': t('alpha') },
|
|
884
|
+
React.createElement('div', { className: 'dsh-sc-alpha-fill', style: { background: `linear-gradient(to right, transparent, ${hueColor})` } }),
|
|
885
|
+
React.createElement('div', { className: 'dsh-sc-knob', style: { left: `${draft.a * 100}%` } })),
|
|
886
|
+
),
|
|
887
|
+
),
|
|
888
|
+
React.createElement(
|
|
889
|
+
'div',
|
|
890
|
+
{ className: 'dsh-sc-fields' },
|
|
891
|
+
field(t('hex'), 'hex', toHex(draft), (raw) => {
|
|
892
|
+
const next = parseHex(raw)
|
|
893
|
+
if (next === undefined) return undefined
|
|
894
|
+
commit({ ...next, a: draft.a })
|
|
895
|
+
return toHex(next)
|
|
896
|
+
}),
|
|
897
|
+
field('R', 'r', String(r), (raw) => {
|
|
898
|
+
const next = clamp(Math.round(Number(raw) || 0), 0, 255)
|
|
899
|
+
commit({ ...rgbToHsv(next, g, b), a: draft.a })
|
|
900
|
+
return String(next)
|
|
901
|
+
}),
|
|
902
|
+
field('G', 'g', String(g), (raw) => {
|
|
903
|
+
const next = clamp(Math.round(Number(raw) || 0), 0, 255)
|
|
904
|
+
commit({ ...rgbToHsv(r, next, b), a: draft.a })
|
|
905
|
+
return String(next)
|
|
906
|
+
}),
|
|
907
|
+
field('B', 'b', String(b), (raw) => {
|
|
908
|
+
const next = clamp(Math.round(Number(raw) || 0), 0, 255)
|
|
909
|
+
commit({ ...rgbToHsv(r, g, next), a: draft.a })
|
|
910
|
+
return String(next)
|
|
911
|
+
}),
|
|
912
|
+
field('A', 'a', String(Math.round(draft.a * 100)), (raw) => {
|
|
913
|
+
const next = clamp(Math.round(Number(raw) || 0), 0, 100)
|
|
914
|
+
commit({ ...draft, a: next / 100 })
|
|
915
|
+
return String(next)
|
|
916
|
+
}),
|
|
917
|
+
),
|
|
918
|
+
React.createElement(
|
|
919
|
+
'div',
|
|
920
|
+
{ className: 'dsh-sc-section' },
|
|
921
|
+
React.createElement('h4', null, t('theme')),
|
|
922
|
+
React.createElement('div', { className: 'dsh-sc-swatches' },
|
|
923
|
+
THEME.map(([hex, alpha], index) => React.createElement(
|
|
924
|
+
'button',
|
|
925
|
+
{
|
|
926
|
+
key: `${hex}-${index}`,
|
|
927
|
+
type: 'button',
|
|
928
|
+
className: 'dsh-sc-swatch',
|
|
929
|
+
'aria-label': index === THEME.length - 1 ? t('clear') : t('theme'),
|
|
930
|
+
onClick: () => commit({ ...parseHex(hex), a: alpha }),
|
|
931
|
+
},
|
|
932
|
+
React.createElement('i', { style: { background: cssOf({ ...parseHex(hex), a: alpha }) } }),
|
|
933
|
+
))),
|
|
934
|
+
),
|
|
935
|
+
React.createElement(
|
|
936
|
+
'div',
|
|
937
|
+
{ className: 'dsh-sc-section' },
|
|
938
|
+
React.createElement('h4', null, t('mine')),
|
|
939
|
+
React.createElement('div', { className: 'dsh-sc-swatches' },
|
|
940
|
+
mine.map((color, index) => React.createElement(
|
|
941
|
+
'button',
|
|
942
|
+
{ key: `mine-${index}`, type: 'button', className: 'dsh-sc-swatch', 'aria-label': t('custom'), onClick: () => commit(color) },
|
|
943
|
+
React.createElement('i', { style: { background: cssOf(color) } }),
|
|
944
|
+
)),
|
|
945
|
+
React.createElement('button', {
|
|
946
|
+
type: 'button',
|
|
947
|
+
className: 'dsh-sc-add',
|
|
948
|
+
'aria-label': t('addMine'),
|
|
949
|
+
onClick: () => setMine((current) => (current.some((item) => toHex(item) === toHex(draft) && item.a === draft.a) ? current : [...current, draft])),
|
|
950
|
+
}, '+')),
|
|
951
|
+
),
|
|
952
|
+
React.createElement(
|
|
953
|
+
'div',
|
|
954
|
+
{ className: 'dsh-sc-actions' },
|
|
955
|
+
React.createElement('button', {
|
|
956
|
+
type: 'button',
|
|
957
|
+
className: 'dsh-sc-btn',
|
|
958
|
+
onClick: () => { onChange(undefined); onClose() },
|
|
959
|
+
}, t('clear')),
|
|
960
|
+
),
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Header action: a swatch-plus-label button that opens the picker. */
|
|
965
|
+
function HeaderAction(props) {
|
|
966
|
+
const { store, t, args } = props
|
|
967
|
+
const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
968
|
+
const [open, setOpen] = React.useState(false)
|
|
969
|
+
const buttonRef = React.useRef(null)
|
|
970
|
+
// The slot is expected to pass the Session it sits in; accept the shapes a
|
|
971
|
+
// header action can plausibly receive so a rename upstream only costs the
|
|
972
|
+
// action instead of silently pinning the wrong Session.
|
|
973
|
+
const sessionId = args === undefined
|
|
974
|
+
? (props.sessionId === undefined ? (props.session === undefined ? undefined : props.session.id) : props.sessionId)
|
|
975
|
+
: args.sessionId
|
|
976
|
+
if (sessionId === undefined) return null
|
|
977
|
+
|
|
978
|
+
const color = snapshot.colors[sessionId]
|
|
979
|
+
const start = color === undefined ? { h: 358, s: 0.68, v: 0.9, a: 1 } : color
|
|
980
|
+
// Marks come from the Host route. When it is not answering, say so on the
|
|
981
|
+
// control instead of looking like a Session that simply has no colour.
|
|
982
|
+
const offline = snapshot.ready !== true
|
|
983
|
+
|
|
984
|
+
return React.createElement(
|
|
985
|
+
React.Fragment,
|
|
986
|
+
null,
|
|
987
|
+
React.createElement(
|
|
988
|
+
'button',
|
|
989
|
+
{
|
|
990
|
+
type: 'button',
|
|
991
|
+
ref: buttonRef,
|
|
992
|
+
className: 'dsh-sc-action',
|
|
993
|
+
'aria-haspopup': 'dialog',
|
|
994
|
+
'aria-expanded': open,
|
|
995
|
+
// No visible label: the control is a colour swatch, and its meaning
|
|
996
|
+
// is carried by the tooltip, the accessible name and the panel
|
|
997
|
+
// heading. A text label would also have to be legible on both the
|
|
998
|
+
// light and the dark header, which a swatch does not.
|
|
999
|
+
'aria-label': color === undefined ? t('mark') : `${t('mark')}:${toHex(color)}`,
|
|
1000
|
+
title: `${offline ? t('offline') : (color === undefined ? t('hint') : `${t('mark')}:${toHex(color)}`)} · ${BUILD}`,
|
|
1001
|
+
onClick: () => setOpen((current) => !current),
|
|
1002
|
+
},
|
|
1003
|
+
offline
|
|
1004
|
+
? React.createElement('span', { 'aria-hidden': 'true' }, '⚠')
|
|
1005
|
+
: color === undefined
|
|
1006
|
+
? React.createElement('span', { 'aria-hidden': 'true' }, '🎨')
|
|
1007
|
+
: React.createElement('span', { className: 'dsh-sc-dot', 'aria-hidden': 'true', style: { background: cssOf(color) } }),
|
|
1008
|
+
),
|
|
1009
|
+
open
|
|
1010
|
+
? React.createElement(ColorPanel, {
|
|
1011
|
+
t,
|
|
1012
|
+
anchor: buttonRef.current,
|
|
1013
|
+
value: start,
|
|
1014
|
+
onChange: (next) => {
|
|
1015
|
+
if (next === undefined) store.clear(sessionId)
|
|
1016
|
+
else store.set(sessionId, next)
|
|
1017
|
+
},
|
|
1018
|
+
onClose: () => setOpen(false),
|
|
1019
|
+
})
|
|
1020
|
+
: null,
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
|
|
1025
|
+
/** Required services; both target slots are reached through `slots.inject`. */
|
|
1026
|
+
exports.inject = ['slots', 'locale', 'layout']
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Register the two surfaces once their slot declarations exist. Each
|
|
1030
|
+
* registration is isolated: a surface the running build does not declare
|
|
1031
|
+
* stays absent instead of failing the whole plugin.
|
|
1032
|
+
* @param ctx - client root context.
|
|
1033
|
+
*/
|
|
1034
|
+
exports.apply = function apply(ctx) {
|
|
1035
|
+
const t = ctx.locale.bind(NS)
|
|
1036
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-session-colors: dictionaries')
|
|
1037
|
+
ctx.effect(installStyle, 'dsh-session-colors: stylesheet')
|
|
1038
|
+
|
|
1039
|
+
// Marks live in this plugin's own Host route. `settingsScope` cannot be
|
|
1040
|
+
// used here: DSH host-backs user settings only for loopback pages, so a
|
|
1041
|
+
// LAN or tunnel page would get a permanently unavailable scope.
|
|
1042
|
+
const store = createStore()
|
|
1043
|
+
ctx.effect(() => () => { store.dispose() }, 'dsh-session-colors: preference store')
|
|
1044
|
+
|
|
1045
|
+
// A device that shows nothing should be able to say why.
|
|
1046
|
+
try {
|
|
1047
|
+
const report = store.describe()
|
|
1048
|
+
console.info(`[dsh-session-colors] build=${BUILD} marks route status=${report.status} writable=${report.writable} marks=${report.marks}${report.error === '' ? '' : ` error=${report.error}`}`)
|
|
1049
|
+
} catch {
|
|
1050
|
+
// Diagnostics must never break the plugin.
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const seats = {}
|
|
1054
|
+
const seat = (name, register) => {
|
|
1055
|
+
try {
|
|
1056
|
+
ctx.slots.inject(name, register)
|
|
1057
|
+
seats[name] = 'active'
|
|
1058
|
+
} catch {
|
|
1059
|
+
seats[name] = 'unavailable'
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Registered as a right-aligned header utility, not as a title-adjacent
|
|
1064
|
+
// action. The title cluster is `flex: 1 1 0%` but cannot shrink below its
|
|
1065
|
+
// content, so an extra action there overflows straight into the utilities
|
|
1066
|
+
// cluster — on a narrow header that put the colour swatch on top of the
|
|
1067
|
+
// model picker. Utilities are right-aligned, so the flexible title
|
|
1068
|
+
// absorbs the width instead of two controls colliding.
|
|
1069
|
+
seat('conversation.session.header.utilities', () => ctx.slots.register(
|
|
1070
|
+
{ name: 'conversation.session.header.utilities', id: ACTION_ID, order: 40, locale: NS },
|
|
1071
|
+
(props) => React.createElement(HeaderAction, {
|
|
1072
|
+
...props,
|
|
1073
|
+
store,
|
|
1074
|
+
t,
|
|
1075
|
+
args: { sessionId: props.sessionId },
|
|
1076
|
+
}),
|
|
1077
|
+
))
|
|
1078
|
+
seat('shell.overlay', () => ctx.slots.register(
|
|
1079
|
+
{ name: 'shell.overlay', id: OVERLAY_ID, order: 100, label: () => t('layer'), locale: NS },
|
|
1080
|
+
(props) => React.createElement(ChipsLayer, { ...props, store, t }),
|
|
1081
|
+
))
|
|
1082
|
+
exports.seats = seats
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
exports.NS = NS
|
|
1086
|
+
exports.ACTION_ID = ACTION_ID
|
|
1087
|
+
exports.OVERLAY_ID = OVERLAY_ID
|
|
1088
|
+
exports.MARKS_ROUTE = MARKS_ROUTE
|
|
1089
|
+
exports.PEER_POLL_MS = PEER_POLL_MS
|
|
1090
|
+
exports.BUILD = BUILD
|
|
1091
|
+
exports.CHIP_WIDTH = CHIP_WIDTH
|
|
1092
|
+
exports.CHIP_HEIGHT = CHIP_HEIGHT
|
|
1093
|
+
exports.CHIP_OFFSET = CHIP_OFFSET
|
|
1094
|
+
exports.SETTLE_FRAMES = SETTLE_FRAMES
|
|
1095
|
+
// Exported for the unit tests: the store, its key scheme and the row lookup
|
|
1096
|
+
// are the parts worth pinning down without a browser.
|
|
1097
|
+
exports.createStore = createStore
|
|
1098
|
+
exports.readMarks = readMarks
|
|
1099
|
+
exports.isColor = isColor
|
|
1100
|
+
exports.sessionIdOf = sessionIdOf
|
|
1101
|
+
exports.layerZIndexOf = layerZIndexOf
|
|
1102
|
+
exports.hsvToRgb = hsvToRgb
|
|
1103
|
+
exports.rgbToHsv = rgbToHsv
|
|
1104
|
+
exports.toHex = toHex
|
|
1105
|
+
exports.parseHex = parseHex
|
|
1106
|
+
exports.cssOf = cssOf
|
|
1107
|
+
return module.exports
|
|
1108
|
+
},
|
|
1109
|
+
})
|