@panphora/hyper-cms 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/hypercms.min.js +442 -36
- package/package.json +7 -4
- package/packed-contract.json +1 -0
- package/src/anchor.js +24 -0
- package/src/apply-loop.js +73 -46
- package/src/enhance.js +79 -6
- package/src/events.js +92 -6
- package/src/hooks.js +100 -0
- package/src/hypercms-clayjs.js +10 -0
- package/src/hypercms.js +109 -386
- package/src/inline-icons.js +15 -0
- package/src/lib/content-dom.js +110 -0
- package/src/lib/region-capabilities.js +71 -0
- package/src/morph.js +1 -0
- package/src/place.js +131 -0
- package/src/platform.js +12 -0
- package/src/refresh.js +23 -13
- package/src/richclay-bridge.js +140 -0
- package/src/row-identity.js +4 -6
- package/src/session.js +303 -0
- package/src/shell.js +27 -6
- package/src/targets.js +149 -0
- package/src/theme/pixel-quiet.overrides.css +303 -9
- package/src/theme.generated.css +303 -9
- package/src/toggle.js +627 -34
- package/src/unresolved.js +87 -18
- package/src/views/inline-ghosts.js +139 -0
- package/src/views/inline-layer.js +663 -0
- package/src/views/inline.js +1092 -0
- package/src/views/sidebar.js +97 -0
|
@@ -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
|
+
}
|