@panphora/hyper-cms 0.12.0 → 0.13.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 +7 -34
- package/dist/hypercms.min.js +19 -72
- package/package.json +1 -1
- package/src/hypercms.js +0 -10
- package/src/theme/pixel-quiet.overrides.css +2 -8
- package/src/theme.generated.css +2 -8
- package/src/views/inline-layer.js +2 -3
- package/src/toggle.js +0 -465
package/package.json
CHANGED
package/src/hypercms.js
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
resumeSession,
|
|
28
28
|
} from './session.js'
|
|
29
29
|
import { findLeafField, writeFieldValue } from './events.js'
|
|
30
|
-
import { maybeInjectToggle } from './toggle.js'
|
|
31
30
|
import { platform, onPlatformEvent, MUTATION_READY } from './platform.js'
|
|
32
31
|
import { installHooks } from './hooks.js'
|
|
33
32
|
|
|
@@ -408,15 +407,6 @@ function whenReady(fn) {
|
|
|
408
407
|
// window, and the guards above no-op there).
|
|
409
408
|
maybeAutoOpen()
|
|
410
409
|
|
|
411
|
-
// Floating edit-mode toggle at module load (same browser-only no-op in node).
|
|
412
|
-
maybeInjectToggle({
|
|
413
|
-
open,
|
|
414
|
-
close,
|
|
415
|
-
isOpen,
|
|
416
|
-
getTheme: () => state.opts?.theme,
|
|
417
|
-
hasRules: (doc) => !!engine.findRules(doc, 'cms'),
|
|
418
|
-
})
|
|
419
|
-
|
|
420
410
|
const cms = {
|
|
421
411
|
open,
|
|
422
412
|
close,
|
|
@@ -140,20 +140,14 @@
|
|
|
140
140
|
box-shadow: 16px 0 48px -28px rgba(43, 36, 27, 0.45);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/* Push the page over so docked content is never hidden underneath
|
|
144
|
-
the geometry the floating toggle has to compose with. --hcms-toggle-shift is
|
|
145
|
-
the distance the toggle moves left, which is the shell width only while the
|
|
146
|
-
shell is docked on the right; docked left, as an overlay, or full-viewport it
|
|
147
|
-
is zero. */
|
|
143
|
+
/* Push the page over so docked content is never hidden underneath. */
|
|
148
144
|
body.hcms-open { --hcms-shell-width: 380px; }
|
|
149
145
|
body.hcms-open:not(.hcms-overlay) { padding-right: var(--hcms-shell-width); }
|
|
150
|
-
body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: var(--hcms-shell-width); }
|
|
151
146
|
body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: var(--hcms-shell-width); }
|
|
152
|
-
body.hcms-open.hcms-overlay { overflow: hidden;
|
|
147
|
+
body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
153
148
|
|
|
154
149
|
@media (max-width: 799px) {
|
|
155
150
|
body.hcms-open { --hcms-shell-width: 100vw; }
|
|
156
|
-
body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: 0px; }
|
|
157
151
|
body.hcms-open:not(.hcms-overlay),
|
|
158
152
|
body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: 0; }
|
|
159
153
|
body.hcms-open { overflow: hidden; }
|
package/src/theme.generated.css
CHANGED
|
@@ -1151,20 +1151,14 @@
|
|
|
1151
1151
|
box-shadow: 16px 0 48px -28px rgba(43, 36, 27, 0.45);
|
|
1152
1152
|
}
|
|
1153
1153
|
|
|
1154
|
-
/* Push the page over so docked content is never hidden underneath
|
|
1155
|
-
the geometry the floating toggle has to compose with. --hcms-toggle-shift is
|
|
1156
|
-
the distance the toggle moves left, which is the shell width only while the
|
|
1157
|
-
shell is docked on the right; docked left, as an overlay, or full-viewport it
|
|
1158
|
-
is zero. */
|
|
1154
|
+
/* Push the page over so docked content is never hidden underneath. */
|
|
1159
1155
|
body.hcms-open { --hcms-shell-width: 380px; }
|
|
1160
1156
|
body.hcms-open:not(.hcms-overlay) { padding-right: var(--hcms-shell-width); }
|
|
1161
|
-
body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: var(--hcms-shell-width); }
|
|
1162
1157
|
body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: var(--hcms-shell-width); }
|
|
1163
|
-
body.hcms-open.hcms-overlay { overflow: hidden;
|
|
1158
|
+
body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
1164
1159
|
|
|
1165
1160
|
@media (max-width: 799px) {
|
|
1166
1161
|
body.hcms-open { --hcms-shell-width: 100vw; }
|
|
1167
|
-
body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) { --hcms-toggle-shift: 0px; }
|
|
1168
1162
|
body.hcms-open:not(.hcms-overlay),
|
|
1169
1163
|
body.hcms-open.hcms-side-left:not(.hcms-overlay) { padding-right: 0; padding-left: 0; }
|
|
1170
1164
|
body.hcms-open { overflow: hidden; }
|
|
@@ -93,9 +93,8 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction, onSt
|
|
|
93
93
|
|
|
94
94
|
function schedule() {
|
|
95
95
|
if (frame || !win) return
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// mount. Every browser has it; a non-visual jsdom does not.
|
|
96
|
+
// A window with no requestAnimationFrame gets a synchronous pass instead of
|
|
97
|
+
// a crash on mount. Every browser has it; a non-visual jsdom does not.
|
|
99
98
|
if (typeof win.requestAnimationFrame !== 'function') return placeAll()
|
|
100
99
|
frame = win.requestAnimationFrame(() => {
|
|
101
100
|
frame = 0
|
package/src/toggle.js
DELETED
|
@@ -1,465 +0,0 @@
|
|
|
1
|
-
// Floating bottom-right button that toggles the CMS open/closed, so a page
|
|
2
|
-
// owner can reopen the CMS after closing it (the shell's X is the only other
|
|
3
|
-
// affordance, and nothing reopens it). Injected at page load when the visitor
|
|
4
|
-
// may edit AND the page actually has cms rules, so regular visitors and
|
|
5
|
-
// rules-less pages (hypercms rides in broad presets) never see it. On a ClayJS
|
|
6
|
-
// page "may edit" is clay.canEdit alone: view mode is where the toggle is
|
|
7
|
-
// needed most, since it is the way into editing.
|
|
8
|
-
// Runtime-only chrome: no-save + snapshot-remove keep it out of every save and
|
|
9
|
-
// snapshot (a static no-save element written into the file would be stripped
|
|
10
|
-
// from disk on the first save), save-ignore keeps it out of autosave/undo,
|
|
11
|
-
// mirroring the shell's own attributes.
|
|
12
|
-
//
|
|
13
|
-
// The control is a <hypercms-toggle> host wrapping a real <button>. The host is
|
|
14
|
-
// an unregistered custom element on purpose: a host page's `button { ... }`
|
|
15
|
-
// reset cannot reach it, and that reset is the common way an injected control
|
|
16
|
-
// gets mangled. Only the structural facts that must survive a hostile
|
|
17
|
-
// `* { ... !important }` reset are pinned inline with !important; each reads a
|
|
18
|
-
// public custom property, so overriding still works. Everything cosmetic is a
|
|
19
|
-
// normal declaration selected by [data-hcms-toggle-host], which an author's own
|
|
20
|
-
// #hcms-toggle rule outranks on specificity.
|
|
21
|
-
|
|
22
|
-
import { reensureStyles } from './shell.js'
|
|
23
|
-
import { modeLifecycle } from './session.js'
|
|
24
|
-
|
|
25
|
-
const TOGGLE_ID = 'hcms-toggle'
|
|
26
|
-
const HOST_ATTR = 'data-hcms-toggle-host'
|
|
27
|
-
const STYLE_ID = 'hcms-toggle-style'
|
|
28
|
-
const STYLE_ATTR = 'data-hcms-toggle-style'
|
|
29
|
-
// "A session of either kind is up", written by the open/close events. NOT
|
|
30
|
-
// body.hcms-open: that class is the sidebar's page shift and an inline session
|
|
31
|
-
// sets nothing on <body>, so a body-class label reads "Edit content" over an
|
|
32
|
-
// open inline editor.
|
|
33
|
-
const SESSION_ATTR = 'data-hcms-session'
|
|
34
|
-
|
|
35
|
-
const SURFACE_VAR = 'var(--hcms-toggle-bg, var(--hcms-toggle-_surface))'
|
|
36
|
-
|
|
37
|
-
// The pill's surface is opaque and neutral, picked at inject time from the two
|
|
38
|
-
// values below by whichever has more contrast against the page's own ink. It is
|
|
39
|
-
// not a tint of currentColor: the button floats over a backdrop it cannot see,
|
|
40
|
-
// and a 10% tint over a dark hero measures 1.3:1. Opaque removes the backdrop
|
|
41
|
-
// from the question entirely. Measured floor for this pair is 4.36:1 across
|
|
42
|
-
// 1516 inks; a 4.5:1 floor needs #030303, which is black in all but name.
|
|
43
|
-
const SURFACE_LIGHT = '#fafafa'
|
|
44
|
-
const SURFACE_DARK = '#0a0a0a'
|
|
45
|
-
|
|
46
|
-
export const TOGGLE_STYLE = `
|
|
47
|
-
[${HOST_ATTR}] {
|
|
48
|
-
all: unset;
|
|
49
|
-
box-sizing: border-box;
|
|
50
|
-
display: var(--hcms-toggle-display, inline-flex);
|
|
51
|
-
font-family: 'Departure Mono', ui-monospace, Menlo, monospace;
|
|
52
|
-
--hcms-toggle-_surface: ${SURFACE_LIGHT};
|
|
53
|
-
}
|
|
54
|
-
[${HOST_ATTR}][data-hcms-surface="dark"] {
|
|
55
|
-
--hcms-toggle-_surface: ${SURFACE_DARK};
|
|
56
|
-
}
|
|
57
|
-
[${HOST_ATTR}] .hcms-toggle__main {
|
|
58
|
-
all: unset;
|
|
59
|
-
box-sizing: border-box;
|
|
60
|
-
display: inline-flex;
|
|
61
|
-
align-items: center;
|
|
62
|
-
gap: 7px;
|
|
63
|
-
padding: 0 14px;
|
|
64
|
-
min-height: 40px;
|
|
65
|
-
font-family: inherit;
|
|
66
|
-
font-weight: 400;
|
|
67
|
-
line-height: 1.5;
|
|
68
|
-
font-size: 14px;
|
|
69
|
-
color: var(--hcms-toggle-color, currentColor);
|
|
70
|
-
border: 2px solid;
|
|
71
|
-
border-color: var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8);
|
|
72
|
-
border-radius: 0;
|
|
73
|
-
cursor: pointer;
|
|
74
|
-
box-shadow: none;
|
|
75
|
-
}
|
|
76
|
-
[${HOST_ATTR}] .hcms-toggle__main:hover {
|
|
77
|
-
border-color: var(--mirk-focus-color, #C7AE93);
|
|
78
|
-
box-shadow: none;
|
|
79
|
-
}
|
|
80
|
-
[${HOST_ATTR}] .hcms-toggle__main:active {
|
|
81
|
-
border-color: var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF);
|
|
82
|
-
}
|
|
83
|
-
[${HOST_ATTR}] .hcms-toggle__main:focus-visible {
|
|
84
|
-
outline: 2px solid currentColor;
|
|
85
|
-
outline-offset: -5px;
|
|
86
|
-
}
|
|
87
|
-
[${HOST_ATTR}] .hcms-toggle__close { display: none; }
|
|
88
|
-
[${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__open { display: none; }
|
|
89
|
-
[${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__close { display: inline; }
|
|
90
|
-
@media (pointer: coarse) {
|
|
91
|
-
[${HOST_ATTR}] .hcms-toggle__main { min-height: 44px; }
|
|
92
|
-
}
|
|
93
|
-
`
|
|
94
|
-
|
|
95
|
-
// Mirrors hyperclayjs core/isAdminOfCurrentResource.js: an explicit
|
|
96
|
-
// ?editmode=true|false param wins, then the window.__hyperclayEditMode global
|
|
97
|
-
// (standalone opt-in), then the platform's isAdminOfCurrentResource cookie.
|
|
98
|
-
// Replicated here rather than read off window.hyperclay because the edit-mode
|
|
99
|
-
// module isn't part of the cms preset, so the global isn't reliably present.
|
|
100
|
-
export function detectEditMode({ search = '', cookie = '', forced = null } = {}) {
|
|
101
|
-
const str = typeof search === 'string' ? search : ''
|
|
102
|
-
const qIndex = str.indexOf('?')
|
|
103
|
-
const query = qIndex === -1 ? str : str.slice(qIndex + 1)
|
|
104
|
-
const param = new URLSearchParams(query).get('editmode')
|
|
105
|
-
if (param) return param === 'true'
|
|
106
|
-
if (forced != null) return Boolean(forced)
|
|
107
|
-
return /(?:^|;\s*)isAdminOfCurrentResource=[^;]/.test(cookie)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// A computed `color` is not always an rgb() string: CSS Color 4 keeps the
|
|
111
|
-
// authored space, so Chrome returns oklch(), lab() and color() as written, and
|
|
112
|
-
// Tailwind v4's whole palette is oklch. rgb()/rgba() is still what the great
|
|
113
|
-
// majority of pages compute to, so parse that directly and let the canvas
|
|
114
|
-
// handle only the rest.
|
|
115
|
-
const colorContexts = new WeakMap()
|
|
116
|
-
|
|
117
|
-
function readCanvasColor(ctx, value) {
|
|
118
|
-
try {
|
|
119
|
-
// 'copy' replaces the pixel instead of blending, so alpha survives.
|
|
120
|
-
ctx.globalCompositeOperation = 'copy'
|
|
121
|
-
ctx.fillStyle = value
|
|
122
|
-
ctx.fillRect(0, 0, 1, 1)
|
|
123
|
-
const d = ctx.getImageData(0, 0, 1, 1).data
|
|
124
|
-
return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }
|
|
125
|
-
} catch (_) {
|
|
126
|
-
return null
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Per document, not per module: the context retains its canvas, which retains
|
|
131
|
-
// its ownerDocument, and hypercms can be driven into a preview iframe.
|
|
132
|
-
function colorContextFor(doc) {
|
|
133
|
-
if (colorContexts.has(doc)) return colorContexts.get(doc)
|
|
134
|
-
let ctx = null
|
|
135
|
-
try {
|
|
136
|
-
const canvas = doc.createElement('canvas')
|
|
137
|
-
canvas.width = canvas.height = 1
|
|
138
|
-
ctx = canvas.getContext ? canvas.getContext('2d', { willReadFrequently: true }) : null
|
|
139
|
-
// A browser that fuzzes canvas readback (Firefox's resistFingerprinting)
|
|
140
|
-
// answers the same colour for everything, which would send every page to
|
|
141
|
-
// one surface at about 1:1. Round-trip a known value once and refuse the
|
|
142
|
-
// whole path if it does not come back exactly.
|
|
143
|
-
if (ctx) {
|
|
144
|
-
const probe = readCanvasColor(ctx, 'rgb(1, 2, 3)')
|
|
145
|
-
if (!probe || probe.r !== 1 || probe.g !== 2 || probe.b !== 3 || probe.a !== 1) ctx = null
|
|
146
|
-
}
|
|
147
|
-
} catch (_) {
|
|
148
|
-
ctx = null
|
|
149
|
-
}
|
|
150
|
-
colorContexts.set(doc, ctx)
|
|
151
|
-
return ctx
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function normalizeColor(value, doc) {
|
|
155
|
-
const raw = String(value == null ? '' : value).trim()
|
|
156
|
-
if (!raw) return null
|
|
157
|
-
const direct = parseRgb(raw)
|
|
158
|
-
if (direct) return direct
|
|
159
|
-
const win = doc && doc.defaultView
|
|
160
|
-
// CSS.supports is the parse check AND the capability probe: jsdom has neither
|
|
161
|
-
// it nor a 2d context, so this short-circuits without jsdom logging a "not
|
|
162
|
-
// implemented" error for getContext. It matters because a rejected fillStyle
|
|
163
|
-
// silently keeps its previous value, so an unguarded read would report the
|
|
164
|
-
// last ink this page measured.
|
|
165
|
-
if (!win || !win.CSS || typeof win.CSS.supports !== 'function') return null
|
|
166
|
-
if (!win.CSS.supports('color', raw)) return null
|
|
167
|
-
const ctx = colorContextFor(doc)
|
|
168
|
-
return ctx ? readCanvasColor(ctx, raw) : null
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function parseRgb(value) {
|
|
172
|
-
const match = /rgba?\(([^)]+)\)/.exec(String(value == null ? '' : value))
|
|
173
|
-
if (!match) return null
|
|
174
|
-
const parts = match[1].split(/[\s,/]+/).filter(Boolean)
|
|
175
|
-
if (parts.length < 3) return null
|
|
176
|
-
const channels = parts.slice(0, 3).map(Number)
|
|
177
|
-
if (channels.some(Number.isNaN)) return null
|
|
178
|
-
const rawAlpha = parts[3]
|
|
179
|
-
const alpha = rawAlpha === undefined
|
|
180
|
-
? 1
|
|
181
|
-
: (rawAlpha.endsWith('%') ? Number(rawAlpha.slice(0, -1)) / 100 : Number(rawAlpha))
|
|
182
|
-
return {
|
|
183
|
-
r: channels[0], g: channels[1], b: channels[2],
|
|
184
|
-
a: Number.isNaN(alpha) ? 1 : alpha,
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function relativeLuminance({ r, g, b }) {
|
|
189
|
-
const channel = (v) => {
|
|
190
|
-
const s = v / 255
|
|
191
|
-
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
192
|
-
}
|
|
193
|
-
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function contrast(a, b) {
|
|
197
|
-
const la = relativeLuminance(a)
|
|
198
|
-
const lb = relativeLuminance(b)
|
|
199
|
-
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const DARK_RGB = { r: 10, g: 10, b: 10 }
|
|
203
|
-
const LIGHT_RGB = { r: 250, g: 250, b: 250 }
|
|
204
|
-
|
|
205
|
-
// Source-over composite, because the label is painted ON the surface: a
|
|
206
|
-
// translucent ink's rendered colour depends on which surface it lands on, so
|
|
207
|
-
// each candidate has to be judged against its own composite.
|
|
208
|
-
function over(fg, bg) {
|
|
209
|
-
const a = fg.a == null ? 1 : fg.a
|
|
210
|
-
return {
|
|
211
|
-
r: fg.r * a + bg.r * (1 - a),
|
|
212
|
-
g: fg.g * a + bg.g * (1 - a),
|
|
213
|
-
b: fg.b * a + bg.b * (1 - a),
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Which of the two surfaces the page's ink reads better on. Ties and unknown
|
|
218
|
-
// inks go light, which is the quieter result on the overwhelmingly common
|
|
219
|
-
// dark-ink-on-light-ground page. The 4.36:1 floor holds for opaque ink only:
|
|
220
|
-
// nothing can make `color: rgb(0 0 0 / 40%)` readable, and this picks the
|
|
221
|
-
// better of two bad options rather than pretending otherwise.
|
|
222
|
-
export function pickSurface(ink) {
|
|
223
|
-
if (!ink) return 'light'
|
|
224
|
-
const onDark = contrast(over(ink, DARK_RGB), DARK_RGB)
|
|
225
|
-
const onLight = contrast(over(ink, LIGHT_RGB), LIGHT_RGB)
|
|
226
|
-
return onDark > onLight ? 'dark' : 'light'
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function pin(el, declarations) {
|
|
230
|
-
for (const [property, value] of Object.entries(declarations)) {
|
|
231
|
-
el.style.setProperty(property, value, 'important')
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function applySurface(host) {
|
|
236
|
-
const win = host.ownerDocument && host.ownerDocument.defaultView
|
|
237
|
-
const main = host.querySelector('.hcms-toggle__main')
|
|
238
|
-
if (!win || !main || typeof win.getComputedStyle !== 'function') return
|
|
239
|
-
const ink = normalizeColor(win.getComputedStyle(main).color, host.ownerDocument)
|
|
240
|
-
host.setAttribute('data-hcms-surface', pickSurface(ink))
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// The ink is read from the live cascade, so it is only final once the page's
|
|
244
|
-
// own stylesheets have applied, and it changes again whenever the page switches
|
|
245
|
-
// theme. A dark-mode toggle that flips <html> or <body> would otherwise leave a
|
|
246
|
-
// near-white label on the near-white surface picked at load. Two elements, no
|
|
247
|
-
// subtree, rAF-collapsed, and it unhooks itself once the host is gone.
|
|
248
|
-
function scheduleSurface(host) {
|
|
249
|
-
applySurface(host)
|
|
250
|
-
const doc = host.ownerDocument
|
|
251
|
-
const win = doc && doc.defaultView
|
|
252
|
-
if (!win) return
|
|
253
|
-
|
|
254
|
-
let frame = 0
|
|
255
|
-
const recompute = () => {
|
|
256
|
-
if (frame) return
|
|
257
|
-
if (typeof win.requestAnimationFrame !== 'function') {
|
|
258
|
-
if (!host.isConnected) return stop()
|
|
259
|
-
return applySurface(host)
|
|
260
|
-
}
|
|
261
|
-
frame = win.requestAnimationFrame(() => {
|
|
262
|
-
frame = 0
|
|
263
|
-
if (!host.isConnected) return stop()
|
|
264
|
-
applySurface(host)
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (typeof win.requestAnimationFrame === 'function') recompute()
|
|
269
|
-
if (doc.readyState !== 'complete') win.addEventListener('load', recompute, { once: true })
|
|
270
|
-
|
|
271
|
-
const observer = typeof win.MutationObserver === 'function' ? new win.MutationObserver(recompute) : null
|
|
272
|
-
if (observer) {
|
|
273
|
-
// No attributeFilter. Theme togglers write whatever attribute they like:
|
|
274
|
-
// Bootstrap 5.3 uses data-bs-theme, Primer uses data-color-mode, plenty use
|
|
275
|
-
// a class or an inline style. An allowlist is a guess about other people's
|
|
276
|
-
// markup and it was already wrong twice. Two elements, no subtree, and the
|
|
277
|
-
// callback collapses into one animation frame, so watching every attribute
|
|
278
|
-
// costs nothing measurable.
|
|
279
|
-
observer.observe(doc.documentElement, { attributes: true })
|
|
280
|
-
if (doc.body) observer.observe(doc.body, { attributes: true })
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const scheme = typeof win.matchMedia === 'function' ? win.matchMedia('(prefers-color-scheme: dark)') : null
|
|
284
|
-
if (scheme && typeof scheme.addEventListener === 'function') scheme.addEventListener('change', recompute)
|
|
285
|
-
|
|
286
|
-
// Self-disconnecting: any attribute write on <html> or <body> schedules a
|
|
287
|
-
// recompute, which stops the watcher once the host has left the document.
|
|
288
|
-
// Round 2 added an explicit sweep at injection time as well; it was dead
|
|
289
|
-
// code, because injectToggle runs once per page. The residual is a single
|
|
290
|
-
// observer surviving on a page that removes our host and then never touches
|
|
291
|
-
// an attribute on <html> or <body> again, which is clutter on a page that
|
|
292
|
-
// has already thrown the control away.
|
|
293
|
-
function stop() {
|
|
294
|
-
if (observer) observer.disconnect()
|
|
295
|
-
if (scheme && typeof scheme.removeEventListener === 'function') scheme.removeEventListener('change', recompute)
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export function injectToggle({ open, close, isOpen, getTheme = () => null }, doc = document) {
|
|
300
|
-
const existing = doc.querySelector(`[${HOST_ATTR}]`)
|
|
301
|
-
if (existing) return existing
|
|
302
|
-
|
|
303
|
-
if (!doc.querySelector(`[${STYLE_ATTR}]`)) {
|
|
304
|
-
const style = doc.createElement('style')
|
|
305
|
-
style.setAttribute(STYLE_ATTR, '')
|
|
306
|
-
style.setAttribute('editor-ui', '')
|
|
307
|
-
if (!doc.getElementById(STYLE_ID)) style.id = STYLE_ID
|
|
308
|
-
style.setAttribute('no-save', '')
|
|
309
|
-
style.setAttribute('snapshot-remove', '')
|
|
310
|
-
style.setAttribute('save-ignore', '')
|
|
311
|
-
style.textContent = TOGGLE_STYLE
|
|
312
|
-
// Prepended, not appended: an author who overrides with a selector no
|
|
313
|
-
// stronger than ours then wins the tie on document order.
|
|
314
|
-
doc.head.insertBefore(style, doc.head.firstChild)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const host = doc.createElement('hypercms-toggle')
|
|
318
|
-
host.className = 'hcms-shell pixel-quiet'
|
|
319
|
-
// The id is the documented override handle, but it is the page's namespace:
|
|
320
|
-
// if the page already owns it, go without rather than shadow their element.
|
|
321
|
-
if (!doc.getElementById(TOGGLE_ID)) host.id = TOGGLE_ID
|
|
322
|
-
host.setAttribute(HOST_ATTR, '')
|
|
323
|
-
host.setAttribute('editor-ui', '')
|
|
324
|
-
host.setAttribute('no-save', '')
|
|
325
|
-
host.setAttribute('snapshot-remove', '')
|
|
326
|
-
host.setAttribute('save-ignore', '')
|
|
327
|
-
host.innerHTML =
|
|
328
|
-
'<button type="button" class="hcms-toggle__main mirk-button">' +
|
|
329
|
-
'<span class="hcms-toggle__open">Edit content</span>' +
|
|
330
|
-
'<span class="hcms-toggle__close">Close editor</span>' +
|
|
331
|
-
'</button>'
|
|
332
|
-
|
|
333
|
-
const main = host.querySelector('.hcms-toggle__main')
|
|
334
|
-
|
|
335
|
-
// The only declarations a page's `* { ... !important }` reset can take from
|
|
336
|
-
// us and leave the control broken rather than merely ugly. Each reads its
|
|
337
|
-
// public custom property first, so an author override still lands.
|
|
338
|
-
pin(host, {
|
|
339
|
-
position: 'fixed',
|
|
340
|
-
right: 'calc(var(--hcms-toggle-offset, 16px) + var(--hcms-toggle-shift, 0px))',
|
|
341
|
-
bottom: 'calc(var(--hcms-toggle-offset, 16px) + env(safe-area-inset-bottom, 0px))',
|
|
342
|
-
'z-index': 'var(--hcms-toggle-z, 2147482900)',
|
|
343
|
-
})
|
|
344
|
-
pin(main, { background: SURFACE_VAR })
|
|
345
|
-
|
|
346
|
-
function requestMode(mode) {
|
|
347
|
-
const win = doc.defaultView
|
|
348
|
-
if (!modeLifecycle(win)) return null
|
|
349
|
-
const setMode = win.clay && win.clay.setMode
|
|
350
|
-
if (typeof setMode !== 'function') return null
|
|
351
|
-
return setMode.call(win.clay, mode)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// The mode the page is actually in. clay.mode is a live getter over the
|
|
355
|
-
// committed facts, so it answers the same in view and after a live switch.
|
|
356
|
-
function currentMode() {
|
|
357
|
-
const win = doc.defaultView
|
|
358
|
-
return win && win.clay ? win.clay.mode : null
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async function mainAction() {
|
|
362
|
-
if (isOpen()) {
|
|
363
|
-
const exit = requestMode('view')
|
|
364
|
-
if (exit) return exit
|
|
365
|
-
close()
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
// Already in edit: the CMS is the only thing left to do, and the page is
|
|
369
|
-
// already writable.
|
|
370
|
-
if (currentMode() === 'edit') return open()
|
|
371
|
-
// In view the switch comes first and the CMS only after it committed. A
|
|
372
|
-
// refusal (busy, not-allowed, save-failed) leaves the CMS closed, since a
|
|
373
|
-
// page that never became writable cannot host it.
|
|
374
|
-
const enter = requestMode('edit')
|
|
375
|
-
if (!enter) return open()
|
|
376
|
-
const receipt = await enter
|
|
377
|
-
if (receipt && receipt.ok === false) return
|
|
378
|
-
return open()
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
async function run(action) {
|
|
382
|
-
try {
|
|
383
|
-
await action()
|
|
384
|
-
} catch (err) {
|
|
385
|
-
console.warn('hypercms: toggle failed to open the CMS', err)
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Delegated, so both a real click on the inner button and a programmatic
|
|
390
|
-
// host.click() fire exactly once.
|
|
391
|
-
host.addEventListener('click', async () => {
|
|
392
|
-
await run(mainAction)
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
// Which label the main button shows, and nothing else decides it. hcms:close
|
|
396
|
-
// fires BEFORE the session is torn down, so isOpen() still answers true inside
|
|
397
|
-
// that handler: the event says what happened and re-asking would leave the
|
|
398
|
-
// button reading "Close editor" over a closed editor.
|
|
399
|
-
const setSessionState = (sessionOpen) => {
|
|
400
|
-
const theme = getTheme()
|
|
401
|
-
host.classList.toggle('light', theme === 'light')
|
|
402
|
-
host.classList.toggle('dark', theme === 'dark')
|
|
403
|
-
if (sessionOpen) host.setAttribute(SESSION_ATTR, 'open')
|
|
404
|
-
else host.removeAttribute(SESSION_ATTR)
|
|
405
|
-
}
|
|
406
|
-
setSessionState(isOpen())
|
|
407
|
-
doc.addEventListener('hcms:open', () => setSessionState(true))
|
|
408
|
-
doc.addEventListener('hcms:close', () => setSessionState(false))
|
|
409
|
-
|
|
410
|
-
doc.body.appendChild(host)
|
|
411
|
-
reensureStyles(doc)
|
|
412
|
-
scheduleSurface(host)
|
|
413
|
-
return host
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Inject at page load when in edit mode and the page has cms rules. DOM-ready
|
|
417
|
-
// deferral matters when the module evaluates while the document is still
|
|
418
|
-
// parsing (the rules tag or <body> may not exist yet).
|
|
419
|
-
export function maybeInjectToggle(api) {
|
|
420
|
-
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
421
|
-
// On a ClayJS page the toggle is the way INTO editing, so it is mounted exactly
|
|
422
|
-
// when ClayJS says the person may edit (clay.canEdit), in view as well as in
|
|
423
|
-
// edit. That permission can arrive after load, and ClayJS announces every
|
|
424
|
-
// change of it with clay:capabilitychange on the document: the first change to
|
|
425
|
-
// canEdit === true mounts, and losing it afterwards takes the toggle away.
|
|
426
|
-
// Without ClayJS the boot global keeps deciding, exactly as before.
|
|
427
|
-
const clay = window.clay
|
|
428
|
-
const managed = !!clay && typeof clay.setMode === 'function'
|
|
429
|
-
const run = () => { if (document.body && api.hasRules(document)) injectToggle(api) }
|
|
430
|
-
const atDomReady = (fn) => {
|
|
431
|
-
if (document.readyState === 'loading') {
|
|
432
|
-
document.addEventListener('DOMContentLoaded', fn, { once: true })
|
|
433
|
-
} else {
|
|
434
|
-
fn()
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (managed) {
|
|
438
|
-
// A page that loads ClayJS's menu opens the CMS from there, so the toggle stays away
|
|
439
|
-
// while that menu is planned, and comes back only if the menu fails to load.
|
|
440
|
-
let menuFailed = false
|
|
441
|
-
const menuOwnsLauncher = () => {
|
|
442
|
-
const plugins = window.clay && window.clay.plugins
|
|
443
|
-
return !menuFailed && Array.isArray(plugins) && plugins.includes('menu')
|
|
444
|
-
}
|
|
445
|
-
const sync = () => {
|
|
446
|
-
if (window.clay && window.clay.canEdit === true && !menuOwnsLauncher()) {
|
|
447
|
-
run()
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
const host = document.querySelector(`[${HOST_ATTR}]`)
|
|
451
|
-
if (host) host.remove()
|
|
452
|
-
}
|
|
453
|
-
atDomReady(sync)
|
|
454
|
-
document.addEventListener('clay:capabilitychange', sync)
|
|
455
|
-
document.addEventListener('clay:plugin-failed', (event) => {
|
|
456
|
-
if (!event.detail || event.detail.plugin !== 'menu') return
|
|
457
|
-
menuFailed = true
|
|
458
|
-
sync()
|
|
459
|
-
})
|
|
460
|
-
return
|
|
461
|
-
}
|
|
462
|
-
const forced = window.__hyperclayEditMode != null ? window.__hyperclayEditMode : null
|
|
463
|
-
if (!detectEditMode({ search: window.location.search, cookie: document.cookie, forced })) return
|
|
464
|
-
atDomReady(run)
|
|
465
|
-
}
|