@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
5
5
  "keywords": [
6
6
  "HTML",
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, and publish
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; --hcms-toggle-display: none; }
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; }
@@ -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, and publish
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; --hcms-toggle-display: none; }
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
- // The same degradation toggle.js applies in scheduleSurface: a window with
97
- // no requestAnimationFrame gets a synchronous pass instead of a crash on
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
- }