@panphora/hyper-cms 0.11.6 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,7 +40,7 @@ function hosts(row, strip) {
40
40
  return strip.width <= row.width && strip.height <= row.height
41
41
  }
42
42
 
43
- export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
43
+ export function createInlineLayer({ doc, layerEl, onActivate, onListAction, onStyle }) {
44
44
  const win = doc.defaultView
45
45
  // Every positioned thing the layer draws, whatever kind: a handle, a row
46
46
  // strip, a list's Add. One array, because all three are placed by one pass.
@@ -93,9 +93,8 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
93
93
 
94
94
  function schedule() {
95
95
  if (frame || !win) return
96
- // The same degradation toggle.js applies in scheduleSurface: a window with
97
- // no requestAnimationFrame gets a synchronous pass instead of a crash on
98
- // mount. Every browser has it; a non-visual jsdom does not.
96
+ // A window with no requestAnimationFrame gets a synchronous pass instead of
97
+ // a crash on mount. Every browser has it; a non-visual jsdom does not.
99
98
  if (typeof win.requestAnimationFrame !== 'function') return placeAll()
100
99
  frame = win.requestAnimationFrame(() => {
101
100
  frame = 0
@@ -135,6 +134,14 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
135
134
  spot.node.hidden = true
136
135
  continue
137
136
  }
137
+ // A Style chip sits inside its element's top-left corner, clear of the
138
+ // row strips and handles that take the top-right.
139
+ if (spot.kind === 'style') {
140
+ const x = Math.max(8, Math.min(anchor.left, viewport.width - handle.width - 8))
141
+ const y = Math.max(8, Math.min(anchor.top, viewport.height - handle.height - 8))
142
+ spot.node.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`
143
+ continue
144
+ }
138
145
  const prefer = spot.kind === 'handle' ? null : CONTROL_PLACEMENT
139
146
  let { x, y } = placeHandle({ anchor, handle, viewport, ...prefer })
140
147
  const pencil = spot.members.find((member) => member.kind === 'handle')
@@ -272,6 +279,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
272
279
 
273
280
  function sameMember(member, spec) {
274
281
  if (member.kind !== spec.kind) return false
282
+ if (member.kind === 'style') return true
275
283
  if (member.kind === 'handle') return targetIdentity(member.target) === targetIdentity(spec.target)
276
284
  if (member.kind === 'row') return member.row === spec.row && sameList(member.list, spec.list)
277
285
  return sameList(member.list, spec.list)
@@ -301,6 +309,21 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
301
309
  return member
302
310
  }
303
311
 
312
+ function makeStyleChip(spec) {
313
+ const button = doc.createElement('button')
314
+ button.type = 'button'
315
+ button.className = 'hcms-inline-style-chip mirk-button mirk-button--small'
316
+ button.setAttribute('aria-label', 'Edit style')
317
+ button.innerHTML = '<span class="mirk-button__label">Style</span>'
318
+ const member = { node: button, kind: 'style', group: spec.group }
319
+ button.addEventListener('click', (event) => {
320
+ event.preventDefault()
321
+ event.stopPropagation()
322
+ onStyle?.(member.group, button)
323
+ })
324
+ return member
325
+ }
326
+
304
327
  function makeListButton(action, label) {
305
328
  const button = doc.createElement('button')
306
329
  button.type = 'button'
@@ -380,15 +403,23 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
380
403
  } else if (event.key === 'Tab') closeSettings(true)
381
404
  else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) && openSettings.menu.contains(event.target)) {
382
405
  event.preventDefault()
383
- openSettings.menu.querySelector('[role="menuitem"]').focus({ preventScroll: true })
406
+ const items = [...openSettings.menu.querySelectorAll('[role="menuitem"]')]
407
+ const at = items.indexOf(event.target)
408
+ const next = event.key === 'Home' ? 0
409
+ : event.key === 'End' ? items.length - 1
410
+ : at === -1 ? 0
411
+ : (at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
412
+ items[next]?.focus({ preventScroll: true })
384
413
  }
385
414
  }
386
415
 
387
- function updateSettings(member, { list, row, rowIndex }) {
388
- Object.assign(member, { list, row, index: rowIndex })
416
+ function updateSettings(member, { list, row, rowIndex, style = null }) {
417
+ Object.assign(member, { list, row, index: rowIndex, style })
389
418
  member.node.setAttribute('data-hcms-list', list.path.join('.'))
390
419
  member.node.setAttribute('data-hcms-row', String(rowIndex))
391
420
  member.button.setAttribute('aria-label', `Settings ${list.path.join('.')}.${rowIndex}`)
421
+ if (style && !member.styleItem.isConnected) member.menu.insertBefore(member.styleItem, member.menu.firstChild)
422
+ else if (!style) member.styleItem.remove()
392
423
  }
393
424
 
394
425
  function makeSettings(spec) {
@@ -402,6 +433,11 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
402
433
  menu.setAttribute('role', 'menu')
403
434
  menu.setAttribute('aria-label', 'Item settings')
404
435
  menu.hidden = true
436
+ const styleItem = doc.createElement('button')
437
+ styleItem.type = 'button'
438
+ styleItem.setAttribute('role', 'menuitem')
439
+ styleItem.setAttribute('data-hcms-style-action', '')
440
+ styleItem.textContent = 'Style'
405
441
  const remove = doc.createElement('button')
406
442
  remove.type = 'button'
407
443
  remove.setAttribute('role', 'menuitem')
@@ -409,7 +445,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
409
445
  remove.textContent = 'Delete'
410
446
  menu.appendChild(remove)
411
447
  node.append(button, menu)
412
- const member = { node, button, menu, kind: 'settings' }
448
+ const member = { node, button, menu, styleItem, kind: 'settings' }
413
449
  updateSettings(member, spec)
414
450
  const show = () => {
415
451
  closeSettings()
@@ -417,7 +453,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
417
453
  menu.hidden = false
418
454
  button.setAttribute('aria-expanded', 'true')
419
455
  node.parentElement.classList.add('has-open-settings')
420
- remove.focus({ preventScroll: true })
456
+ menu.querySelector('[role="menuitem"]').focus({ preventScroll: true })
421
457
  schedule()
422
458
  }
423
459
  button.addEventListener('click', (event) => {
@@ -438,6 +474,12 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
438
474
  closeSettings(true)
439
475
  onListAction?.({ action: 'remove', list: member.list, index: member.index, row: member.row })
440
476
  })
477
+ styleItem.addEventListener('click', (event) => {
478
+ event.preventDefault()
479
+ event.stopPropagation()
480
+ closeSettings()
481
+ onStyle?.(member.style, member.button)
482
+ })
441
483
  return member
442
484
  }
443
485
 
@@ -445,6 +487,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
445
487
  if (member.kind === 'handle') updateHandle(member, spec.target)
446
488
  else if (member.kind === 'row') updateRowControls(member, spec)
447
489
  else if (member.kind === 'settings') updateSettings(member, spec)
490
+ else if (member.kind === 'style') member.group = spec.group
448
491
  }
449
492
 
450
493
  function addDesired(desired, desiredIndex, el, spec, category) {
@@ -459,7 +502,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
459
502
  spot.members.push(spec)
460
503
  }
461
504
 
462
- function addListControls(desired, desiredIndex, list) {
505
+ function addListControls(desired, desiredIndex, list, rowStyles) {
463
506
  const rows = list.items || []
464
507
  rows.forEach((el, i) => {
465
508
  if (!isAnchorable(el)) return
@@ -467,7 +510,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
467
510
  kind: 'row', list, row: el, rowIndex: i, count: rows.length,
468
511
  }, 'item')
469
512
  addDesired(desired, desiredIndex, el, {
470
- kind: 'settings', list, row: el, rowIndex: i,
513
+ kind: 'settings', list, row: el, rowIndex: i, style: rowStyles.get(el) || null,
471
514
  }, 'item')
472
515
  })
473
516
  }
@@ -481,7 +524,9 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
481
524
  ? makeHandle(spec.target)
482
525
  : spec.kind === 'row'
483
526
  ? makeRowControls(spec.list, spec.row, spec.rowIndex, spec.count)
484
- : makeSettings(spec))
527
+ : spec.kind === 'style'
528
+ ? makeStyleChip(spec)
529
+ : makeSettings(spec))
485
530
  unused.delete(member)
486
531
  updateMember(member, spec)
487
532
  if (controlsHidden && member.kind !== 'handle') member.node.hidden = true
@@ -498,18 +543,26 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
498
543
  spot.members = next
499
544
  }
500
545
 
501
- function reconcile(targetList, lists) {
546
+ function reconcile(targetList, lists, groups) {
502
547
  const desired = []
503
548
  const desiredIndex = new Map()
504
549
  const nextTargets = new Map()
505
550
  let nextHandleCount = 0
506
551
  for (const target of targetList || []) {
552
+ if (target.style) continue
507
553
  if (!nextTargets.has(target.el)) nextTargets.set(target.el, target)
508
554
  if (target.kind !== 'handle' || !isAnchorable(target.el)) continue
509
555
  addDesired(desired, desiredIndex, target.el, { kind: 'handle', target }, 'item')
510
556
  nextHandleCount++
511
557
  }
512
- for (const list of lists || []) addListControls(desired, desiredIndex, list)
558
+ const rowStyles = new Map()
559
+ for (const group of groups || []) {
560
+ if (group.scope === 'row') rowStyles.set(group.el, group)
561
+ else if (group.scope === 'section' && isAnchorable(group.el)) {
562
+ addDesired(desired, desiredIndex, group.el, { kind: 'style', group }, 'style')
563
+ }
564
+ }
565
+ for (const list of lists || []) addListControls(desired, desiredIndex, list, rowStyles)
513
566
 
514
567
  const unused = new Set(placements)
515
568
  const visibility = new Map()
@@ -540,8 +593,10 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
540
593
  reconcileMembers(spot, wanted.members)
541
594
  spot.kind = spot.members.some((member) => member.kind === 'row')
542
595
  ? 'row'
543
- : spot.members.some((member) => member.kind === 'handle') ? 'handle' : 'add'
544
- spot.node.setAttribute('aria-label', spot.category === 'add' ? 'List controls' : 'Item controls')
596
+ : spot.members.some((member) => member.kind === 'handle')
597
+ ? 'handle'
598
+ : spot.members.some((member) => member.kind === 'style') ? 'style' : 'add'
599
+ spot.node.setAttribute('aria-label', spot.category === 'add' ? 'List controls' : spot.category === 'style' ? 'Style controls' : 'Item controls')
545
600
  next.push(spot)
546
601
  }
547
602
  for (const spot of unused) spot.node.remove()
@@ -580,8 +635,8 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
580
635
  }
581
636
 
582
637
  return {
583
- setTargets(list, lists) {
584
- reconcile(list, lists)
638
+ setTargets(list, lists, groups = []) {
639
+ reconcile(list, lists, groups)
585
640
  ghosts.setLists(lists)
586
641
  syncObservation()
587
642
  listen()
@@ -36,7 +36,7 @@ import { applyUnresolvedState } from '../unresolved.js'
36
36
  import { refreshForm } from '../refresh.js'
37
37
  import { clearSessionOpen, markSessionOpen, reensureStyles } from '../shell.js'
38
38
  import { isAnchorable } from '../anchor.js'
39
- import { resolveTargets } from '../targets.js'
39
+ import { resolveTargets, styleGroups } from '../targets.js'
40
40
  import { createContentView } from '../lib/content-dom.js'
41
41
  import { morph } from 'hyper-morph'
42
42
  import { place } from '../place.js'
@@ -54,6 +54,7 @@ import {
54
54
  import { installSnapshotHook } from '../hooks.js'
55
55
  import { createInlineLayer } from './inline-layer.js'
56
56
  import { inlineIcon } from '../inline-icons.js'
57
+ import { contentRoot } from '../page-root.js'
57
58
 
58
59
  const HOST_TAG = 'hypercms-inline'
59
60
 
@@ -89,6 +90,7 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
89
90
  // the first placement toward wherever the last field happened to sit.
90
91
  let activeTarget = null
91
92
  let activeHandle = null
93
+ let groupsNow = []
92
94
  let popMode = null
93
95
 
94
96
  // Build one binding: the editor, the marker, the snapshot hook, and the two
@@ -258,6 +260,7 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
258
260
  // The handle and the page click are two doors to one room.
259
261
  onActivate: (target, handle) => this.activate(target, handle),
260
262
  onListAction: (op) => this.listAction(op),
263
+ onStyle: (group, handle) => this.openStyle(group, handle),
261
264
  })
262
265
  layer.setFollower(() => this.placePopover())
263
266
  host.closeEl.addEventListener('click', () => this.deactivate())
@@ -266,6 +269,12 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
266
269
  event.stopPropagation()
267
270
  this.toggleControls()
268
271
  })
272
+ host.pageStyleEl.addEventListener('click', (event) => {
273
+ event.preventDefault()
274
+ event.stopPropagation()
275
+ const group = groupsNow.find((candidate) => candidate.scope === 'page')
276
+ if (group) this.openStyle(group, host.pageStyleEl)
277
+ })
269
278
  // There is no drawer. What a field with no visual representation gets is
270
279
  // this one tap out to the view that edits everything.
271
280
  // The switch carries the session's options across (hypercms.js open()), so
@@ -339,7 +348,7 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
339
348
  // per target: the target set is rebuilt on every refresh, and per-element
340
349
  // listeners would have to be torn down and reattached with it.
341
350
  bindPage() {
342
- const root = this.ctx.pageRoot
351
+ const root = contentRoot(this.ctx.pageRoot)
343
352
  const hostRoot = host.root
344
353
 
345
354
  const onPointerOver = (event) => {
@@ -405,7 +414,7 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
405
414
  // carries it for.
406
415
  const onChanged = (event) => {
407
416
  const detail = event.detail
408
- if (!detail || detail.pageRoot !== this.ctx.pageRoot || !detail.path) return
417
+ if (!detail || detail.pageRoot !== contentRoot(this.ctx.pageRoot) || !detail.path) return
409
418
  for (const binding of bindings.values()) {
410
419
  if (binding.path === detail.path) binding.written = true
411
420
  }
@@ -448,6 +457,7 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
448
457
  activeTarget = target
449
458
  activeHandle = handle || null
450
459
  popMode = null
460
+ host.popTitleEl.textContent = 'Edit content'
451
461
 
452
462
  // Unhide BEFORE measuring. A hidden popover has a zero rect, which places
453
463
  // it at the clamp instead of beside its anchor — the same trap as the 0px
@@ -458,6 +468,33 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
458
468
  focusFirst(leaf)
459
469
  },
460
470
 
471
+ // A style popover shows exactly one group's style fields: the page's from
472
+ // the bar, a section's from its chip, a row's from its gear menu. The
473
+ // section and row ones sit beside what they style; the page one beside the
474
+ // button, because the whole page is no place to anchor a popover.
475
+ openStyle(group, handle) {
476
+ if (!group || !host) return
477
+ const leaf = revealPaths(this, host, group.paths)
478
+ if (!leaf) {
479
+ this.deactivate()
480
+ return
481
+ }
482
+ activeTarget = {
483
+ kind: 'style',
484
+ scope: group.scope,
485
+ el: group.scope === 'page' ? host.pageStyleEl : group.el,
486
+ path: group.paths[0].split('.'),
487
+ paths: group.paths,
488
+ }
489
+ activeHandle = handle || null
490
+ popMode = null
491
+ host.popTitleEl.textContent = group.scope === 'page' ? 'Page style' : 'Style'
492
+ host.popEl.hidden = false
493
+ host.formRoot.querySelectorAll(`.${ACTIVE_CLASS}`).forEach(autosizeIn)
494
+ this.placePopover()
495
+ focusFirst(leaf)
496
+ },
497
+
461
498
  // Bind richclay to the page element and edit the text where it sits.
462
499
  // Returns false when the target cannot be bound, which is the caller's
463
500
  // signal to open the popover over it instead.
@@ -511,13 +548,23 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
511
548
  syncTargets() {
512
549
  if (!layer) return
513
550
  const ctx = this.ctx
514
- const { targets, lists } = resolveTargets(ctx.pageRoot, ctx.pageRules)
515
- layer.setTargets(targets, lists)
551
+ const resolved = resolveTargets(ctx.pageRoot, ctx.pageRules, doc)
552
+ const lists = resolved.lists
553
+ const targets = resolved.targets.filter((target) => !target.style)
554
+ groupsNow = styleGroups(resolved.targets, lists)
555
+ host.pageStyleEl.hidden = !groupsNow.some((group) => group.scope === 'page')
556
+ layer.setTargets(resolved.targets, lists, groupsNow)
516
557
  const byEl = reconcileBindings(this, bindings, targets, doc)
517
558
  // An open popover holds its path the same way a binding does. Re-point it
518
559
  // at the target that now stands for the element it is anchored over, or
519
560
  // the popover keeps editing the row that inherited its old index.
520
- if (activeTarget) {
561
+ if (activeTarget?.kind === 'style') {
562
+ const group = activeTarget.scope === 'page'
563
+ ? groupsNow.find((candidate) => candidate.scope === 'page')
564
+ : groupsNow.find((candidate) => candidate.el === activeTarget.el)
565
+ if (group) activeTarget = { ...activeTarget, paths: group.paths, path: group.paths[0].split('.') }
566
+ else this.deactivate()
567
+ } else if (activeTarget) {
521
568
  const candidates = byEl.get(activeTarget.el) || []
522
569
  const fresh = candidates.find((target) => sameTargetBinding(target, activeTarget)) ||
523
570
  candidates.find((target) => target.path.join('.') === activeTarget.path.join('.')) ||
@@ -644,7 +691,9 @@ export function createInlineView({ doc, pageRoot, opts = {} }) {
644
691
  this.deactivate()
645
692
  return
646
693
  }
647
- const leaf = revealPath(this, host, activeTarget.path.join('.'))
694
+ const leaf = activeTarget.kind === 'style'
695
+ ? revealPaths(this, host, activeTarget.paths)
696
+ : revealPath(this, host, activeTarget.path.join('.'))
648
697
  if (!leaf) {
649
698
  this.deactivate()
650
699
  return
@@ -951,18 +1000,34 @@ function revealPath(view, host, path) {
951
1000
  field.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]') === item.parentElement.closest('[data-hcms-shape="object-array"]'))
952
1001
  : [leaf]
953
1002
  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
- }
1003
+ for (const field of fields) markRevealed(view, field)
963
1004
  return leaf
964
1005
  }
965
1006
 
1007
+ // Reveal exactly these fields, with no card siblings: a style popover shows
1008
+ // style fields only.
1009
+ function revealPaths(view, host, paths) {
1010
+ clearPathClasses(host.root)
1011
+ let first = null
1012
+ for (const path of paths) {
1013
+ const field = view.formRoot && view.formRoot.querySelector(`[data-hcms-path="${cssEscape(path)}"]`)
1014
+ if (!field) continue
1015
+ first = first || field
1016
+ markRevealed(view, field)
1017
+ }
1018
+ return first
1019
+ }
1020
+
1021
+ function markRevealed(view, field) {
1022
+ field.classList.add(ACTIVE_CLASS)
1023
+ field.removeAttribute('draggable')
1024
+ for (let el = field.parentElement; el; el = el.parentElement) {
1025
+ el.classList.add(ONPATH_CLASS)
1026
+ el.removeAttribute('draggable')
1027
+ if (el === view.formRoot) break
1028
+ }
1029
+ }
1030
+
966
1031
  function clearPathClasses(root) {
967
1032
  if (!root) return
968
1033
  for (const el of root.querySelectorAll(`.${ACTIVE_CLASS}, .${ONPATH_CLASS}`)) {
@@ -1036,6 +1101,9 @@ function mountInlineHost(doc, theme) {
1036
1101
  <span class="mirk-button__label">Edit in the sidebar</span>
1037
1102
  </button>
1038
1103
  </div>
1104
+ <button type="button" class="hcms-inline-page-style mirk-button mirk-button--small" data-hcms-page-style-open hidden>
1105
+ <span class="mirk-button__label">Page style</span>
1106
+ </button>
1039
1107
  <button type="button" class="hcms-inline-toggle mirk-button mirk-button--small" data-hcms-controls-toggle aria-pressed="false" hidden>
1040
1108
  <span class="mirk-button__label">Hide controls</span>
1041
1109
  </button>
@@ -1045,7 +1113,7 @@ function mountInlineHost(doc, theme) {
1045
1113
  <div class="hcms-inline-layer"></div>
1046
1114
  <div class="hcms-inline-pop" role="dialog" aria-label="Edit content" hidden>
1047
1115
  <div class="hcms-inline-pop-header">
1048
- <span>Edit content</span>
1116
+ <span class="hcms-inline-pop-title">Edit content</span>
1049
1117
  <button type="button" class="hcms-inline-pop-close mirk-button mirk-button--small" aria-label="Close field editor">
1050
1118
  <span class="mirk-button__label">${inlineIcon('remove')}</span>
1051
1119
  </button>
@@ -1067,6 +1135,8 @@ function mountInlineHost(doc, theme) {
1067
1135
  popEl: root.querySelector('.hcms-inline-pop'),
1068
1136
  closeEl: root.querySelector('.hcms-inline-pop-close'),
1069
1137
  toggleEl: root.querySelector('[data-hcms-controls-toggle]'),
1138
+ pageStyleEl: root.querySelector('[data-hcms-page-style-open]'),
1139
+ popTitleEl: root.querySelector('.hcms-inline-pop-title'),
1070
1140
  destroy() { root.remove() },
1071
1141
  }
1072
1142
  }