@panphora/hyper-cms 0.10.0 → 0.11.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/README.md +33 -0
- package/dist/hypercms.min.js +442 -36
- package/package.json +7 -4
- package/packed-contract.json +1 -0
- package/src/anchor.js +24 -0
- package/src/apply-loop.js +73 -46
- package/src/enhance.js +79 -6
- package/src/events.js +92 -6
- package/src/hooks.js +100 -0
- package/src/hypercms-clayjs.js +10 -0
- package/src/hypercms.js +109 -386
- package/src/inline-icons.js +15 -0
- package/src/lib/content-dom.js +110 -0
- package/src/lib/region-capabilities.js +71 -0
- package/src/morph.js +1 -0
- package/src/place.js +131 -0
- package/src/platform.js +12 -0
- package/src/refresh.js +23 -13
- package/src/richclay-bridge.js +140 -0
- package/src/row-identity.js +4 -6
- package/src/session.js +303 -0
- package/src/shell.js +27 -6
- package/src/targets.js +149 -0
- package/src/theme/pixel-quiet.overrides.css +303 -9
- package/src/theme.generated.css +303 -9
- package/src/toggle.js +627 -34
- package/src/unresolved.js +87 -18
- package/src/views/inline-ghosts.js +139 -0
- package/src/views/inline-layer.js +663 -0
- package/src/views/inline.js +1092 -0
- package/src/views/sidebar.js +97 -0
package/src/toggle.js
CHANGED
|
@@ -7,24 +7,178 @@
|
|
|
7
7
|
// snapshot (a static no-save element written into the file would be stripped
|
|
8
8
|
// from disk on the first save), save-ignore keeps it out of autosave/undo,
|
|
9
9
|
// mirroring the shell's own attributes.
|
|
10
|
+
//
|
|
11
|
+
// The control is a <hypercms-toggle> host wrapping a real <button>. The host is
|
|
12
|
+
// an unregistered custom element on purpose: a host page's `button { ... }`
|
|
13
|
+
// reset cannot reach it, and that reset is the common way an injected control
|
|
14
|
+
// gets mangled. Only the structural facts that must survive a hostile
|
|
15
|
+
// `* { ... !important }` reset are pinned inline with !important; each reads a
|
|
16
|
+
// public custom property, so overriding still works. Everything cosmetic is a
|
|
17
|
+
// normal declaration selected by [data-hcms-toggle-host], which an author's own
|
|
18
|
+
// #hcms-toggle rule outranks on specificity.
|
|
19
|
+
//
|
|
20
|
+
// With two views to choose between it is a split button: the main button plus an
|
|
21
|
+
// arrow that opens a menu. The arrow's background and the menu's whole geometry
|
|
22
|
+
// are pinned for exactly the reason the host's position is — an unpinned arrow
|
|
23
|
+
// goes transparent under a page's `button { background: none !important }`, and
|
|
24
|
+
// an unpinned menu drops into the flow and shoves the page. The menu's `display`
|
|
25
|
+
// is pinned too: [hidden] is a UA rule with no specificity, so any page rule
|
|
26
|
+
// matching the element beats it and the menu would sit open forever.
|
|
27
|
+
|
|
28
|
+
import { reensureStyles } from './shell.js'
|
|
10
29
|
|
|
11
30
|
const TOGGLE_ID = 'hcms-toggle'
|
|
31
|
+
const HOST_ATTR = 'data-hcms-toggle-host'
|
|
12
32
|
const STYLE_ID = 'hcms-toggle-style'
|
|
33
|
+
const STYLE_ATTR = 'data-hcms-toggle-style'
|
|
34
|
+
// "A session of either kind is up", written by the open/close events. NOT
|
|
35
|
+
// body.hcms-open: that class is the sidebar's page shift and an inline session
|
|
36
|
+
// sets nothing on <body>, so a body-class label reads "Edit content" over an
|
|
37
|
+
// open inline editor.
|
|
38
|
+
const SESSION_ATTR = 'data-hcms-session'
|
|
39
|
+
const SPLIT_ATTR = 'data-hcms-split'
|
|
40
|
+
|
|
41
|
+
// The preference, one setting across the origin. No version suffix: there are
|
|
42
|
+
// exactly two valid values and the membership check on read is what makes any
|
|
43
|
+
// other value — a stale name, a hand-edited entry, a future view this build does
|
|
44
|
+
// not have — read as first run rather than as a broken session.
|
|
45
|
+
export const VIEW_STORAGE_KEY = 'hcms.view'
|
|
46
|
+
const VIEW_NAMES = ['sidebar', 'inline']
|
|
47
|
+
const VIEW_LABELS = { sidebar: 'In the sidebar', inline: 'On the page' }
|
|
48
|
+
|
|
49
|
+
const SURFACE_VAR = 'var(--hcms-toggle-bg, var(--hcms-toggle-_surface))'
|
|
50
|
+
|
|
51
|
+
export function readStoredView(win) {
|
|
52
|
+
try {
|
|
53
|
+
const value = win && win.localStorage ? win.localStorage.getItem(VIEW_STORAGE_KEY) : null
|
|
54
|
+
return VIEW_NAMES.includes(value) ? value : null
|
|
55
|
+
} catch (_) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Written when someone picks a view, and only once that view is actually up: a
|
|
61
|
+
// mount that throws must not teach the next page load to try the same broken
|
|
62
|
+
// view. localStorage throws outright in a few configurations (Safari private
|
|
63
|
+
// mode, cookies disabled), and a remembered view is a convenience, not a
|
|
64
|
+
// requirement.
|
|
65
|
+
export function writeStoredView(win, view) {
|
|
66
|
+
if (!VIEW_NAMES.includes(view)) return
|
|
67
|
+
try {
|
|
68
|
+
win?.localStorage?.setItem(VIEW_STORAGE_KEY, view)
|
|
69
|
+
} catch (_) {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The pill's surface is opaque and neutral, picked at inject time from the two
|
|
73
|
+
// values below by whichever has more contrast against the page's own ink. It is
|
|
74
|
+
// not a tint of currentColor: the button floats over a backdrop it cannot see,
|
|
75
|
+
// and a 10% tint over a dark hero measures 1.3:1. Opaque removes the backdrop
|
|
76
|
+
// from the question entirely. Measured floor for this pair is 4.36:1 across
|
|
77
|
+
// 1516 inks; a 4.5:1 floor needs #030303, which is black in all but name.
|
|
78
|
+
const SURFACE_LIGHT = '#fafafa'
|
|
79
|
+
const SURFACE_DARK = '#0a0a0a'
|
|
13
80
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
font:
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
81
|
+
export const TOGGLE_STYLE = `
|
|
82
|
+
[${HOST_ATTR}] {
|
|
83
|
+
all: unset;
|
|
84
|
+
box-sizing: border-box;
|
|
85
|
+
display: var(--hcms-toggle-display, inline-flex);
|
|
86
|
+
font-family: 'Departure Mono', ui-monospace, Menlo, monospace;
|
|
87
|
+
--hcms-toggle-_surface: ${SURFACE_LIGHT};
|
|
88
|
+
}
|
|
89
|
+
[${HOST_ATTR}][data-hcms-surface="dark"] {
|
|
90
|
+
--hcms-toggle-_surface: ${SURFACE_DARK};
|
|
91
|
+
}
|
|
92
|
+
[${HOST_ATTR}] .hcms-toggle__main,
|
|
93
|
+
[${HOST_ATTR}] .hcms-toggle__arrow {
|
|
94
|
+
all: unset;
|
|
95
|
+
box-sizing: border-box;
|
|
96
|
+
display: inline-flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 7px;
|
|
99
|
+
padding: 0 14px;
|
|
100
|
+
min-height: 40px;
|
|
101
|
+
font-family: inherit;
|
|
102
|
+
font-weight: 400;
|
|
103
|
+
line-height: 1.5;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
color: var(--hcms-toggle-color, currentColor);
|
|
106
|
+
border: 2px solid;
|
|
107
|
+
border-color: var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8);
|
|
108
|
+
border-radius: 0;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
box-shadow: none;
|
|
111
|
+
}
|
|
112
|
+
[${HOST_ATTR}] .hcms-toggle__main:hover,
|
|
113
|
+
[${HOST_ATTR}] .hcms-toggle__arrow:hover {
|
|
114
|
+
border-color: var(--mirk-focus-color, #C7AE93);
|
|
115
|
+
box-shadow: none;
|
|
116
|
+
}
|
|
117
|
+
[${HOST_ATTR}] .hcms-toggle__main:active,
|
|
118
|
+
[${HOST_ATTR}] .hcms-toggle__arrow:active {
|
|
119
|
+
border-color: var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF);
|
|
120
|
+
}
|
|
121
|
+
[${HOST_ATTR}] .hcms-toggle__main:focus-visible,
|
|
122
|
+
[${HOST_ATTR}] .hcms-toggle__arrow:focus-visible {
|
|
123
|
+
outline: 2px solid currentColor;
|
|
124
|
+
outline-offset: -5px;
|
|
125
|
+
}
|
|
126
|
+
[${HOST_ATTR}] .hcms-toggle__close { display: none; }
|
|
127
|
+
[${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__open { display: none; }
|
|
128
|
+
[${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__close { display: inline; }
|
|
129
|
+
[${HOST_ATTR}][${SPLIT_ATTR}] .hcms-toggle__main {
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
padding-right: 12px;
|
|
132
|
+
}
|
|
133
|
+
[${HOST_ATTR}] .hcms-toggle__arrow {
|
|
134
|
+
gap: 0;
|
|
135
|
+
padding: 0 10px;
|
|
136
|
+
border-radius: 0;
|
|
137
|
+
border-left-width: 0;
|
|
138
|
+
}
|
|
139
|
+
[${HOST_ATTR}] .hcms-toggle__menu {
|
|
140
|
+
all: unset;
|
|
141
|
+
box-sizing: border-box;
|
|
142
|
+
min-width: 180px;
|
|
143
|
+
padding: 4px;
|
|
144
|
+
font-family: inherit;
|
|
145
|
+
font-size: 14px;
|
|
146
|
+
line-height: 1.5;
|
|
147
|
+
color: var(--hcms-toggle-color, currentColor);
|
|
148
|
+
background: ${SURFACE_VAR};
|
|
149
|
+
border: 1px solid var(--mirk-input-border, #D8C8AF);
|
|
150
|
+
border-radius: var(--mirk-radius, 5px);
|
|
151
|
+
box-shadow: 0 14px 34px -14px rgba(0, 0, 0, .45);
|
|
152
|
+
}
|
|
153
|
+
[${HOST_ATTR}] .hcms-toggle__item {
|
|
154
|
+
all: unset;
|
|
155
|
+
box-sizing: border-box;
|
|
156
|
+
display: block;
|
|
157
|
+
width: 100%;
|
|
158
|
+
padding: 9px 10px;
|
|
159
|
+
border-radius: 0;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
[${HOST_ATTR}] .hcms-toggle__item::before {
|
|
163
|
+
content: "○";
|
|
164
|
+
margin-right: 8px;
|
|
165
|
+
opacity: .55;
|
|
166
|
+
}
|
|
167
|
+
[${HOST_ATTR}] .hcms-toggle__item[aria-checked="true"]::before {
|
|
168
|
+
content: "●";
|
|
169
|
+
opacity: 1;
|
|
170
|
+
}
|
|
171
|
+
[${HOST_ATTR}] .hcms-toggle__item:hover {
|
|
172
|
+
background: color-mix(in srgb, currentColor 12%, transparent);
|
|
173
|
+
}
|
|
174
|
+
[${HOST_ATTR}] .hcms-toggle__item:focus-visible {
|
|
175
|
+
outline: 2px solid currentColor;
|
|
176
|
+
outline-offset: -3px;
|
|
177
|
+
}
|
|
178
|
+
@media (pointer: coarse) {
|
|
179
|
+
[${HOST_ATTR}] .hcms-toggle__main,
|
|
180
|
+
[${HOST_ATTR}] .hcms-toggle__arrow { min-height: 44px; }
|
|
181
|
+
}
|
|
28
182
|
`
|
|
29
183
|
|
|
30
184
|
// Mirrors hyperclayjs core/isAdminOfCurrentResource.js: an explicit
|
|
@@ -42,34 +196,473 @@ export function detectEditMode({ search = '', cookie = '', forced = null } = {})
|
|
|
42
196
|
return /(?:^|;\s*)isAdminOfCurrentResource=[^;]/.test(cookie)
|
|
43
197
|
}
|
|
44
198
|
|
|
45
|
-
|
|
46
|
-
|
|
199
|
+
// A computed `color` is not always an rgb() string: CSS Color 4 keeps the
|
|
200
|
+
// authored space, so Chrome returns oklch(), lab() and color() as written, and
|
|
201
|
+
// Tailwind v4's whole palette is oklch. rgb()/rgba() is still what the great
|
|
202
|
+
// majority of pages compute to, so parse that directly and let the canvas
|
|
203
|
+
// handle only the rest.
|
|
204
|
+
const colorContexts = new WeakMap()
|
|
205
|
+
|
|
206
|
+
function readCanvasColor(ctx, value) {
|
|
207
|
+
try {
|
|
208
|
+
// 'copy' replaces the pixel instead of blending, so alpha survives.
|
|
209
|
+
ctx.globalCompositeOperation = 'copy'
|
|
210
|
+
ctx.fillStyle = value
|
|
211
|
+
ctx.fillRect(0, 0, 1, 1)
|
|
212
|
+
const d = ctx.getImageData(0, 0, 1, 1).data
|
|
213
|
+
return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }
|
|
214
|
+
} catch (_) {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Per document, not per module: the context retains its canvas, which retains
|
|
220
|
+
// its ownerDocument, and hypercms can be driven into a preview iframe.
|
|
221
|
+
function colorContextFor(doc) {
|
|
222
|
+
if (colorContexts.has(doc)) return colorContexts.get(doc)
|
|
223
|
+
let ctx = null
|
|
224
|
+
try {
|
|
225
|
+
const canvas = doc.createElement('canvas')
|
|
226
|
+
canvas.width = canvas.height = 1
|
|
227
|
+
ctx = canvas.getContext ? canvas.getContext('2d', { willReadFrequently: true }) : null
|
|
228
|
+
// A browser that fuzzes canvas readback (Firefox's resistFingerprinting)
|
|
229
|
+
// answers the same colour for everything, which would send every page to
|
|
230
|
+
// one surface at about 1:1. Round-trip a known value once and refuse the
|
|
231
|
+
// whole path if it does not come back exactly.
|
|
232
|
+
if (ctx) {
|
|
233
|
+
const probe = readCanvasColor(ctx, 'rgb(1, 2, 3)')
|
|
234
|
+
if (!probe || probe.r !== 1 || probe.g !== 2 || probe.b !== 3 || probe.a !== 1) ctx = null
|
|
235
|
+
}
|
|
236
|
+
} catch (_) {
|
|
237
|
+
ctx = null
|
|
238
|
+
}
|
|
239
|
+
colorContexts.set(doc, ctx)
|
|
240
|
+
return ctx
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeColor(value, doc) {
|
|
244
|
+
const raw = String(value == null ? '' : value).trim()
|
|
245
|
+
if (!raw) return null
|
|
246
|
+
const direct = parseRgb(raw)
|
|
247
|
+
if (direct) return direct
|
|
248
|
+
const win = doc && doc.defaultView
|
|
249
|
+
// CSS.supports is the parse check AND the capability probe: jsdom has neither
|
|
250
|
+
// it nor a 2d context, so this short-circuits without jsdom logging a "not
|
|
251
|
+
// implemented" error for getContext. It matters because a rejected fillStyle
|
|
252
|
+
// silently keeps its previous value, so an unguarded read would report the
|
|
253
|
+
// last ink this page measured.
|
|
254
|
+
if (!win || !win.CSS || typeof win.CSS.supports !== 'function') return null
|
|
255
|
+
if (!win.CSS.supports('color', raw)) return null
|
|
256
|
+
const ctx = colorContextFor(doc)
|
|
257
|
+
return ctx ? readCanvasColor(ctx, raw) : null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function parseRgb(value) {
|
|
261
|
+
const match = /rgba?\(([^)]+)\)/.exec(String(value == null ? '' : value))
|
|
262
|
+
if (!match) return null
|
|
263
|
+
const parts = match[1].split(/[\s,/]+/).filter(Boolean)
|
|
264
|
+
if (parts.length < 3) return null
|
|
265
|
+
const channels = parts.slice(0, 3).map(Number)
|
|
266
|
+
if (channels.some(Number.isNaN)) return null
|
|
267
|
+
const rawAlpha = parts[3]
|
|
268
|
+
const alpha = rawAlpha === undefined
|
|
269
|
+
? 1
|
|
270
|
+
: (rawAlpha.endsWith('%') ? Number(rawAlpha.slice(0, -1)) / 100 : Number(rawAlpha))
|
|
271
|
+
return {
|
|
272
|
+
r: channels[0], g: channels[1], b: channels[2],
|
|
273
|
+
a: Number.isNaN(alpha) ? 1 : alpha,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function relativeLuminance({ r, g, b }) {
|
|
278
|
+
const channel = (v) => {
|
|
279
|
+
const s = v / 255
|
|
280
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
281
|
+
}
|
|
282
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function contrast(a, b) {
|
|
286
|
+
const la = relativeLuminance(a)
|
|
287
|
+
const lb = relativeLuminance(b)
|
|
288
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const DARK_RGB = { r: 10, g: 10, b: 10 }
|
|
292
|
+
const LIGHT_RGB = { r: 250, g: 250, b: 250 }
|
|
293
|
+
|
|
294
|
+
// Source-over composite, because the label is painted ON the surface: a
|
|
295
|
+
// translucent ink's rendered colour depends on which surface it lands on, so
|
|
296
|
+
// each candidate has to be judged against its own composite.
|
|
297
|
+
function over(fg, bg) {
|
|
298
|
+
const a = fg.a == null ? 1 : fg.a
|
|
299
|
+
return {
|
|
300
|
+
r: fg.r * a + bg.r * (1 - a),
|
|
301
|
+
g: fg.g * a + bg.g * (1 - a),
|
|
302
|
+
b: fg.b * a + bg.b * (1 - a),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Which of the two surfaces the page's ink reads better on. Ties and unknown
|
|
307
|
+
// inks go light, which is the quieter result on the overwhelmingly common
|
|
308
|
+
// dark-ink-on-light-ground page. The 4.36:1 floor holds for opaque ink only:
|
|
309
|
+
// nothing can make `color: rgb(0 0 0 / 40%)` readable, and this picks the
|
|
310
|
+
// better of two bad options rather than pretending otherwise.
|
|
311
|
+
export function pickSurface(ink) {
|
|
312
|
+
if (!ink) return 'light'
|
|
313
|
+
const onDark = contrast(over(ink, DARK_RGB), DARK_RGB)
|
|
314
|
+
const onLight = contrast(over(ink, LIGHT_RGB), LIGHT_RGB)
|
|
315
|
+
return onDark > onLight ? 'dark' : 'light'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function pin(el, declarations) {
|
|
319
|
+
for (const [property, value] of Object.entries(declarations)) {
|
|
320
|
+
el.style.setProperty(property, value, 'important')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function applySurface(host) {
|
|
325
|
+
const win = host.ownerDocument && host.ownerDocument.defaultView
|
|
326
|
+
const main = host.querySelector('.hcms-toggle__main')
|
|
327
|
+
if (!win || !main || typeof win.getComputedStyle !== 'function') return
|
|
328
|
+
const ink = normalizeColor(win.getComputedStyle(main).color, host.ownerDocument)
|
|
329
|
+
host.setAttribute('data-hcms-surface', pickSurface(ink))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The ink is read from the live cascade, so it is only final once the page's
|
|
333
|
+
// own stylesheets have applied, and it changes again whenever the page switches
|
|
334
|
+
// theme. A dark-mode toggle that flips <html> or <body> would otherwise leave a
|
|
335
|
+
// near-white label on the near-white surface picked at load. Two elements, no
|
|
336
|
+
// subtree, rAF-collapsed, and it unhooks itself once the host is gone.
|
|
337
|
+
function scheduleSurface(host) {
|
|
338
|
+
applySurface(host)
|
|
339
|
+
const doc = host.ownerDocument
|
|
340
|
+
const win = doc && doc.defaultView
|
|
341
|
+
if (!win) return
|
|
342
|
+
|
|
343
|
+
let frame = 0
|
|
344
|
+
const recompute = () => {
|
|
345
|
+
if (frame) return
|
|
346
|
+
if (typeof win.requestAnimationFrame !== 'function') {
|
|
347
|
+
if (!host.isConnected) return stop()
|
|
348
|
+
return applySurface(host)
|
|
349
|
+
}
|
|
350
|
+
frame = win.requestAnimationFrame(() => {
|
|
351
|
+
frame = 0
|
|
352
|
+
if (!host.isConnected) return stop()
|
|
353
|
+
applySurface(host)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (typeof win.requestAnimationFrame === 'function') recompute()
|
|
358
|
+
if (doc.readyState !== 'complete') win.addEventListener('load', recompute, { once: true })
|
|
359
|
+
|
|
360
|
+
const observer = typeof win.MutationObserver === 'function' ? new win.MutationObserver(recompute) : null
|
|
361
|
+
if (observer) {
|
|
362
|
+
// No attributeFilter. Theme togglers write whatever attribute they like:
|
|
363
|
+
// Bootstrap 5.3 uses data-bs-theme, Primer uses data-color-mode, plenty use
|
|
364
|
+
// a class or an inline style. An allowlist is a guess about other people's
|
|
365
|
+
// markup and it was already wrong twice. Two elements, no subtree, and the
|
|
366
|
+
// callback collapses into one animation frame, so watching every attribute
|
|
367
|
+
// costs nothing measurable.
|
|
368
|
+
observer.observe(doc.documentElement, { attributes: true })
|
|
369
|
+
if (doc.body) observer.observe(doc.body, { attributes: true })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const scheme = typeof win.matchMedia === 'function' ? win.matchMedia('(prefers-color-scheme: dark)') : null
|
|
373
|
+
if (scheme && typeof scheme.addEventListener === 'function') scheme.addEventListener('change', recompute)
|
|
374
|
+
|
|
375
|
+
// Self-disconnecting: any attribute write on <html> or <body> schedules a
|
|
376
|
+
// recompute, which stops the watcher once the host has left the document.
|
|
377
|
+
// Round 2 added an explicit sweep at injection time as well; it was dead
|
|
378
|
+
// code, because injectToggle runs once per page. The residual is a single
|
|
379
|
+
// observer surviving on a page that removes our host and then never touches
|
|
380
|
+
// an attribute on <html> or <body> again, which is clutter on a page that
|
|
381
|
+
// has already thrown the control away.
|
|
382
|
+
function stop() {
|
|
383
|
+
if (observer) observer.disconnect()
|
|
384
|
+
if (scheme && typeof scheme.removeEventListener === 'function') scheme.removeEventListener('change', recompute)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function injectToggle({ open, close, isOpen, getTheme = () => null, views = VIEW_NAMES }, doc = document) {
|
|
389
|
+
const existing = doc.querySelector(`[${HOST_ATTR}]`)
|
|
47
390
|
if (existing) return existing
|
|
48
|
-
|
|
391
|
+
|
|
392
|
+
if (!doc.querySelector(`[${STYLE_ATTR}]`)) {
|
|
49
393
|
const style = doc.createElement('style')
|
|
50
|
-
style.
|
|
394
|
+
style.setAttribute(STYLE_ATTR, '')
|
|
395
|
+
style.setAttribute('editor-ui', '')
|
|
396
|
+
if (!doc.getElementById(STYLE_ID)) style.id = STYLE_ID
|
|
397
|
+
style.setAttribute('no-save', '')
|
|
51
398
|
style.setAttribute('snapshot-remove', '')
|
|
52
|
-
style.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
399
|
+
style.setAttribute('save-ignore', '')
|
|
400
|
+
style.textContent = TOGGLE_STYLE
|
|
401
|
+
// Prepended, not appended: an author who overrides with a selector no
|
|
402
|
+
// stronger than ours then wins the tie on document order.
|
|
403
|
+
doc.head.insertBefore(style, doc.head.firstChild)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const host = doc.createElement('hypercms-toggle')
|
|
407
|
+
host.className = 'hcms-shell pixel-quiet'
|
|
408
|
+
// The id is the documented override handle, but it is the page's namespace:
|
|
409
|
+
// if the page already owns it, go without rather than shadow their element.
|
|
410
|
+
if (!doc.getElementById(TOGGLE_ID)) host.id = TOGGLE_ID
|
|
411
|
+
host.setAttribute(HOST_ATTR, '')
|
|
412
|
+
host.setAttribute('editor-ui', '')
|
|
413
|
+
host.setAttribute('no-save', '')
|
|
414
|
+
host.setAttribute('snapshot-remove', '')
|
|
415
|
+
host.setAttribute('save-ignore', '')
|
|
416
|
+
host.innerHTML =
|
|
417
|
+
'<button type="button" class="hcms-toggle__main mirk-button">' +
|
|
418
|
+
'<span class="hcms-toggle__open">Edit content</span>' +
|
|
419
|
+
'<span class="hcms-toggle__close">Close editor</span>' +
|
|
420
|
+
'</button>'
|
|
421
|
+
|
|
422
|
+
const win = doc.defaultView
|
|
423
|
+
const main = host.querySelector('.hcms-toggle__main')
|
|
424
|
+
// Fixed order, so the menu does not reorder itself from page to page, and
|
|
425
|
+
// filtered against the views this build actually has.
|
|
426
|
+
const available = VIEW_NAMES.filter((name) => views.includes(name))
|
|
427
|
+
// An arrow offering a choice of one is a menu that can only repeat what the
|
|
428
|
+
// button already does, so with a single view there is no arrow at all.
|
|
429
|
+
const split = available.length > 1
|
|
430
|
+
const arrow = split ? makeArrow(doc) : null
|
|
431
|
+
const menu = split ? makeMenu(doc, available) : null
|
|
432
|
+
if (split) {
|
|
433
|
+
host.setAttribute(SPLIT_ATTR, '')
|
|
434
|
+
host.appendChild(arrow)
|
|
435
|
+
host.appendChild(menu)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The only declarations a page's `* { ... !important }` reset can take from
|
|
439
|
+
// us and leave the control broken rather than merely ugly. Each reads its
|
|
440
|
+
// public custom property first, so an author override still lands.
|
|
441
|
+
pin(host, {
|
|
442
|
+
position: 'fixed',
|
|
443
|
+
right: 'calc(var(--hcms-toggle-offset, 16px) + var(--hcms-toggle-shift, 0px))',
|
|
444
|
+
bottom: 'calc(var(--hcms-toggle-offset, 16px) + env(safe-area-inset-bottom, 0px))',
|
|
445
|
+
'z-index': 'var(--hcms-toggle-z, 2147482900)',
|
|
446
|
+
})
|
|
447
|
+
pin(main, { background: SURFACE_VAR })
|
|
448
|
+
if (arrow) pin(arrow, { background: SURFACE_VAR })
|
|
449
|
+
if (menu) {
|
|
450
|
+
pin(menu, {
|
|
451
|
+
position: 'absolute',
|
|
452
|
+
right: '0',
|
|
453
|
+
bottom: 'calc(100% + 8px)',
|
|
454
|
+
'z-index': 'var(--hcms-toggle-z, 2147482900)',
|
|
455
|
+
display: 'none',
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const items = () => (menu ? [...menu.querySelectorAll('[role="menuitemradio"]')] : [])
|
|
460
|
+
|
|
461
|
+
function focusEl(el) {
|
|
462
|
+
if (!el || typeof el.focus !== 'function') return
|
|
463
|
+
try { el.focus({ preventScroll: true }) } catch (_) { el.focus() }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function focusItem(index) {
|
|
467
|
+
const list = items()
|
|
468
|
+
if (!list.length) return
|
|
469
|
+
focusEl(list[(index + list.length) % list.length])
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Read fresh every time the menu opens: the selection made two clicks ago is
|
|
473
|
+
// the one the radio should be sitting on.
|
|
474
|
+
function markChecked() {
|
|
475
|
+
const stored = readStoredView(win)
|
|
476
|
+
for (const item of items()) {
|
|
477
|
+
item.setAttribute('aria-checked', String(item.getAttribute('data-hcms-view') === stored))
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function onOutsidePointer(event) {
|
|
482
|
+
if (host.contains(event.target)) return
|
|
483
|
+
closeMenu()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function setMenuOpen(show) {
|
|
487
|
+
if (!menu) return
|
|
488
|
+
menu.hidden = !show
|
|
489
|
+
pin(menu, { display: show ? 'block' : 'none' })
|
|
490
|
+
arrow.setAttribute('aria-expanded', String(show))
|
|
491
|
+
if (show) {
|
|
492
|
+
markChecked()
|
|
493
|
+
// Capture, so a page that swallows pointer events on the way up cannot
|
|
494
|
+
// leave the menu standing open over content it no longer belongs to.
|
|
495
|
+
doc.addEventListener('pointerdown', onOutsidePointer, true)
|
|
496
|
+
} else {
|
|
497
|
+
doc.removeEventListener('pointerdown', onOutsidePointer, true)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function openMenu(index = 0) {
|
|
502
|
+
setMenuOpen(true)
|
|
503
|
+
focusItem(index)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function closeMenu({ focusArrow = false } = {}) {
|
|
507
|
+
if (!menu || menu.hidden) return
|
|
508
|
+
setMenuOpen(false)
|
|
509
|
+
if (focusArrow) focusEl(arrow)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A remembered view this page cannot render is ignored HERE and left in
|
|
513
|
+
// storage: the preference belongs to the person, not to the page, and the
|
|
514
|
+
// next page may well be able to honour it.
|
|
515
|
+
function rememberedView() {
|
|
516
|
+
const stored = readStoredView(win)
|
|
517
|
+
return stored && available.includes(stored) ? stored : null
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The one path that writes the preference, because it is the one where the
|
|
521
|
+
// person chose. After the open, never before: a mount that throws leaves the
|
|
522
|
+
// preference exactly as it was.
|
|
523
|
+
async function chooseView(name) {
|
|
524
|
+
await open({ view: name })
|
|
525
|
+
writeStoredView(win, name)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function mainAction() {
|
|
529
|
+
if (isOpen()) {
|
|
530
|
+
close()
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
const remembered = rememberedView()
|
|
534
|
+
if (remembered) return open({ view: remembered })
|
|
535
|
+
// First run. With a real choice to make the button asks rather than
|
|
536
|
+
// guessing; with one view there is nothing to ask.
|
|
537
|
+
if (split) return openMenu()
|
|
538
|
+
// Opening the only view a page has is not a choice, so it is not
|
|
539
|
+
// remembered: writing it here would take the preference away from every
|
|
540
|
+
// page that does offer both, which is deletion by another name.
|
|
541
|
+
return open(available[0] ? { view: available[0] } : {})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function run(action) {
|
|
64
545
|
try {
|
|
65
|
-
|
|
66
|
-
else await open()
|
|
546
|
+
await action()
|
|
67
547
|
} catch (err) {
|
|
68
548
|
console.warn('hypercms: toggle failed to open the CMS', err)
|
|
69
549
|
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Delegated, so both a real click on the inner button and a programmatic
|
|
553
|
+
// host.click() fire exactly once. The arrow and the menu own their own
|
|
554
|
+
// clicks, which bubble through here on their way out.
|
|
555
|
+
host.addEventListener('click', async (event) => {
|
|
556
|
+
if (arrow && arrow.contains(event.target)) return
|
|
557
|
+
if (menu && menu.contains(event.target)) return
|
|
558
|
+
await run(mainAction)
|
|
70
559
|
})
|
|
71
|
-
|
|
72
|
-
|
|
560
|
+
|
|
561
|
+
if (arrow) {
|
|
562
|
+
arrow.addEventListener('click', (event) => {
|
|
563
|
+
event.preventDefault()
|
|
564
|
+
if (menu.hidden) openMenu()
|
|
565
|
+
else closeMenu({ focusArrow: true })
|
|
566
|
+
})
|
|
567
|
+
arrow.addEventListener('keydown', (event) => {
|
|
568
|
+
if (event.key === 'ArrowUp') {
|
|
569
|
+
event.preventDefault()
|
|
570
|
+
openMenu(-1)
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
// preventDefault on Enter and Space stops the button's own activation
|
|
574
|
+
// click, which would otherwise arrive and toggle the menu straight back
|
|
575
|
+
// shut.
|
|
576
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
577
|
+
event.preventDefault()
|
|
578
|
+
openMenu(0)
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
menu.addEventListener('click', (event) => {
|
|
582
|
+
const item = event.target.closest?.('[role="menuitemradio"]')
|
|
583
|
+
if (!item) return
|
|
584
|
+
event.preventDefault()
|
|
585
|
+
closeMenu()
|
|
586
|
+
run(() => chooseView(item.getAttribute('data-hcms-view')))
|
|
587
|
+
})
|
|
588
|
+
menu.addEventListener('keydown', (event) => {
|
|
589
|
+
const list = items()
|
|
590
|
+
const current = list.indexOf(doc.activeElement)
|
|
591
|
+
switch (event.key) {
|
|
592
|
+
case 'ArrowDown': event.preventDefault(); focusItem(current + 1); break
|
|
593
|
+
case 'ArrowUp': event.preventDefault(); focusItem(current - 1); break
|
|
594
|
+
case 'Home': event.preventDefault(); focusItem(0); break
|
|
595
|
+
case 'End': event.preventDefault(); focusItem(list.length - 1); break
|
|
596
|
+
case 'Enter':
|
|
597
|
+
case ' ':
|
|
598
|
+
event.preventDefault()
|
|
599
|
+
if (current === -1) break
|
|
600
|
+
closeMenu()
|
|
601
|
+
run(() => chooseView(list[current].getAttribute('data-hcms-view')))
|
|
602
|
+
break
|
|
603
|
+
case 'Escape': event.preventDefault(); closeMenu({ focusArrow: true }); break
|
|
604
|
+
// No preventDefault: Tab leaves the menu, it does not cycle inside it.
|
|
605
|
+
case 'Tab': closeMenu(); break
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Which label the main button shows, and nothing else decides it. hcms:close
|
|
611
|
+
// fires BEFORE the session is torn down, so isOpen() still answers true inside
|
|
612
|
+
// that handler: the event says what happened and re-asking would leave the
|
|
613
|
+
// button reading "Close editor" over a closed editor.
|
|
614
|
+
const setSessionState = (sessionOpen) => {
|
|
615
|
+
const theme = getTheme()
|
|
616
|
+
host.classList.toggle('light', theme === 'light')
|
|
617
|
+
host.classList.toggle('dark', theme === 'dark')
|
|
618
|
+
if (sessionOpen) host.setAttribute(SESSION_ATTR, 'open')
|
|
619
|
+
else host.removeAttribute(SESSION_ATTR)
|
|
620
|
+
}
|
|
621
|
+
setSessionState(isOpen())
|
|
622
|
+
doc.addEventListener('hcms:open', () => setSessionState(true))
|
|
623
|
+
doc.addEventListener('hcms:close', () => setSessionState(false))
|
|
624
|
+
|
|
625
|
+
doc.body.appendChild(host)
|
|
626
|
+
reensureStyles(doc)
|
|
627
|
+
scheduleSurface(host)
|
|
628
|
+
return host
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function makeArrow(doc) {
|
|
632
|
+
const arrow = doc.createElement('button')
|
|
633
|
+
arrow.type = 'button'
|
|
634
|
+
arrow.className = 'hcms-toggle__arrow mirk-button'
|
|
635
|
+
arrow.setAttribute('aria-haspopup', 'menu')
|
|
636
|
+
arrow.setAttribute('aria-expanded', 'false')
|
|
637
|
+
// The arrow is a glyph, so unlike the main button it has no visible text to
|
|
638
|
+
// take its accessible name from.
|
|
639
|
+
arrow.setAttribute('aria-label', 'Choose where to edit')
|
|
640
|
+
arrow.textContent = '▾'
|
|
641
|
+
return arrow
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function makeMenu(doc, available) {
|
|
645
|
+
const menu = doc.createElement('div')
|
|
646
|
+
menu.className = 'hcms-toggle__menu'
|
|
647
|
+
menu.setAttribute('role', 'menu')
|
|
648
|
+
menu.setAttribute('aria-label', 'Where to edit')
|
|
649
|
+
menu.hidden = true
|
|
650
|
+
for (const name of available) {
|
|
651
|
+
const item = doc.createElement('button')
|
|
652
|
+
item.type = 'button'
|
|
653
|
+
item.className = 'hcms-toggle__item'
|
|
654
|
+
// menuitemradio, not menuitem: the two are one choice with one answer, and
|
|
655
|
+
// the remembered one is checked.
|
|
656
|
+
item.setAttribute('role', 'menuitemradio')
|
|
657
|
+
item.setAttribute('aria-checked', 'false')
|
|
658
|
+
item.setAttribute('data-hcms-view', name)
|
|
659
|
+
// Roving focus: the menu is entered from the arrow and moved through with
|
|
660
|
+
// the arrow keys, never tabbed into item by item.
|
|
661
|
+
item.tabIndex = -1
|
|
662
|
+
item.textContent = VIEW_LABELS[name]
|
|
663
|
+
menu.appendChild(item)
|
|
664
|
+
}
|
|
665
|
+
return menu
|
|
73
666
|
}
|
|
74
667
|
|
|
75
668
|
// Inject at page load when in edit mode and the page has cms rules. DOM-ready
|