@panphora/hyper-cms 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1092 @@
1
+ // The inline view: the editing session rendered onto the page itself rather than
2
+ // into a side panel.
3
+ //
4
+ // Three ways to change one field, chosen per target: text is edited in place
5
+ // through richclay bound to the page element, a native control on the page is
6
+ // left alone to be used, and everything else gets a handle that opens the
7
+ // popover holding the selected item's real form controls.
8
+ //
9
+ // The form is NOT a second representation of the data. It is the same form the
10
+ // sidebar builds, mounted inside the inline host with every field hidden; the
11
+ // popover reveals the selected field and its item siblings in place. Nothing is ever
12
+ // moved in or out of the form tree, so engine.extract(formRoot, formRules) can
13
+ // never be missing a path. That is the failure mode of the obvious alternative,
14
+ // relocating a field node into a popover and back.
15
+ //
16
+ // A text edit is the one path that does not write the page through the engine:
17
+ // richclay has already written it. What the commit does is mirror that value
18
+ // into the form leaf and apply as usual, which the engine's compare-before-write
19
+ // guard turns into a no-op on the element under the caret.
20
+
21
+ import { buildForm } from '../form-builder.js'
22
+ import {
23
+ bindEvents,
24
+ commit,
25
+ cssEscape,
26
+ extractFormData,
27
+ findLeafField,
28
+ onAdd,
29
+ onMove,
30
+ requestRemove,
31
+ suppressUndo,
32
+ writeFieldValue,
33
+ } from '../events.js'
34
+ import { autosizeTextarea, enhanceFields, upgradeInlineTextRules } from '../enhance.js'
35
+ import { applyUnresolvedState } from '../unresolved.js'
36
+ import { refreshForm } from '../refresh.js'
37
+ import { clearSessionOpen, markSessionOpen, reensureStyles } from '../shell.js'
38
+ import { isAnchorable } from '../anchor.js'
39
+ import { resolveTargets } from '../targets.js'
40
+ import { createContentView } from '../lib/content-dom.js'
41
+ import { morph } from 'hyper-morph'
42
+ import { place } from '../place.js'
43
+ import { platform } from '../platform.js'
44
+ import {
45
+ BOUND_ATTR,
46
+ BOUND_ID_ATTR,
47
+ RC_OWNED_ATTR,
48
+ markBound,
49
+ registerBinding,
50
+ releaseBinding,
51
+ resolveRichClay,
52
+ stripOrphanEditorState,
53
+ } from '../richclay-bridge.js'
54
+ import { installSnapshotHook } from '../hooks.js'
55
+ import { createInlineLayer } from './inline-layer.js'
56
+ import { inlineIcon } from '../inline-icons.js'
57
+
58
+ const HOST_TAG = 'hypercms-inline'
59
+
60
+ // Revealed fields and their ancestors, cleared before each activation.
61
+ const ACTIVE_CLASS = 'is-hcms-inline-active'
62
+ const ONPATH_CLASS = 'is-hcms-inline-onpath'
63
+
64
+ const FOCUSABLE =
65
+ 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
66
+
67
+ // The same commands the sidebar's rich-text fields carry, so the two views
68
+ // offer the same formatting.
69
+ const TOOLBAR = ['bold', 'italic', 'link', 'undo', 'redo']
70
+
71
+ const HEADING = /^H[1-6]$/
72
+
73
+ let nextBoundId = 0
74
+
75
+ export function createInlineView({ doc, pageRoot, opts = {} }) {
76
+ const richText = opts.richText !== false
77
+ let host = null
78
+ let layer = null
79
+ let unbindPage = null
80
+ // Every richclay instance this view put on a PAGE element, keyed by that
81
+ // element. Bindings are made on the first click and live until the session
82
+ // ends, so a second click on a heading finds its editor rather than building
83
+ // another one.
84
+ const bindings = new Map()
85
+ // Which target the popover is open over, which handle opened it (so focus can
86
+ // go back there), and the placement mode of THIS open. The mode is reset on
87
+ // every activation: place()'s hysteresis is about one popover following its
88
+ // own anchor, and carrying a previous open's mode into a new one would bias
89
+ // the first placement toward wherever the last field happened to sit.
90
+ let activeTarget = null
91
+ let activeHandle = null
92
+ let popMode = null
93
+
94
+ // Build one binding: the editor, the marker, the snapshot hook, and the two
95
+ // listeners that turn typing into commits and blur into one undo primitive.
96
+ // Returns it, or null when the element cannot carry an editor. Shared by the
97
+ // first click and by the repair after a morph, so a rebound element comes back
98
+ // with everything the first bind gave it.
99
+ function bindText(ctx, el, path, prop, carry) {
100
+ const RichClay = resolveRichClay(doc.defaultView)
101
+ if (typeof RichClay !== 'function') return null
102
+
103
+ // A rebind inherits what the element looked like before richclay first
104
+ // touched it. Re-deriving these from the DOM reads the editor's own
105
+ // normalisation back as the author's markup, and the peer's morph as
106
+ // proof there was never an author's editor here.
107
+ const inherited = carry || {}
108
+ // Before richclay touches it. Squire normalises the markup it is handed:
109
+ // measured in Chrome, binding a heading rewrote
110
+ // <h1>A page that edits <em>itself</em></h1>
111
+ // into
112
+ // <h1><div>A page that edits <i>itself</i></div></h1>
113
+ // and that reaches the saved file. Someone who clicks a heading, reads it
114
+ // and presses Escape has changed nothing and must not have changed their
115
+ // document either, so an untouched session puts this back verbatim.
116
+ const originalHTML = inherited.originalHTML ?? createContentView(el, { capability: 'history' }).html()
117
+ const richClayIsOurs = inherited.richClayIsOurs ?? !el.hasAttribute('data-richclay')
118
+ // richclay's constructor returns the existing instance for an element that
119
+ // already has one (richclay.js:85-86), so on a page where the author mounted
120
+ // their own editor this is theirs, not ours. Adopt it for the session and
121
+ // never tear it down: destroying it would take away an editor hypercms did
122
+ // not create and cannot put back. data-richclay-active is richclay's own
123
+ // "there is a live editor here" flag, written by setupEditorAttributes.
124
+ const adopted = inherited.adopted ?? el.getAttribute('data-richclay-active') === 'true'
125
+ // The undo baseline, read before richclay touches the element for the same
126
+ // reason as originalHTML above: read after construct it holds Squire's
127
+ // normalisation, and undoing then writes <div> wrappers and <i> tags into
128
+ // the author's file. richclay stamps no-undo on what it activates, so from
129
+ // the bind until unbind this is the only record the page's undo stack gets.
130
+ // Never inherited: a rebind's baseline is the value on the page now.
131
+ const oldValue = readPageValue(el, prop, 'history')
132
+ const editor = construct(ctx, RichClay, el, prop, richClayIsOurs)
133
+ if (!editor) return null
134
+
135
+ // The rule's projection, not this element's children. A marker is only ever
136
+ // read back to answer "should the rule be rich", and for an array the rule
137
+ // is shared by every row: a plain row under a rich rule would otherwise
138
+ // report plain and downgrade the whole rule the moment the row that proved
139
+ // it rich is deleted. Recording the projection also states the frozen
140
+ // projection rule directly instead of approximating it.
141
+ markBound(el, prop === 'innerHTML', richClayIsOurs)
142
+ const boundId = String(++nextBoundId)
143
+ el.setAttribute(BOUND_ID_ATTR, boundId)
144
+ // The second trigger for the snapshot hook, and the reliable one: by the
145
+ // time anyone clicks a heading the host client is certainly loaded, even
146
+ // on a page that missed the readiness event at open(). Without the hook
147
+ // the editor's contenteditable reaches the saved file.
148
+ installSnapshotHook()
149
+
150
+ const binding = {
151
+ el,
152
+ editor,
153
+ path,
154
+ prop,
155
+ boundId,
156
+ originalHTML,
157
+ oldValue,
158
+ richClayIsOurs,
159
+ adopted,
160
+ // Set by squire's input event, which is the only signal that someone
161
+ // actually edited. Comparing the projected value cannot answer this:
162
+ // the normalisation above changes innerHTML at bind time, before anyone
163
+ // has typed anything.
164
+ dirty: false,
165
+ written: false,
166
+ // Whether the pre-bind markup may go back. Adopted means the editor is the
167
+ // author's and its normalisation is theirs to keep; dirty means they
168
+ // typed; written means a commit changed this path.
169
+ restorable() {
170
+ return !this.adopted && !this.dirty && !this.written
171
+ },
172
+ lastEdited: undefined,
173
+ }
174
+ bindings.set(el, binding)
175
+ registerBinding(boundId, binding)
176
+
177
+ // Toolbar commands mutate the DOM through squire without always firing a
178
+ // native input event, so squire's own signal is the one to commit on.
179
+ const squire = editor.squire
180
+ if (squire && typeof squire.addEventListener === 'function') {
181
+ const onInput = () => {
182
+ binding.dirty = true
183
+ // What this person produced, captured while the DOM still holds it.
184
+ // recordUndo reads this rather than the element, because at the one
185
+ // boundary that matters the element no longer holds their work.
186
+ binding.lastEdited = readPageValue(el, binding.prop, 'history')
187
+ acceptInlineTextChange(ctx, binding)
188
+ }
189
+ squire.addEventListener('input', onInput)
190
+ binding.detachInput = () => squire.removeEventListener?.('input', onInput)
191
+ }
192
+
193
+ // Blur closes an edit session: one undo primitive covering everything
194
+ // typed since the bind (or since the last blur).
195
+ const onBlur = () => recordUndo(binding)
196
+ el.addEventListener('blur', onBlur)
197
+ binding.detachBlur = () => el.removeEventListener('blur', onBlur)
198
+
199
+ return binding
200
+ }
201
+
202
+ return {
203
+ name: 'inline',
204
+ richText,
205
+ ctx: null,
206
+ root: null,
207
+ formRoot: null,
208
+ errorEl: null,
209
+ noticeEl: null,
210
+ handoffEl: null,
211
+ handoffCountEl: null,
212
+
213
+ popEl: null,
214
+
215
+ // Page targets own richclay instances. Popover siblings use their native
216
+ // form controls without mounting another richclay instance per field.
217
+ enhanceFormRichText: false,
218
+
219
+ // The wider upgrade, not the sidebar's. Every text projection this view
220
+ // binds rich text to has to round-trip through @innerHTML, list rows
221
+ // included, and only this one looks inside an array.
222
+ prepareRules(sourceRules) {
223
+ return richText ? upgradeInlineTextRules(sourceRules, pageRoot) : sourceRules
224
+ },
225
+
226
+ bindText(el, path, prop, carry) { return bindText(this.ctx, el, path, prop, carry) },
227
+
228
+ mount(initialData) {
229
+ const ctx = this.ctx
230
+ host = mountInlineHost(doc, opts.theme)
231
+ this.root = host.root
232
+ this.formRoot = host.formRoot
233
+ this.errorEl = host.errorEl
234
+ this.noticeEl = host.noticeEl
235
+ this.handoffEl = host.handoffEl
236
+ this.handoffCountEl = host.handoffCountEl
237
+ this.popEl = host.popEl
238
+ // A body class is a page edit unless it is suppressed, the same reason
239
+ // the sidebar wraps its own mount.
240
+ suppressUndo(() => markSessionOpen(doc))
241
+
242
+ const fragment = buildForm({
243
+ pageRules: ctx.pageRules,
244
+ formRules: ctx.formRules,
245
+ data: initialData,
246
+ doc,
247
+ })
248
+ host.formRoot.appendChild(fragment)
249
+ ctx.seeder.seed(host.formRoot)
250
+ enhanceFields(host.formRoot, doc, this.enhanceFormRichText)
251
+ applyUnresolvedState(ctx)
252
+
253
+ bindEvents(ctx)
254
+
255
+ layer = createInlineLayer({
256
+ doc,
257
+ layerEl: host.layerEl,
258
+ // The handle and the page click are two doors to one room.
259
+ onActivate: (target, handle) => this.activate(target, handle),
260
+ onListAction: (op) => this.listAction(op),
261
+ })
262
+ layer.setFollower(() => this.placePopover())
263
+ host.closeEl.addEventListener('click', () => this.deactivate())
264
+ host.toggleEl.addEventListener('click', (event) => {
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ this.toggleControls()
268
+ })
269
+ // There is no drawer. What a field with no visual representation gets is
270
+ // this one tap out to the view that edits everything.
271
+ // The switch carries the session's options across (hypercms.js open()), so
272
+ // the sidebar comes up on the same page root with the same callbacks.
273
+ host.handoffEl.querySelector('[data-hcms-open-view]').addEventListener('click', (event) => {
274
+ event.preventDefault()
275
+ event.stopPropagation()
276
+ ctx.onViewRequested?.('sidebar')
277
+ })
278
+ this.bindPage()
279
+ this.syncTargets()
280
+ },
281
+
282
+ // Add, remove and reorder, every one of them acting on the FORM row and
283
+ // letting the commit move the page.
284
+ //
285
+ // Moving the page row here instead would corrupt the rollback:
286
+ // captureChildren runs INSIDE applyWithRollback (apply-loop.js:39), after
287
+ // the caller has already mutated, so a failed apply would restore the
288
+ // post-move DOM and the reorder would never come undone. Moving the form row
289
+ // first means the commit snapshots the page container still in its pre-move
290
+ // state. Routing through requestRemove rather than onRemove is the same kind
291
+ // of inheritance: the consent modal an object array raises is that path's,
292
+ // and reimplementing it here would be a second policy to keep in step.
293
+ listAction({ action, list, index, row }) {
294
+ const ctx = this.ctx
295
+ const arrayPath = list.path.join('.')
296
+ const arrayEl = ctx.formRoot.querySelector(`[data-hcms-path="${cssEscape(arrayPath)}"]`)
297
+ if (!arrayEl) return
298
+ if (action === 'add') {
299
+ onAdd(arrayPath, ctx)
300
+ return
301
+ }
302
+ // The strip was stamped with the index its row held when the layer was
303
+ // last built, and a refresh trails a structural change by an observer
304
+ // batch. Re-resolve from the row element so a second click inside that
305
+ // window acts on the row under the pointer rather than on whatever has
306
+ // since taken its number.
307
+ const rowIndex = row ? liveRowIndex(ctx, arrayPath, row) : index
308
+ // A row that is no longer in the list was removed by the click before this
309
+ // one, and its strip is still on screen because the refresh trails the
310
+ // change. There is nothing to act on, and the number it was stamped with
311
+ // now belongs to the row that moved up into its place, so doing nothing is
312
+ // the only correct outcome.
313
+ if (rowIndex === -1) return
314
+ const formRow = formRowAt(arrayEl, rowIndex)
315
+ if (!formRow) return
316
+ if (action === 'remove') {
317
+ requestRemove(formRow, ctx)
318
+ // The commit has already moved the page and the form. Reconcile now
319
+ // rather than on the debounced refresh: until this runs, every binding
320
+ // below the change still holds the numeric path it had before it, and a
321
+ // keystroke inside that window commits into the wrong row.
322
+ this.syncTargets()
323
+ return
324
+ }
325
+ onMove(formRow, action === 'move-up' ? -1 : 1, ctx)
326
+ this.syncTargets()
327
+ },
328
+
329
+ toggleControls() {
330
+ if (!layer || !host) return
331
+ const hidden = !layer.controlsHidden
332
+ layer.setControlsHidden(hidden)
333
+ host.toggleEl.setAttribute('aria-pressed', String(hidden))
334
+ const label = host.toggleEl.querySelector('.mirk-button__label')
335
+ if (label) label.textContent = hidden ? 'Show controls' : 'Hide controls'
336
+ },
337
+
338
+ // Discovery and activation, delegated on the page root rather than bound
339
+ // per target: the target set is rebuilt on every refresh, and per-element
340
+ // listeners would have to be torn down and reattached with it.
341
+ bindPage() {
342
+ const root = this.ctx.pageRoot
343
+ const hostRoot = host.root
344
+
345
+ const onPointerOver = (event) => {
346
+ const target = layer && layer.elementToTarget(event.target)
347
+ if (target) layer.showHighlight(target.el)
348
+ else layer?.hideHighlight()
349
+ // One pointer path, two questions. The second: a list row too small to
350
+ // hold its own strip shows it only while the pointer is on that row,
351
+ // and the raw element is what answers that — the strip is drawn over
352
+ // its row and is not a target, so a resolved target cannot say so.
353
+ layer?.setHoveredRow(event.target)
354
+ }
355
+ const onPointerLeave = () => {
356
+ layer?.hideHighlight()
357
+ layer?.setHoveredRow(null)
358
+ }
359
+
360
+ // Bind on the press, not on the click. The browser places the caret as the
361
+ // default action of mousedown, and it can only do that if the element is
362
+ // already editable by then. Binding on click is one step too late, which is
363
+ // why the caret used to land at the start of the text however far into it
364
+ // the person pressed. Nothing here focuses: the browser's own default
365
+ // action does that, and it is the only thing that knows the press
366
+ // coordinates.
367
+ const onPointerDown = (event) => {
368
+ if (hostRoot.contains(event.target)) return
369
+ const target = layer && layer.elementToTarget(event.target)
370
+ if (!target || target.kind !== 'text') return
371
+ if (bindings.has(target.el)) return
372
+ bindText(this.ctx, target.el, target.path.join('.'), projectionOf(target))
373
+ }
374
+
375
+ const onClick = (event) => {
376
+ // The editor's own chrome. A handle already activates through its own
377
+ // listener, and a click in the popover must not close it.
378
+ if (hostRoot.contains(event.target)) return
379
+ const target = layer && layer.elementToTarget(event.target)
380
+ if (!target) {
381
+ this.deactivate()
382
+ return
383
+ }
384
+ // A text target is already bound and focused by the pointerdown above,
385
+ // so this only has to leave its default alone. A link inside one is the
386
+ // exception: while the session is open, following it would navigate away
387
+ // mid-edit.
388
+ const inLink = typeof event.target.closest === 'function' && event.target.closest('a[href]')
389
+ if (inLink || target.kind !== 'text') event.preventDefault()
390
+ this.activate(target)
391
+ }
392
+
393
+ const onKeyDown = (event) => {
394
+ if (event.key === 'Escape') this.deactivate()
395
+ }
396
+
397
+ // A commit naming this element's path means something wrote content to it,
398
+ // and the pre-bind markup must never go back over that. richclay's
399
+ // rewriting commits nothing, which is precisely what makes this the right
400
+ // signal and the text comparison it replaces the wrong one: that could not
401
+ // tell an API write that kept the same words from the editor's own work.
402
+ //
403
+ // On the document because the event is dispatched on the host, which does
404
+ // not sit inside the page root. detail.pageRoot is what the payload
405
+ // carries it for.
406
+ const onChanged = (event) => {
407
+ const detail = event.detail
408
+ if (!detail || detail.pageRoot !== this.ctx.pageRoot || !detail.path) return
409
+ for (const binding of bindings.values()) {
410
+ if (binding.path === detail.path) binding.written = true
411
+ }
412
+ }
413
+
414
+ root.addEventListener('pointerover', onPointerOver)
415
+ root.addEventListener('pointerleave', onPointerLeave)
416
+ root.addEventListener('pointerdown', onPointerDown)
417
+ root.addEventListener('click', onClick)
418
+ hostRoot.addEventListener('keydown', onKeyDown)
419
+ doc.addEventListener('hcms:change', onChanged)
420
+
421
+ unbindPage = () => {
422
+ root.removeEventListener('pointerover', onPointerOver)
423
+ root.removeEventListener('pointerleave', onPointerLeave)
424
+ root.removeEventListener('pointerdown', onPointerDown)
425
+ root.removeEventListener('click', onClick)
426
+ hostRoot.removeEventListener('keydown', onKeyDown)
427
+ doc.removeEventListener('hcms:change', onChanged)
428
+ }
429
+ },
430
+
431
+ // Reveal this target's form fields over the target. Nothing moves in
432
+ // or out of the form tree — the leaf is shown where it already lives — so
433
+ // engine.extract(formRoot) can never be missing a path.
434
+ activate(target, handle) {
435
+ if (!target || !host) return
436
+ // A text target is edited where it sits, and only falls through to the
437
+ // popover when it cannot be: no richclay on the page, or a root richclay
438
+ // refuses. Without the fall-through, clicking such a target would do
439
+ // nothing at all.
440
+ if (target.kind === 'text' && this.activateText(target)) return
441
+
442
+ const leaf = revealPath(this, host, target.path.join('.'))
443
+ if (!leaf) {
444
+ this.deactivate()
445
+ return
446
+ }
447
+
448
+ activeTarget = target
449
+ activeHandle = handle || null
450
+ popMode = null
451
+
452
+ // Unhide BEFORE measuring. A hidden popover has a zero rect, which places
453
+ // it at the clamp instead of beside its anchor — the same trap as the 0px
454
+ // textarea below, which was sized while the popover had no height.
455
+ host.popEl.hidden = false
456
+ host.formRoot.querySelectorAll(`.${ACTIVE_CLASS}`).forEach(autosizeIn)
457
+ this.placePopover()
458
+ focusFirst(leaf)
459
+ },
460
+
461
+ // Bind richclay to the page element and edit the text where it sits.
462
+ // Returns false when the target cannot be bound, which is the caller's
463
+ // signal to open the popover over it instead.
464
+ //
465
+ // On demand, never at mount: an instance per text target on a page with
466
+ // twenty of them is twenty Squire editors, twenty toolbars and a hundred
467
+ // document-level listeners for one heading someone might click.
468
+ activateText(target) {
469
+ const el = target.el
470
+ const existing = bindings.get(el)
471
+ if (existing) {
472
+ focusEditor(existing)
473
+ return true
474
+ }
475
+
476
+ const binding = bindText(this.ctx, el, target.path.join('.'), projectionOf(target))
477
+ if (!binding) return false
478
+
479
+ focusEditor(binding)
480
+ return true
481
+ },
482
+
483
+ placePopover() {
484
+ if (!host || !activeTarget || host.popEl.hidden) return
485
+ const win = doc.defaultView
486
+ if (!win) return
487
+ const viewport = { width: win.innerWidth, height: win.innerHeight }
488
+ const anchor = activeTarget.el.getBoundingClientRect()
489
+ const bar = host.popEl.getBoundingClientRect()
490
+ const { mode, x, y } = place({ anchor, bar, viewport, current: popMode })
491
+ popMode = mode
492
+ host.popEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`
493
+ },
494
+
495
+ deactivate() {
496
+ if (!host || (!activeTarget && host.popEl.hidden)) return
497
+ host.popEl.hidden = true
498
+ clearPathClasses(host.root)
499
+ const handle = activeHandle
500
+ activeTarget = null
501
+ activeHandle = null
502
+ popMode = null
503
+ if (handle && doc.contains(handle) && typeof handle.focus === 'function') {
504
+ try { handle.focus({ preventScroll: true }) } catch (_) { handle.focus() }
505
+ }
506
+ },
507
+
508
+ // Re-resolve every editable thing on the page and hand the current set to
509
+ // the layer. Runs on mount and on every refresh, because a refresh can add
510
+ // or remove rows and can rebind the rules (refresh.js re-runs prepareRules).
511
+ syncTargets() {
512
+ if (!layer) return
513
+ const ctx = this.ctx
514
+ const { targets, lists } = resolveTargets(ctx.pageRoot, ctx.pageRules)
515
+ layer.setTargets(targets, lists)
516
+ const byEl = reconcileBindings(this, bindings, targets, doc)
517
+ // An open popover holds its path the same way a binding does. Re-point it
518
+ // at the target that now stands for the element it is anchored over, or
519
+ // the popover keeps editing the row that inherited its old index.
520
+ if (activeTarget) {
521
+ const candidates = byEl.get(activeTarget.el) || []
522
+ const fresh = candidates.find((target) => sameTargetBinding(target, activeTarget)) ||
523
+ candidates.find((target) => target.path.join('.') === activeTarget.path.join('.')) ||
524
+ (candidates.length === 1 ? candidates[0] : null)
525
+ // Gone from the rules, so the popover would keep writing its old path
526
+ // from an element nothing reads. Close it rather than leave it editing a
527
+ // field that is not there any more.
528
+ if (fresh) activeTarget = fresh
529
+ else this.deactivate()
530
+ }
531
+ sweepOrphanEditors(this.ctx, bindings, doc)
532
+ // Every ruled field with no anchorable element, whatever its kind: a
533
+ // permanently hidden metadata span and a target inside a closed tab are
534
+ // byte-identical to the browser, so this is a live count and not a
535
+ // classification. Read off the same floor the handles clear rather than a
536
+ // second rule that could disagree with it.
537
+ const away = targets.reduce((total, target) => total + (isAnchorable(target.el) ? 0 : 1), 0)
538
+ if (this.handoffEl) {
539
+ this.handoffCountEl.textContent = away === 0
540
+ ? ''
541
+ : `${away} ${away === 1 ? "field isn't" : "fields aren't"} visible right now.`
542
+ // At zero the bar says nothing at all, rather than "0 fields".
543
+ this.handoffEl.hidden = away === 0
544
+ }
545
+ },
546
+
547
+ // `changes` is the mutation batch behind an 'observer' refresh. The inline
548
+ // view will need it to recognise its own edits; nothing reads it yet, and a
549
+ // view must treat an absent batch as "refresh everything".
550
+ refresh(reason, changes) {
551
+ if (reason === 'livesync') {
552
+ // The stylesheet lives in <head> and the session class on <body>, both
553
+ // outside this host, so a full-document morph can strip either. The page
554
+ // shift is still the sidebar's alone: this view adds no geometry class.
555
+ reensureStyles(doc)
556
+ markSessionOpen(doc)
557
+ refreshForm(this.ctx, { ignoreActiveValue: true })
558
+ } else if (reason === 'undo') {
559
+ refreshForm(this.ctx, { ignoreActiveValue: false })
560
+ } else {
561
+ refreshForm(this.ctx)
562
+ }
563
+ this.syncTargets()
564
+ // Both reasons put a morph over the page, and a morph takes the editor's
565
+ // attributes off every element this view has bound.
566
+ if (reason === 'livesync' || reason === 'undo') this.rebindText(reason)
567
+ this.restoreActive()
568
+ },
569
+
570
+ // A live-sync morph re-syncs the page against the incoming copy, and this
571
+ // view's own snapshot hook is what makes that copy clean: contenteditable,
572
+ // data-richclay, no-undo and the bound marker all come back off an element
573
+ // a live editor is still pointing at. An undo replaying those same
574
+ // attribute writes in reverse does the same thing. Repair the pairing after
575
+ // the morph, for the reason restoreActive repairs the popover after it.
576
+ rebindText(reason) {
577
+ // On the undo path the value did not change because anyone typed: the undo
578
+ // itself reverted it. Recording that would push a primitive describing the
579
+ // undo's own effect, and a fresh record clears the redo stack, so the
580
+ // person who just pressed undo could not press redo. Measured against the
581
+ // recorder, which fires its handlers outside its own pause.
582
+ const record = reason !== 'undo'
583
+ for (const [el, binding] of [...bindings]) {
584
+ // Replaced outright: no key matched, so the incoming element took this
585
+ // one's place. What they typed before the sync is already committed and
586
+ // still belongs on the undo stack, but the node it was typed into is off
587
+ // the page — nothing is put back onto it, and the incoming markup stands.
588
+ if (!doc.contains(el)) {
589
+ unbind(this.ctx, binding, { restore: false, record })
590
+ bindings.delete(el)
591
+ continue
592
+ }
593
+ if (el.hasAttribute(BOUND_ATTR)) {
594
+ const current = readPageValue(el, binding.prop)
595
+ // An undo or a sync that did not reach this element leaves its value
596
+ // exactly as the person typed it, and their edit is still pending.
597
+ // Only an element the change actually moved needs a fresh baseline,
598
+ // and only then is what they typed gone with it.
599
+ if (current !== binding.lastEdited) {
600
+ // Survived with the marker intact, so the editor is still live and
601
+ // still correct; only the value moved under it. Its baseline is now
602
+ // the value the morph reverted away from, and left stale the next
603
+ // blur records that revert as a fresh edit — which clears the redo
604
+ // stack, so the person who pressed undo cannot press redo.
605
+ binding.oldValue = current
606
+ // The value they had typed is gone with the undo, so it must not
607
+ // survive as a pending edit: left set, the next blur would record it
608
+ // against the new baseline and clear the redo stack, which is the
609
+ // defect the line above exists to prevent, arriving one step later.
610
+ binding.lastEdited = undefined
611
+ }
612
+ continue
613
+ }
614
+ // Survived, but the page no longer treats it as ours. Close the session
615
+ // that was open, one primitive for what they typed, and open a fresh one
616
+ // on the markup as it now stands. Deliberately no restore: the element
617
+ // holds the incoming copy's markup, and putting a pre-morph snapshot
618
+ // back over it would undo somebody else's edit.
619
+ const hadFocus = doc.activeElement === el
620
+ unbind(this.ctx, binding, { restore: false, record })
621
+ bindings.delete(el)
622
+ const rebound = bindText(this.ctx, el, binding.path, binding.prop, {
623
+ richClayIsOurs: binding.richClayIsOurs,
624
+ adopted: binding.adopted,
625
+ })
626
+ // Only the element they were actually in: a sync that arrives while
627
+ // someone is typing must not pull the caret into a different one.
628
+ if (rebound && hadFocus) focusEditor(rebound)
629
+ }
630
+ },
631
+
632
+ // morphForm re-syncs every field from a freshly built form, and that form
633
+ // carries no reveal classes, so an open popover goes blank while still
634
+ // sitting there open. Measured in a browser: the leaf keeps its identity
635
+ // and loses only its class, which is why nothing here rebuilds anything.
636
+ // The same reason enhanceFields and applyErrorState re-run after the morph
637
+ // in refresh.js.
638
+ restoreActive() {
639
+ if (!host || !activeTarget || host.popEl.hidden) return
640
+ // The refresh can take the page element out from under an open popover:
641
+ // a live-sync that deletes the row being edited, say. Its rect would then
642
+ // measure zero and the popover would place against the viewport clamp.
643
+ if (!doc.contains(activeTarget.el)) {
644
+ this.deactivate()
645
+ return
646
+ }
647
+ const leaf = revealPath(this, host, activeTarget.path.join('.'))
648
+ if (!leaf) {
649
+ this.deactivate()
650
+ return
651
+ }
652
+ // After the classes, never before: a textarea measured while its wrapper
653
+ // is still display:none reports a zero scrollHeight and sizes to nothing.
654
+ host.formRoot.querySelectorAll(`.${ACTIVE_CLASS}`).forEach(autosizeIn)
655
+ this.placePopover()
656
+ // Deliberately no focusFirst: a refresh can land mid-typing, and moving
657
+ // the caret back to the top of the field on every page mutation would
658
+ // make the field unusable.
659
+ },
660
+
661
+ // Deliberately NOT a page field. Focusing one would scroll the page and
662
+ // begin an edit nobody asked for. Focusing the host itself puts the next Tab
663
+ // into the session bar without moving the page or starting anything.
664
+ focusOnOpen() {
665
+ if (this.root && typeof this.root.focus === 'function') {
666
+ try { this.root.focus({ preventScroll: true }) } catch (_) { this.root.focus() }
667
+ }
668
+ },
669
+
670
+ destroy() {
671
+ // Dropped before the close, not restored to: the handle is about to be
672
+ // removed with the layer, and focusing it on the way out would fight the
673
+ // session's own restore of whatever had focus before it opened.
674
+ activeHandle = null
675
+ this.deactivate()
676
+ for (const binding of bindings.values()) unbind(this.ctx, binding)
677
+ bindings.clear()
678
+ unbindPage?.()
679
+ unbindPage = null
680
+ layer?.destroy()
681
+ layer = null
682
+ host?.destroy()
683
+ host = null
684
+ clearSessionOpen(doc)
685
+ this.popEl = null
686
+ this.handoffEl = null
687
+ this.handoffCountEl = null
688
+ },
689
+ }
690
+ }
691
+
692
+ // Build the editor, or answer null when this element cannot carry one.
693
+ //
694
+ // RichClay writes contenteditable, data-richclay and a class onto an authored
695
+ // page element. Undo suppression keeps those runtime attributes off the undo
696
+ // stack. The mutation observer can schedule one refresh, which must converge.
697
+ function construct(ctx, RichClay, el, prop, richClayIsOurs) {
698
+ return suppressUndo(() => {
699
+ let editor = null
700
+ try {
701
+ editor = new RichClay(el, {
702
+ inline: true,
703
+ hyperclay: false,
704
+ // Formatting is offered only where the rule can keep it. A textContent
705
+ // projection commits the element's text, so a bold applied through this
706
+ // toolbar would be flattened by the very next commit — the button would
707
+ // be promising something the rule cannot hold. That is every scalar
708
+ // array row (enhance.js never upgrades a "[]" rule) and everything at
709
+ // all under richText: false. richclay reads false as "no toolbar"
710
+ // (resolveToolbarControls, richclay.js:1007).
711
+ toolbar: prop === 'innerHTML' ? TOOLBAR : false,
712
+ // A heading is one line by definition; without this, Enter in it
713
+ // creates a second block inside the <h1>.
714
+ ...(HEADING.test(el.tagName) ? { singleLine: true } : null),
715
+ })
716
+ } catch (err) {
717
+ console.warn('[hypercms] richclay activation failed; the field falls back to the popover', err)
718
+ return null
719
+ }
720
+ // richclay's own verdict, which it delivers by returning an instance that
721
+ // never activates: TABLE and its row parts, SCRIPT/STYLE/TEXTAREA/TITLE/
722
+ // IFRAME/NOSCRIPT/XMP, TEMPLATE, and anything outside the HTML namespace.
723
+ // Without this check the person clicks one of those and nothing happens.
724
+ if (editor.unsupported || !editor.active) {
725
+ // Only tear down an instance this call is responsible for. On an
726
+ // element the author opted in on, richclay owns the mount and the
727
+ // instance is theirs; destroying it takes away an editor hypercms
728
+ // did not create and cannot put back.
729
+ if (richClayIsOurs) { try { editor.destroy() } catch (_) {} }
730
+ return null
731
+ }
732
+ // An editor can be active while its element is inert. A live-sync morph
733
+ // strips contenteditable and the runtime attributes off the element and
734
+ // leaves the instance believing it never stopped, so on the rebind that
735
+ // follows, this call is the only thing that makes the element editable
736
+ // again. reattach answers for itself whether anything needs doing, so a
737
+ // healthy editor costs one comparison. Absent on an older richclay, where
738
+ // the element stays inert and there is nothing hypercms can do about it.
739
+ if (typeof editor.reattach === 'function') editor.reattach()
740
+ return editor
741
+ })
742
+ }
743
+
744
+ // Mirror the page element into the form leaf, then commit like any other edit.
745
+ //
746
+ // Per change, not on blur. commit IS the notification — it dispatches
747
+ // hcms:change and calls onChange — so a blur-only commit would lose the edit's
748
+ // own event to whatever unrelated commit folded the text in first, and then be
749
+ // skipped on its fingerprint. The form leaf has to be written first or the
750
+ // commit extracts the stale one and puts the old text back on the page.
751
+ function acceptInlineTextChange(ctx, { path, el, prop }) {
752
+ const field = findLeafField(ctx.formRoot, path)
753
+ if (!field) return
754
+ writeFieldValue(field, readPageValue(el, prop), ctx.formRoot, path)
755
+ commit(extractFormData(ctx), { path, structural: false }, ctx)
756
+ }
757
+
758
+ // What the engine will read back out of this element. The DOM adapter trims a
759
+ // text projection (hyper-html-api dom.js text()), so a raw textContent read puts
760
+ // a value in the form that the page can never extract, the mirror disagrees on
761
+ // every commit, and the engine rewrites the text node the caret is sitting in.
762
+ function readPageValue(el, prop, capability = 'data') {
763
+ const view = createContentView(el, { capability })
764
+ return prop === 'innerHTML' ? view.html() : view.text().trim()
765
+ }
766
+
767
+ // One primitive per edit session, at the boundary that closes it. Everything in
768
+ // between is invisible to the page's undo stack: richclay stamps no-undo on
769
+ // what it activates and hyper-undo honours it.
770
+ //
771
+ // Unlike resolveUnobservedProjection this can record an array row, because the
772
+ // element is in hand rather than resolved from a document-level selector.
773
+ function recordUndo(binding) {
774
+ const { el, prop, oldValue, lastEdited } = binding
775
+ // Deliberately not the element. A live-sync morph replaces the content before
776
+ // this boundary is reached, so reading the DOM here records a peer's edit
777
+ // under this person's name and undo reverts THEIR change. No local edit means
778
+ // no primitive at all, which is the same fix from the other side: an untouched
779
+ // binding that a morph wrote through must not put anything on the stack.
780
+ const newValue = lastEdited
781
+ if (newValue === undefined || newValue === oldValue) return
782
+ binding.oldValue = newValue
783
+ const u = platform('undo')
784
+ if (!u || typeof u.recordValue !== 'function') return
785
+ // hyper-undo drops recordValue while its recorder is paused (scope.js:190),
786
+ // and both boundaries that close a binding sit inside a pause somebody else
787
+ // opened: teardownSession suppresses around destroy (session.js:225), and
788
+ // clayjs dispatches clay:sync-applied inside its mutation pause, which pauses
789
+ // undo too (live-sync.js:1357, mutation.js:132). The values are read here and
790
+ // the record is posted after those finally-blocks have run, so the edit keeps
791
+ // its place on the stack instead of vanishing at the moment it ends.
792
+ //
793
+ // A record against an element the morph took off the page would be an entry
794
+ // that replays into nothing and still costs an undo press, so it is skipped.
795
+ const options = {
796
+ prop,
797
+ oldValue,
798
+ newValue,
799
+ read: target => readPageValue(target, prop, 'history'),
800
+ write: (target, value) => replaceContent(target, prop, value),
801
+ }
802
+ if (u.isPaused) queueMicrotask(() => { if (el.isConnected) u.recordValue(el, options) })
803
+ else u.recordValue(el, options)
804
+ }
805
+
806
+ function replaceContent(target, prop, value) {
807
+ const source = target.cloneNode(false)
808
+ if (prop === 'innerHTML') source.innerHTML = value
809
+ else source.textContent = value
810
+ morph(target, Array.from(source.childNodes), {
811
+ morphStyle: 'innerHTML',
812
+ policy: 'history',
813
+ restoreFocus: false,
814
+ scripts: { handle: false, merge: false },
815
+ })
816
+ }
817
+
818
+ // Close one binding. The undo record comes first because teardown suppresses
819
+ // undo while it removes editor runtime state.
820
+ //
821
+ // `restore` is what separates ending a session from repairing one. Ending it
822
+ // puts the author's markup back when nobody typed; repairing after a morph must
823
+ // not, because the markup on the element is the incoming copy's and a pre-morph
824
+ // snapshot written back over it would undo somebody else's edit.
825
+ function unbind(ctx, binding, { restore = true, record = true } = {}) {
826
+ if (record) recordUndo(binding)
827
+ binding.detachInput?.()
828
+ binding.detachBlur?.()
829
+ suppressUndo(() => {
830
+ if (!binding.adopted) {
831
+ try { binding.editor.destroy() } catch (err) {
832
+ console.warn('[hypercms] richclay teardown failed; editor state may reach the save', err)
833
+ }
834
+ // destroy() takes data-richclay off only on richclay 0.5.0, which tracks
835
+ // who wrote it. Neither vendored 0.4.0 copy removes that attribute
836
+ // anywhere, so without this the mount selector stays on the live element
837
+ // and the next save carries it into the author's file, exactly what the
838
+ // clone-side removal exists to prevent. Never on an element the author
839
+ // opted in on: there the attribute is theirs.
840
+ if (binding.richClayIsOurs) binding.el.removeAttribute('data-richclay')
841
+ }
842
+ // The bridge only unmarks the snapshot clone. Left on the live element,
843
+ // the marker would tell the next snapshot to strip an element hypercms no
844
+ // longer owns, and the provenance flag beside it would reach the file.
845
+ binding.el.removeAttribute(BOUND_ATTR)
846
+ binding.el.removeAttribute(RC_OWNED_ATTR)
847
+ binding.el.removeAttribute(BOUND_ID_ATTR)
848
+ releaseBinding(binding.boundId)
849
+ // Nobody typed, so the only difference between this element and the one
850
+ // the author wrote is the editor's own normalisation. Put their markup
851
+ // back. Undo suppression keeps the restore from becoming an edit.
852
+ if (restore && binding.restorable()) {
853
+ replaceContent(binding.el, 'innerHTML', binding.originalHTML)
854
+ }
855
+ })
856
+ }
857
+
858
+ // The click that opens a text target lands before the element is editable, so
859
+ // the browser has put no caret in it. richclay's focus() goes through squire,
860
+ // which puts the caret in the text rather than selecting the whole region.
861
+ function focusEditor(binding) {
862
+ const { editor, el } = binding
863
+ if (el.ownerDocument.activeElement === el) return
864
+ try {
865
+ if (typeof editor.focus === 'function') editor.focus()
866
+ else el.focus()
867
+ } catch (_) {}
868
+ }
869
+
870
+ // resolveTargets re-walks the page on every refresh, so it is the only authority
871
+ // on which path reaches which element right now. Both a binding and an open
872
+ // popover cache a path from the moment they were made: a move or a remove
873
+ // renumbers every row after it, and without this, typing into the row you just
874
+ // moved up writes into the row that took its old index.
875
+ //
876
+ // The projection is re-read for the same reason. refreshForm re-runs
877
+ // prepareRules every refresh, so a rule upgrades to @innerHTML the moment its
878
+ // element gains markup. A binding frozen on textContent would flatten every tag
879
+ // in that element on the next keystroke, including the node under the caret.
880
+ function reconcileBindings(view, bindings, targets, doc) {
881
+ const byEl = new Map()
882
+ for (const target of targets) {
883
+ const candidates = byEl.get(target.el)
884
+ if (candidates) candidates.push(target)
885
+ else byEl.set(target.el, [target])
886
+ }
887
+
888
+ for (const [el, binding] of [...bindings]) {
889
+ const candidates = byEl.get(el) || []
890
+ const target = candidates.find((candidate) => projectionOf(candidate) === binding.prop) || candidates[0]
891
+ if (!target) {
892
+ // The rules no longer name this element. Left bound it stays editable with
893
+ // nowhere of its own to write, and every keystroke commits the old path,
894
+ // so the text lands in whatever element the rule now points at. End the
895
+ // session on it instead. restore is on because nobody has taken the
896
+ // element away: if it was never typed into, the author's markup goes back.
897
+ unbind(view.ctx, binding, { restore: true })
898
+ bindings.delete(el)
899
+ continue
900
+ }
901
+ binding.path = target.path.join('.')
902
+ const prop = projectionOf(target)
903
+ if (prop === binding.prop) continue
904
+ // The projection changed under a live editor. Rebuilding it is the only way
905
+ // the toolbar, the commit and the undo baseline end up describing the same
906
+ // half of the element.
907
+ const hadFocus = doc.activeElement === el
908
+ unbind(view.ctx, binding, { restore: false })
909
+ bindings.delete(el)
910
+ const rebound = view.bindText(el, binding.path, prop, {
911
+ richClayIsOurs: binding.richClayIsOurs,
912
+ adopted: binding.adopted,
913
+ originalHTML: binding.originalHTML,
914
+ })
915
+ if (rebound && hadFocus) focusEditor(rebound)
916
+ }
917
+
918
+ return byEl
919
+ }
920
+
921
+ function sameTargetBinding(left, right) {
922
+ return left.el === right.el && left.kind === right.kind && left.attr === right.attr
923
+ }
924
+
925
+ // A text target is either a bare rule (textContent) or one prepareRules rebound
926
+ // to @innerHTML. Nothing else is ever text.
927
+ function projectionOf(target) {
928
+ return target.attr === 'innerHTML' ? 'innerHTML' : 'textContent'
929
+ }
930
+
931
+ // Runs on every sync, because a structural apply is exactly when the engine
932
+ // clones, and a refresh is what follows one.
933
+ function sweepOrphanEditors(ctx, bindings, doc) {
934
+ for (const el of ctx.pageRoot.querySelectorAll(`[${BOUND_ATTR}]`)) {
935
+ if (bindings.has(el)) continue
936
+ suppressUndo(() => stripOrphanEditorState(el, doc.defaultView))
937
+ }
938
+ }
939
+
940
+ // Keep the complete form mounted for extraction, but reveal only the selected
941
+ // field and its item siblings. Nested lists keep their own editing scope.
942
+ function revealPath(view, host, path) {
943
+ const leaf = view.formRoot &&
944
+ view.formRoot.querySelector(`[data-hcms-path="${cssEscape(path)}"]`)
945
+ clearPathClasses(host.root)
946
+ if (!leaf) return null
947
+ const item = leaf.closest('[data-hcms-card]')
948
+ const fields = item
949
+ ? [...item.querySelectorAll('[data-hcms-shape="scalar"][data-hcms-path]')]
950
+ .filter((field) => field.closest('[data-hcms-card]') === item &&
951
+ field.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]') === item.parentElement.closest('[data-hcms-shape="object-array"]'))
952
+ : [leaf]
953
+ if (!fields.includes(leaf)) fields.push(leaf)
954
+ for (const field of fields) {
955
+ field.classList.add(ACTIVE_CLASS)
956
+ field.removeAttribute('draggable')
957
+ for (let el = field.parentElement; el; el = el.parentElement) {
958
+ el.classList.add(ONPATH_CLASS)
959
+ el.removeAttribute('draggable')
960
+ if (el === view.formRoot) break
961
+ }
962
+ }
963
+ return leaf
964
+ }
965
+
966
+ function clearPathClasses(root) {
967
+ if (!root) return
968
+ for (const el of root.querySelectorAll(`.${ACTIVE_CLASS}, .${ONPATH_CLASS}`)) {
969
+ el.classList.remove(ACTIVE_CLASS, ONPATH_CLASS)
970
+ }
971
+ }
972
+
973
+ // Every textarea in the revealed leaf was measured while the popover was
974
+ // hidden, so it is sitting at height: 0 until it is sized again.
975
+ function autosizeIn(leaf) {
976
+ if (leaf.tagName === 'TEXTAREA') autosizeTextarea(leaf)
977
+ leaf.querySelectorAll?.('textarea').forEach(autosizeTextarea)
978
+ }
979
+
980
+ function focusFirst(leaf) {
981
+ const el = leaf.matches?.(FOCUSABLE) ? leaf : leaf.querySelector?.(FOCUSABLE)
982
+ if (!el || typeof el.focus !== 'function') return
983
+ try { el.focus({ preventScroll: true }) } catch (_) { el.focus() }
984
+ }
985
+
986
+ function mountInlineHost(doc, theme) {
987
+ // reensureStyles is idempotent: it returns immediately when the stylesheet is
988
+ // already in the document and installs it when it is not, so it doubles as the
989
+ // "ensure" the sidebar's private ensureStyles provides.
990
+ reensureStyles(doc)
991
+
992
+ const root = doc.createElement(HOST_TAG)
993
+
994
+ // Same theme contract as the sidebar: pixel-quiet is the baked-in look and an
995
+ // optional theme pins light/dark. hcms-shell is what the tokens and every
996
+ // mirk widget rule are scoped to, so without it the popover's fields render
997
+ // as browser defaults; hcms-inline is what keeps the docked-panel geometry
998
+ // off, since that is now scoped to .hcms-panel.
999
+ const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
1000
+ root.className = 'hcms-shell pixel-quiet hcms-inline' + themeClass
1001
+
1002
+ // The engine skip selector. Every read and write in hypercms passes
1003
+ // { skip: '[data-hcms-shell]' }, so this one attribute is what keeps the
1004
+ // engine from walking into the form and treating the CMS's own fields as page
1005
+ // content. Reused rather than renamed: it is already honored in seven places
1006
+ // across six files, and each of them covers this host for free.
1007
+ root.setAttribute('data-hcms-shell', '')
1008
+ root.setAttribute('editor-ui', '')
1009
+
1010
+ // Both spellings of "never persist this", because the two clients each have a
1011
+ // path that knows only one of them. clayjs strips [no-save] and [save-remove]
1012
+ // alike (region-policy.js:82), but hyperclayjs's AI-edit clone path queries
1013
+ // [save-remove] exclusively (ai-edit.js:151, :249), so the modern spelling on
1014
+ // its own would leave the whole editor sitting in that clone.
1015
+ root.setAttribute('no-save', '')
1016
+ root.setAttribute('save-remove', '')
1017
+ // Out of every snapshot, not just the save: without this, live sync ships the
1018
+ // editor to everyone else's browser.
1019
+ root.setAttribute('snapshot-remove', '')
1020
+ // The CMS's own chrome must never wake autosave, undo or dirty tracking.
1021
+ // no-watch implies all three (region-policy.js:184-185), which is why
1022
+ // save-ignore and no-trigger-autosave are NOT here: they would be redundant,
1023
+ // and a redundant attribute is one a later reader mistakes for load-bearing.
1024
+ root.setAttribute('no-watch', '')
1025
+
1026
+ // Focusable for focusOnOpen, but NOT role="dialog" / aria-modal. The sidebar
1027
+ // is modal — it traps focus and locks the page. This one is the opposite: the
1028
+ // page stays fully usable while it is open, which is the entire point.
1029
+ root.setAttribute('tabindex', '-1')
1030
+
1031
+ root.innerHTML = `
1032
+ <div class="hcms-inline-bar">
1033
+ <div class="hcms-inline-handoff" hidden>
1034
+ <span class="hcms-inline-handoff-count"></span>
1035
+ <button type="button" class="hcms-inline-handoff-open mirk-button mirk-button--small" data-hcms-open-view="sidebar">
1036
+ <span class="mirk-button__label">Edit in the sidebar</span>
1037
+ </button>
1038
+ </div>
1039
+ <button type="button" class="hcms-inline-toggle mirk-button mirk-button--small" data-hcms-controls-toggle aria-pressed="false" hidden>
1040
+ <span class="mirk-button__label">Hide controls</span>
1041
+ </button>
1042
+ <div class="hcms-inline-notice" role="status" hidden></div>
1043
+ <div class="hcms-inline-error" role="alert" hidden></div>
1044
+ </div>
1045
+ <div class="hcms-inline-layer"></div>
1046
+ <div class="hcms-inline-pop" role="dialog" aria-label="Edit content" hidden>
1047
+ <div class="hcms-inline-pop-header">
1048
+ <span>Edit content</span>
1049
+ <button type="button" class="hcms-inline-pop-close mirk-button mirk-button--small" aria-label="Close field editor">
1050
+ <span class="mirk-button__label">${inlineIcon('remove')}</span>
1051
+ </button>
1052
+ </div>
1053
+ <div data-hcms-form-root class="hcms-form"></div>
1054
+ </div>
1055
+ `
1056
+
1057
+ doc.body.appendChild(root)
1058
+
1059
+ return {
1060
+ root,
1061
+ formRoot: root.querySelector('[data-hcms-form-root]'),
1062
+ noticeEl: root.querySelector('.hcms-inline-notice'),
1063
+ errorEl: root.querySelector('.hcms-inline-error'),
1064
+ handoffEl: root.querySelector('.hcms-inline-handoff'),
1065
+ handoffCountEl: root.querySelector('.hcms-inline-handoff-count'),
1066
+ layerEl: root.querySelector('.hcms-inline-layer'),
1067
+ popEl: root.querySelector('.hcms-inline-pop'),
1068
+ closeEl: root.querySelector('.hcms-inline-pop-close'),
1069
+ toggleEl: root.querySelector('[data-hcms-controls-toggle]'),
1070
+ destroy() { root.remove() },
1071
+ }
1072
+ }
1073
+
1074
+ // The form row a page row stands for. onMove and requestRemove both take a form
1075
+ // item element and find their array back from it, so this resolves exactly what
1076
+ // they expect to be handed: the array's own items slot, indexed. Deliberately
1077
+ // not a path query — the row's data-hcms-path is restamped by every structural
1078
+ // operation, and the slot's child order is the thing those two read.
1079
+ function formRowAt(arrayEl, index) {
1080
+ const slot = arrayEl.querySelector('.hcms-array-items')
1081
+ if (!slot) return null
1082
+ const rows = slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]')
1083
+ return rows[index] || null
1084
+ }
1085
+
1086
+ // Where this page row sits in its list right now, or -1 when the row is no
1087
+ // longer in it (a remove that already landed).
1088
+ function liveRowIndex(ctx, arrayPath, row) {
1089
+ const { lists } = resolveTargets(ctx.pageRoot, ctx.pageRules)
1090
+ const list = lists.find((candidate) => candidate.path.join('.') === arrayPath)
1091
+ return list ? list.items.indexOf(row) : -1
1092
+ }