@panphora/hyper-cms 0.8.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,663 @@
1
+ // Each item has one positioned container for its edit and row-action buttons.
2
+ // List Add controls live in an excluded placeholder after the real rows.
3
+ //
4
+ // Two mechanisms, kept deliberately separate, because merging them was the first
5
+ // draft's mistake (plan §3.1.2 and §3.5). An IntersectionObserver answers "is
6
+ // this anchor showing", and it accounts for an overflow:hidden ancestor clipping
7
+ // an anchor that is still inside the viewport, which no viewport comparison can
8
+ // see. It does NOT fire when a visible element merely moves, so it can never
9
+ // drive placement. Placement is a separate frame-coalesced pass driven by scroll
10
+ // and resize.
11
+ //
12
+ // The list controls live here rather than in a layer of their own so they are
13
+ // placed by that same pass and hidden by that same observer. They carry no
14
+ // knowledge of the form: a click reports which list and which row it belongs to
15
+ // and the view does the rest, because acting on a page row from here would move
16
+ // the page outside the rollback snapshot (inline.js listAction).
17
+
18
+ import { placeHandle } from '../place.js'
19
+ import { isAnchorable } from '../anchor.js'
20
+ import { inlineIcon } from '../inline-icons.js'
21
+ import { createInlineGhosts } from './inline-ghosts.js'
22
+
23
+ // Move actions retain the first/last-row availability rules.
24
+ const ROW_ACTIONS = [
25
+ ['move-up', 'Move up'],
26
+ ['move-down', 'Move down'],
27
+ ]
28
+
29
+ // A list control is pinned INSIDE its anchor's top-right corner. 'corner'
30
+ // because place.js's adaptive rule measures the anchor against the control and
31
+ // sends a small one BESIDE it, which is right for a handle floating near a link
32
+ // and wrong for a strip that belongs to a row: measured in Chrome, every card
33
+ // row shorter than ~47px put its strip wholly outside the card. inset 0 because
34
+ // a handle straddles its corner on purpose and a strip must not — §3.3 asks for
35
+ // a strip pinned to the card's top-right, inside it.
36
+ const CONTROL_PLACEMENT = { prefer: 'corner', inset: 0 }
37
+
38
+ // Measure the complete toolbar, including the pencil, against the row's box.
39
+ function hosts(row, strip) {
40
+ return strip.width <= row.width && strip.height <= row.height
41
+ }
42
+
43
+ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
44
+ const win = doc.defaultView
45
+ // Every positioned thing the layer draws, whatever kind: a handle, a row
46
+ // strip, a list's Add. One array, because all three are placed by one pass.
47
+ let placements = []
48
+ // Anchor element -> the placements riding on it. One element can carry two:
49
+ // in a scalar array of images, every row is also a handle target.
50
+ let index = new Map()
51
+ let handleCount = 0
52
+ // Every resolved target, keyed by the page element it sits on — not just the
53
+ // ones that got a handle. The highlight and the page-level click have to
54
+ // reach a text or a native target too, and neither of those has an entry.
55
+ let targets = new Map()
56
+ let observer = null
57
+ let observedAnchors = new Set()
58
+ let frame = 0
59
+ let listening = false
60
+ let follower = null
61
+ let highlighted = null
62
+ // The rows showing a strip they are too small to host: the one under the
63
+ // pointer, and the one holding focus. Two slots rather than one so a strip
64
+ // does not vanish from under the keyboard while the pointer rests elsewhere;
65
+ // only ever ONE is drawn (hover wins), so two strips can never collide.
66
+ let hoveredRow = null
67
+ let focusedRow = null
68
+ // Row element AND its strip node, both mapped to the row. The strip is drawn
69
+ // over the row it belongs to, so pointing at the strip has to read as
70
+ // pointing at the row or it would hide under the pointer that came for it.
71
+ let rowOwners = new Map()
72
+ // A toggle, not a mode: the strips and the Adds go away and the handles stay,
73
+ // so someone reading a page they are editing can see it without leaving the
74
+ // session. Layer state rather than per-placement state, so it survives the
75
+ // target reconciliation on every refresh.
76
+ let controlsHidden = false
77
+ let openSettings = null
78
+ const ghosts = createInlineGhosts({
79
+ doc,
80
+ themeRoot: layerEl.closest('[data-hcms-shell]'),
81
+ onResize: schedule,
82
+ onAdd: (list) => onListAction?.({ action: 'add', list, index: list.items.length }),
83
+ })
84
+
85
+ // ONE reusable outline, moved to whatever is hovered. Marking each target
86
+ // with an attribute instead would write editor state into an authored
87
+ // element, which is the class of problem richclay-bridge.js exists to undo.
88
+ const highlight = doc.createElement('div')
89
+ highlight.className = 'hcms-inline-highlight'
90
+ highlight.hidden = true
91
+ highlight.setAttribute('aria-hidden', 'true')
92
+ layerEl.appendChild(highlight)
93
+
94
+ function schedule() {
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.
99
+ if (typeof win.requestAnimationFrame !== 'function') return placeAll()
100
+ frame = win.requestAnimationFrame(() => {
101
+ frame = 0
102
+ placeAll()
103
+ })
104
+ }
105
+
106
+ const revealedRow = () => hoveredRow || focusedRow
107
+
108
+ function placeAll() {
109
+ if (!win) return
110
+ ghosts.update()
111
+ const viewport = { width: win.innerWidth, height: win.innerHeight }
112
+ const revealed = revealedRow()
113
+ for (const spot of placements) {
114
+ if (!spot.visible) {
115
+ spot.node.hidden = true
116
+ continue
117
+ }
118
+ for (const member of spot.members) member.node.hidden = controlsHidden && member.kind !== 'handle'
119
+ // hidden must come off BEFORE measuring: a hidden node has a zero rect.
120
+ spot.node.hidden = false
121
+ const anchor = spot.el.getBoundingClientRect()
122
+ let handle = spot.node.getBoundingClientRect()
123
+ const fullWidth = handle.width
124
+ const tooSmall = spot.kind === 'row' && !hosts(anchor, handle)
125
+ // A row too small to hold its strip shows it only while that row is the
126
+ // one being pointed at or typed in. This deviates from §3.3's "always
127
+ // visible", and only for rows that cannot physically hold the control.
128
+ if (tooSmall && spot.el !== revealed) {
129
+ for (const member of spot.members) {
130
+ if (member.kind === 'row') member.node.hidden = true
131
+ }
132
+ handle = spot.node.getBoundingClientRect()
133
+ }
134
+ if (spot.members.every((member) => member.node.hidden)) {
135
+ spot.node.hidden = true
136
+ continue
137
+ }
138
+ const prefer = spot.kind === 'handle' ? null : CONTROL_PLACEMENT
139
+ let { x, y } = placeHandle({ anchor, handle, viewport, ...prefer })
140
+ const pencil = spot.members.find((member) => member.kind === 'handle')
141
+ if (tooSmall && pencil) {
142
+ const pencilBox = pencil.node.getBoundingClientRect()
143
+ const position = placeHandle({ anchor, handle: pencilBox, viewport })
144
+ const afterPencil = spot.members.slice(spot.members.indexOf(pencil) + 1)
145
+ .filter((member) => !member.node.hidden)
146
+ .reduce((width, member) => width + member.node.getBoundingClientRect().width + 2, 0)
147
+ x = Math.max(8 + fullWidth, Math.min(viewport.width - 8, position.x + pencilBox.width + afterPencil)) - handle.width
148
+ y = position.y
149
+ }
150
+ spot.node.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`
151
+ }
152
+ if (openSettings) {
153
+ if (!openSettings.node.isConnected || openSettings.node.closest('[hidden]')) closeSettings()
154
+ else {
155
+ const rect = openSettings.button.getBoundingClientRect()
156
+ const menu = openSettings.menu.getBoundingClientRect()
157
+ openSettings.menu.style.left = `${Math.max(8, rect.right - menu.width) - rect.left}px`
158
+ openSettings.menu.style.right = 'auto'
159
+ openSettings.menu.style.top = rect.bottom + 6 + menu.height > viewport.height - 8 ? 'auto' : 'calc(100% + 6px)'
160
+ openSettings.menu.style.bottom = rect.bottom + 6 + menu.height > viewport.height - 8 ? 'calc(100% + 6px)' : 'auto'
161
+ }
162
+ }
163
+ placeHighlight()
164
+ follower?.()
165
+ }
166
+
167
+ // The row `el` belongs to, walking up from it: one Map lookup per ancestor,
168
+ // the same shape elementToTarget uses for targets.
169
+ function rowAt(el) {
170
+ if (el?.closest?.('[data-hcms-ghost]')) return null
171
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
172
+ const row = rowOwners.get(node)
173
+ if (row) return row
174
+ }
175
+ return null
176
+ }
177
+
178
+ function onFocusIn(event) {
179
+ if (openSettings && !openSettings.node.contains(event.target)) closeSettings()
180
+ const before = revealedRow()
181
+ focusedRow = rowAt(event.target)
182
+ // Focus landing on a strip's own button is the keyboard taking over, so the
183
+ // pointer's row must not out-vote it: otherwise tabbing to ↑ would hide the
184
+ // strip the focus is standing on.
185
+ if (focusedRow && layerEl.contains(event.target)) hoveredRow = null
186
+ if (revealedRow() !== before) schedule()
187
+ }
188
+
189
+ function onFocusOut(event) {
190
+ // The one case focusin cannot answer: focus left for nothing at all, so no
191
+ // later focusin arrives to say which row it is in now.
192
+ if (event.relatedTarget || !focusedRow) return
193
+ const before = revealedRow()
194
+ focusedRow = null
195
+ if (revealedRow() !== before) schedule()
196
+ }
197
+
198
+ // The highlight carries an explicit width/height, so it never measures itself
199
+ // and the hidden-before-measuring trap the handles have does not apply.
200
+ function placeHighlight() {
201
+ if (!highlighted || highlight.hidden) return
202
+ const rect = highlighted.getBoundingClientRect()
203
+ highlight.style.width = `${Math.round(rect.width)}px`
204
+ highlight.style.height = `${Math.round(rect.height)}px`
205
+ highlight.style.transform = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`
206
+ }
207
+
208
+ function syncObservation() {
209
+ if (!win || typeof win.IntersectionObserver !== 'function') {
210
+ for (const spot of placements) spot.visible = true
211
+ return
212
+ }
213
+ if (!observer) {
214
+ observer = new win.IntersectionObserver((records) => {
215
+ let changed = false
216
+ for (const record of records) {
217
+ for (const spot of index.get(record.target) || []) {
218
+ if (spot.visible !== record.isIntersecting) {
219
+ spot.visible = record.isIntersecting
220
+ changed = true
221
+ }
222
+ }
223
+ }
224
+ if (changed) schedule()
225
+ }, { threshold: 0 })
226
+ }
227
+ const next = new Set(index.keys())
228
+ for (const el of observedAnchors) {
229
+ if (!next.has(el)) observer.unobserve(el)
230
+ }
231
+ for (const el of next) {
232
+ if (!observedAnchors.has(el)) observer.observe(el)
233
+ }
234
+ observedAnchors = next
235
+ }
236
+
237
+ function listen() {
238
+ if (listening || !win) return
239
+ // capture, so a scroll inside an overflow container reaches us too. A
240
+ // handle over a target inside a scrolling panel has to track that panel,
241
+ // and a scroll event on it does not bubble to the window.
242
+ win.addEventListener('scroll', schedule, { passive: true, capture: true })
243
+ win.addEventListener('resize', schedule, { passive: true })
244
+ // focusin/focusout, not focus/blur: only these two bubble, and the strip's
245
+ // buttons and the page rows are in different subtrees.
246
+ doc.addEventListener('focusin', onFocusIn)
247
+ doc.addEventListener('focusout', onFocusOut)
248
+ doc.addEventListener('pointerdown', onOutsideSettings, true)
249
+ doc.addEventListener('keydown', onSettingsKeyDown, true)
250
+ listening = true
251
+ }
252
+
253
+ function unlisten() {
254
+ if (!listening || !win) return
255
+ win.removeEventListener('scroll', schedule, { capture: true })
256
+ win.removeEventListener('resize', schedule)
257
+ doc.removeEventListener('focusin', onFocusIn)
258
+ doc.removeEventListener('focusout', onFocusOut)
259
+ doc.removeEventListener('pointerdown', onOutsideSettings, true)
260
+ doc.removeEventListener('keydown', onSettingsKeyDown, true)
261
+ listening = false
262
+ }
263
+
264
+ function targetIdentity(target) {
265
+ return `${target.kind}\0${target.attr || ''}`
266
+ }
267
+
268
+ function sameList(left, right) {
269
+ return left.container === right.container &&
270
+ left.scalar === right.scalar
271
+ }
272
+
273
+ function sameMember(member, spec) {
274
+ if (member.kind !== spec.kind) return false
275
+ if (member.kind === 'handle') return targetIdentity(member.target) === targetIdentity(spec.target)
276
+ if (member.kind === 'row') return member.row === spec.row && sameList(member.list, spec.list)
277
+ return sameList(member.list, spec.list)
278
+ }
279
+
280
+ function updateHandle(member, target) {
281
+ member.target = target
282
+ const path = target.path.join('.')
283
+ member.node.setAttribute('data-hcms-target', path)
284
+ if (target.icon) member.node.setAttribute('data-hcms-icon', target.icon)
285
+ else member.node.removeAttribute('data-hcms-icon')
286
+ member.node.setAttribute('aria-label', `Edit ${path}`)
287
+ }
288
+
289
+ function makeHandle(target) {
290
+ const button = doc.createElement('button')
291
+ button.type = 'button'
292
+ button.className = 'hcms-inline-handle mirk-button mirk-button--small'
293
+ button.innerHTML = `<span class="mirk-button__label">${inlineIcon('edit')}</span>`
294
+ const member = { node: button, kind: 'handle', target }
295
+ updateHandle(member, target)
296
+ button.addEventListener('click', (event) => {
297
+ event.preventDefault()
298
+ event.stopPropagation()
299
+ onActivate?.(member.target, button)
300
+ })
301
+ return member
302
+ }
303
+
304
+ function makeListButton(action, label) {
305
+ const button = doc.createElement('button')
306
+ button.type = 'button'
307
+ button.className = 'hcms-inline-list-button mirk-button mirk-button--small'
308
+ button.setAttribute('data-hcms-list-action', action)
309
+ button.setAttribute('aria-label', label)
310
+ button.innerHTML = `<span class="mirk-button__label">${inlineIcon(action)}${action === 'add' ? '<span>Add</span>' : ''}</span>`
311
+ return button
312
+ }
313
+
314
+ // One row's strip. The index is stamped here because the first-row and
315
+ // last-row availability rules below need the position the layer was built with.
316
+ // The action deliberately does NOT trust it: listAction re-resolves the row
317
+ // from the element it is handed, because a refresh trails a structural change
318
+ // by an observer batch, and a second click inside that window would otherwise
319
+ // act on whatever had taken this row's number.
320
+ function updateRowControls(member, { list, row, rowIndex, count }) {
321
+ const path = list.path.join('.')
322
+ member.list = list
323
+ member.row = row
324
+ member.index = rowIndex
325
+ member.count = count
326
+ member.node.setAttribute('data-hcms-list', path)
327
+ member.node.setAttribute('data-hcms-row', String(rowIndex))
328
+ for (const button of member.node.querySelectorAll('[data-hcms-list-action]')) {
329
+ const action = button.getAttribute('data-hcms-list-action')
330
+ const label = ROW_ACTIONS.find(([name]) => name === action)?.[1] || action
331
+ button.setAttribute('aria-label', `${label} ${path}.${rowIndex}`)
332
+ button.disabled = (action === 'move-up' && rowIndex === 0) ||
333
+ (action === 'move-down' && rowIndex === count - 1)
334
+ }
335
+ }
336
+
337
+ function makeRowControls(list, row, rowIndex, count) {
338
+ const strip = doc.createElement('div')
339
+ strip.className = 'hcms-inline-row-controls'
340
+ const member = { node: strip, kind: 'row', list, row, index: rowIndex, count }
341
+ for (const [action, label] of ROW_ACTIONS) {
342
+ const button = makeListButton(action, label)
343
+ button.addEventListener('click', (event) => {
344
+ event.preventDefault()
345
+ event.stopPropagation()
346
+ if (button.disabled) return
347
+ onListAction?.({
348
+ action,
349
+ list: member.list,
350
+ index: member.index,
351
+ row: member.row,
352
+ })
353
+ })
354
+ strip.appendChild(button)
355
+ }
356
+ updateRowControls(member, { list, row, rowIndex, count })
357
+ return member
358
+ }
359
+
360
+ function closeSettings(restoreFocus = false) {
361
+ if (!openSettings) return
362
+ const member = openSettings
363
+ openSettings = null
364
+ member.menu.hidden = true
365
+ member.button.setAttribute('aria-expanded', 'false')
366
+ member.node.parentElement?.classList.remove('has-open-settings')
367
+ if (restoreFocus && member.button.isConnected) member.button.focus({ preventScroll: true })
368
+ }
369
+
370
+ function onOutsideSettings(event) {
371
+ if (openSettings && !openSettings.node.contains(event.target)) closeSettings()
372
+ }
373
+
374
+ function onSettingsKeyDown(event) {
375
+ if (!openSettings) return
376
+ if (event.key === 'Escape') {
377
+ event.preventDefault()
378
+ event.stopPropagation()
379
+ closeSettings(true)
380
+ } else if (event.key === 'Tab') closeSettings(true)
381
+ else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) && openSettings.menu.contains(event.target)) {
382
+ event.preventDefault()
383
+ openSettings.menu.querySelector('[role="menuitem"]').focus({ preventScroll: true })
384
+ }
385
+ }
386
+
387
+ function updateSettings(member, { list, row, rowIndex }) {
388
+ Object.assign(member, { list, row, index: rowIndex })
389
+ member.node.setAttribute('data-hcms-list', list.path.join('.'))
390
+ member.node.setAttribute('data-hcms-row', String(rowIndex))
391
+ member.button.setAttribute('aria-label', `Settings ${list.path.join('.')}.${rowIndex}`)
392
+ }
393
+
394
+ function makeSettings(spec) {
395
+ const node = doc.createElement('div')
396
+ node.className = 'hcms-inline-settings'
397
+ const button = makeListButton('settings', 'Settings')
398
+ button.setAttribute('aria-haspopup', 'menu')
399
+ button.setAttribute('aria-expanded', 'false')
400
+ const menu = doc.createElement('div')
401
+ menu.className = 'hcms-inline-settings-menu'
402
+ menu.setAttribute('role', 'menu')
403
+ menu.setAttribute('aria-label', 'Item settings')
404
+ menu.hidden = true
405
+ const remove = doc.createElement('button')
406
+ remove.type = 'button'
407
+ remove.setAttribute('role', 'menuitem')
408
+ remove.setAttribute('data-hcms-list-action', 'remove')
409
+ remove.textContent = 'Delete'
410
+ menu.appendChild(remove)
411
+ node.append(button, menu)
412
+ const member = { node, button, menu, kind: 'settings' }
413
+ updateSettings(member, spec)
414
+ const show = () => {
415
+ closeSettings()
416
+ openSettings = member
417
+ menu.hidden = false
418
+ button.setAttribute('aria-expanded', 'true')
419
+ node.parentElement.classList.add('has-open-settings')
420
+ remove.focus({ preventScroll: true })
421
+ schedule()
422
+ }
423
+ button.addEventListener('click', (event) => {
424
+ event.preventDefault()
425
+ event.stopPropagation()
426
+ if (openSettings === member) closeSettings(true)
427
+ else show()
428
+ })
429
+ button.addEventListener('keydown', (event) => {
430
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
431
+ event.preventDefault()
432
+ show()
433
+ }
434
+ })
435
+ remove.addEventListener('click', (event) => {
436
+ event.preventDefault()
437
+ event.stopPropagation()
438
+ closeSettings(true)
439
+ onListAction?.({ action: 'remove', list: member.list, index: member.index, row: member.row })
440
+ })
441
+ return member
442
+ }
443
+
444
+ function updateMember(member, spec) {
445
+ if (member.kind === 'handle') updateHandle(member, spec.target)
446
+ else if (member.kind === 'row') updateRowControls(member, spec)
447
+ else if (member.kind === 'settings') updateSettings(member, spec)
448
+ }
449
+
450
+ function addDesired(desired, desiredIndex, el, spec, category) {
451
+ let spot = desiredIndex.get(el)?.find((candidate) => candidate.category === category)
452
+ if (!spot) {
453
+ spot = { el, category, members: [] }
454
+ desired.push(spot)
455
+ const riders = desiredIndex.get(el)
456
+ if (riders) riders.push(spot)
457
+ else desiredIndex.set(el, [spot])
458
+ }
459
+ spot.members.push(spec)
460
+ }
461
+
462
+ function addListControls(desired, desiredIndex, list) {
463
+ const rows = list.items || []
464
+ rows.forEach((el, i) => {
465
+ if (!isAnchorable(el)) return
466
+ addDesired(desired, desiredIndex, el, {
467
+ kind: 'row', list, row: el, rowIndex: i, count: rows.length,
468
+ }, 'item')
469
+ addDesired(desired, desiredIndex, el, {
470
+ kind: 'settings', list, row: el, rowIndex: i,
471
+ }, 'item')
472
+ })
473
+ }
474
+
475
+ function reconcileMembers(spot, specs) {
476
+ const unused = new Set(spot.members)
477
+ const next = []
478
+ for (const spec of specs) {
479
+ const member = spot.members.find((candidate) => unused.has(candidate) && sameMember(candidate, spec)) ||
480
+ (spec.kind === 'handle'
481
+ ? makeHandle(spec.target)
482
+ : spec.kind === 'row'
483
+ ? makeRowControls(spec.list, spec.row, spec.rowIndex, spec.count)
484
+ : makeSettings(spec))
485
+ unused.delete(member)
486
+ updateMember(member, spec)
487
+ if (controlsHidden && member.kind !== 'handle') member.node.hidden = true
488
+ next.push(member)
489
+ }
490
+ for (const member of unused) {
491
+ if (member === openSettings) closeSettings()
492
+ member.node.remove()
493
+ }
494
+ next.sort((left, right) => ['row', 'handle', 'settings'].indexOf(left.kind) - ['row', 'handle', 'settings'].indexOf(right.kind))
495
+ next.forEach((member, i) => {
496
+ if (spot.node.children[i] !== member.node) spot.node.insertBefore(member.node, spot.node.children[i] || null)
497
+ })
498
+ spot.members = next
499
+ }
500
+
501
+ function reconcile(targetList, lists) {
502
+ const desired = []
503
+ const desiredIndex = new Map()
504
+ const nextTargets = new Map()
505
+ let nextHandleCount = 0
506
+ for (const target of targetList || []) {
507
+ if (!nextTargets.has(target.el)) nextTargets.set(target.el, target)
508
+ if (target.kind !== 'handle' || !isAnchorable(target.el)) continue
509
+ addDesired(desired, desiredIndex, target.el, { kind: 'handle', target }, 'item')
510
+ nextHandleCount++
511
+ }
512
+ for (const list of lists || []) addListControls(desired, desiredIndex, list)
513
+
514
+ const unused = new Set(placements)
515
+ const visibility = new Map()
516
+ for (const spot of placements) {
517
+ if (!visibility.has(spot.el)) visibility.set(spot.el, spot.visible)
518
+ }
519
+ const next = []
520
+ for (const wanted of desired) {
521
+ let spot = placements.find((candidate) =>
522
+ unused.has(candidate) && candidate.el === wanted.el && candidate.category === wanted.category)
523
+ if (!spot) {
524
+ const container = doc.createElement('div')
525
+ container.className = 'hcms-inline-item-controls'
526
+ container.setAttribute('role', 'group')
527
+ spot = {
528
+ el: wanted.el,
529
+ node: container,
530
+ category: wanted.category,
531
+ kind: wanted.category === 'add' ? 'add' : 'handle',
532
+ members: [],
533
+ visible: visibility.get(wanted.el) ?? false,
534
+ }
535
+ layerEl.appendChild(container)
536
+ }
537
+ unused.delete(spot)
538
+ spot.el = wanted.el
539
+ spot.category = wanted.category
540
+ reconcileMembers(spot, wanted.members)
541
+ spot.kind = spot.members.some((member) => member.kind === 'row')
542
+ ? 'row'
543
+ : spot.members.some((member) => member.kind === 'handle') ? 'handle' : 'add'
544
+ spot.node.setAttribute('aria-label', spot.category === 'add' ? 'List controls' : 'Item controls')
545
+ next.push(spot)
546
+ }
547
+ for (const spot of unused) spot.node.remove()
548
+
549
+ placements = next
550
+ index = new Map()
551
+ rowOwners = new Map()
552
+ for (const spot of placements) {
553
+ const riders = index.get(spot.el)
554
+ if (riders) riders.push(spot)
555
+ else index.set(spot.el, [spot])
556
+ for (const member of spot.members) {
557
+ if (member.kind !== 'row' && member.kind !== 'settings') continue
558
+ rowOwners.set(member.row, member.row)
559
+ rowOwners.set(member.node, member.row)
560
+ rowOwners.set(spot.node, member.row)
561
+ }
562
+ }
563
+ if (hoveredRow && !rowOwners.has(hoveredRow)) hoveredRow = null
564
+ if (focusedRow && !rowOwners.has(focusedRow)) focusedRow = null
565
+ targets = nextTargets
566
+ handleCount = nextHandleCount
567
+ }
568
+
569
+ function clearEntries() {
570
+ closeSettings()
571
+ observer?.disconnect()
572
+ observer = null
573
+ observedAnchors = new Set()
574
+ for (const spot of placements) spot.node.remove()
575
+ placements = []
576
+ index = new Map()
577
+ targets = new Map()
578
+ rowOwners = new Map()
579
+ handleCount = 0
580
+ }
581
+
582
+ return {
583
+ setTargets(list, lists) {
584
+ reconcile(list, lists)
585
+ ghosts.setLists(lists)
586
+ syncObservation()
587
+ listen()
588
+ schedule()
589
+ },
590
+ refresh: schedule,
591
+ get count() { return handleCount },
592
+
593
+ get controlsHidden() { return controlsHidden },
594
+
595
+ // Hidden immediately rather than on the next frame, so the toggle reads as
596
+ // instant; showing them again goes through the placement pass, which is what
597
+ // gives them their coordinates back.
598
+ setControlsHidden(hidden) {
599
+ controlsHidden = !!hidden
600
+ ghosts.setHidden(controlsHidden)
601
+ if (controlsHidden) {
602
+ closeSettings()
603
+ for (const spot of placements) {
604
+ for (const member of spot.members) if (member.kind !== 'handle') member.node.hidden = true
605
+ if (spot.members.every((member) => member.node.hidden)) spot.node.hidden = true
606
+ }
607
+ }
608
+ schedule()
609
+ },
610
+
611
+ // The nearest target containing `el`, walking up from it. One Map lookup
612
+ // per ancestor rather than a scan of the target list per pointer event.
613
+ elementToTarget(el) {
614
+ if (el?.closest?.('[data-hcms-ghost]')) return null
615
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
616
+ const target = targets.get(node)
617
+ if (target) return target
618
+ }
619
+ return null
620
+ },
621
+
622
+ // Where the pointer is, answered off the one pointer path the highlight
623
+ // already rides (inline.js bindPage). Takes the RAW element under the
624
+ // pointer rather than a resolved target: a strip is not a target at all,
625
+ // and reading it as "off the row" would hide it under the pointer.
626
+ setHoveredRow(el) {
627
+ const before = revealedRow()
628
+ hoveredRow = el ? rowAt(el) : null
629
+ if (revealedRow() !== before) schedule()
630
+ },
631
+
632
+ showHighlight(el) {
633
+ if (!el) return
634
+ highlighted = el
635
+ highlight.hidden = false
636
+ placeHighlight()
637
+ },
638
+
639
+ hideHighlight() {
640
+ highlighted = null
641
+ highlight.hidden = true
642
+ },
643
+
644
+ // The seam the view uses to ride the same frame-coalesced pass the handles
645
+ // do, so an open popover tracks a scroll without a second rAF loop.
646
+ setFollower(fn) {
647
+ follower = typeof fn === 'function' ? fn : null
648
+ },
649
+
650
+ destroy() {
651
+ ghosts.destroy()
652
+ if (frame && win) win.cancelAnimationFrame(frame)
653
+ frame = 0
654
+ follower = null
655
+ highlighted = null
656
+ hoveredRow = null
657
+ focusedRow = null
658
+ highlight.remove()
659
+ unlisten()
660
+ clearEntries()
661
+ },
662
+ }
663
+ }