@panphora/hyper-cms 0.11.6 → 0.12.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.
@@ -135,6 +135,14 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
135
135
  spot.node.hidden = true
136
136
  continue
137
137
  }
138
+ // A Style chip sits inside its element's top-left corner, clear of the
139
+ // row strips and handles that take the top-right.
140
+ if (spot.kind === 'style') {
141
+ const x = Math.max(8, Math.min(anchor.left, viewport.width - handle.width - 8))
142
+ const y = Math.max(8, Math.min(anchor.top, viewport.height - handle.height - 8))
143
+ spot.node.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`
144
+ continue
145
+ }
138
146
  const prefer = spot.kind === 'handle' ? null : CONTROL_PLACEMENT
139
147
  let { x, y } = placeHandle({ anchor, handle, viewport, ...prefer })
140
148
  const pencil = spot.members.find((member) => member.kind === 'handle')
@@ -272,6 +280,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
272
280
 
273
281
  function sameMember(member, spec) {
274
282
  if (member.kind !== spec.kind) return false
283
+ if (member.kind === 'style') return true
275
284
  if (member.kind === 'handle') return targetIdentity(member.target) === targetIdentity(spec.target)
276
285
  if (member.kind === 'row') return member.row === spec.row && sameList(member.list, spec.list)
277
286
  return sameList(member.list, spec.list)
@@ -301,6 +310,21 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
301
310
  return member
302
311
  }
303
312
 
313
+ function makeStyleChip(spec) {
314
+ const button = doc.createElement('button')
315
+ button.type = 'button'
316
+ button.className = 'hcms-inline-style-chip mirk-button mirk-button--small'
317
+ button.setAttribute('aria-label', 'Edit style')
318
+ button.innerHTML = '<span class="mirk-button__label">Style</span>'
319
+ const member = { node: button, kind: 'style', group: spec.group }
320
+ button.addEventListener('click', (event) => {
321
+ event.preventDefault()
322
+ event.stopPropagation()
323
+ onStyle?.(member.group, button)
324
+ })
325
+ return member
326
+ }
327
+
304
328
  function makeListButton(action, label) {
305
329
  const button = doc.createElement('button')
306
330
  button.type = 'button'
@@ -380,15 +404,23 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
380
404
  } else if (event.key === 'Tab') closeSettings(true)
381
405
  else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) && openSettings.menu.contains(event.target)) {
382
406
  event.preventDefault()
383
- openSettings.menu.querySelector('[role="menuitem"]').focus({ preventScroll: true })
407
+ const items = [...openSettings.menu.querySelectorAll('[role="menuitem"]')]
408
+ const at = items.indexOf(event.target)
409
+ const next = event.key === 'Home' ? 0
410
+ : event.key === 'End' ? items.length - 1
411
+ : at === -1 ? 0
412
+ : (at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
413
+ items[next]?.focus({ preventScroll: true })
384
414
  }
385
415
  }
386
416
 
387
- function updateSettings(member, { list, row, rowIndex }) {
388
- Object.assign(member, { list, row, index: rowIndex })
417
+ function updateSettings(member, { list, row, rowIndex, style = null }) {
418
+ Object.assign(member, { list, row, index: rowIndex, style })
389
419
  member.node.setAttribute('data-hcms-list', list.path.join('.'))
390
420
  member.node.setAttribute('data-hcms-row', String(rowIndex))
391
421
  member.button.setAttribute('aria-label', `Settings ${list.path.join('.')}.${rowIndex}`)
422
+ if (style && !member.styleItem.isConnected) member.menu.insertBefore(member.styleItem, member.menu.firstChild)
423
+ else if (!style) member.styleItem.remove()
392
424
  }
393
425
 
394
426
  function makeSettings(spec) {
@@ -402,6 +434,11 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
402
434
  menu.setAttribute('role', 'menu')
403
435
  menu.setAttribute('aria-label', 'Item settings')
404
436
  menu.hidden = true
437
+ const styleItem = doc.createElement('button')
438
+ styleItem.type = 'button'
439
+ styleItem.setAttribute('role', 'menuitem')
440
+ styleItem.setAttribute('data-hcms-style-action', '')
441
+ styleItem.textContent = 'Style'
405
442
  const remove = doc.createElement('button')
406
443
  remove.type = 'button'
407
444
  remove.setAttribute('role', 'menuitem')
@@ -409,7 +446,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
409
446
  remove.textContent = 'Delete'
410
447
  menu.appendChild(remove)
411
448
  node.append(button, menu)
412
- const member = { node, button, menu, kind: 'settings' }
449
+ const member = { node, button, menu, styleItem, kind: 'settings' }
413
450
  updateSettings(member, spec)
414
451
  const show = () => {
415
452
  closeSettings()
@@ -417,7 +454,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
417
454
  menu.hidden = false
418
455
  button.setAttribute('aria-expanded', 'true')
419
456
  node.parentElement.classList.add('has-open-settings')
420
- remove.focus({ preventScroll: true })
457
+ menu.querySelector('[role="menuitem"]').focus({ preventScroll: true })
421
458
  schedule()
422
459
  }
423
460
  button.addEventListener('click', (event) => {
@@ -438,6 +475,12 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
438
475
  closeSettings(true)
439
476
  onListAction?.({ action: 'remove', list: member.list, index: member.index, row: member.row })
440
477
  })
478
+ styleItem.addEventListener('click', (event) => {
479
+ event.preventDefault()
480
+ event.stopPropagation()
481
+ closeSettings()
482
+ onStyle?.(member.style, member.button)
483
+ })
441
484
  return member
442
485
  }
443
486
 
@@ -445,6 +488,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
445
488
  if (member.kind === 'handle') updateHandle(member, spec.target)
446
489
  else if (member.kind === 'row') updateRowControls(member, spec)
447
490
  else if (member.kind === 'settings') updateSettings(member, spec)
491
+ else if (member.kind === 'style') member.group = spec.group
448
492
  }
449
493
 
450
494
  function addDesired(desired, desiredIndex, el, spec, category) {
@@ -459,7 +503,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
459
503
  spot.members.push(spec)
460
504
  }
461
505
 
462
- function addListControls(desired, desiredIndex, list) {
506
+ function addListControls(desired, desiredIndex, list, rowStyles) {
463
507
  const rows = list.items || []
464
508
  rows.forEach((el, i) => {
465
509
  if (!isAnchorable(el)) return
@@ -467,7 +511,7 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
467
511
  kind: 'row', list, row: el, rowIndex: i, count: rows.length,
468
512
  }, 'item')
469
513
  addDesired(desired, desiredIndex, el, {
470
- kind: 'settings', list, row: el, rowIndex: i,
514
+ kind: 'settings', list, row: el, rowIndex: i, style: rowStyles.get(el) || null,
471
515
  }, 'item')
472
516
  })
473
517
  }
@@ -481,7 +525,9 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
481
525
  ? makeHandle(spec.target)
482
526
  : spec.kind === 'row'
483
527
  ? makeRowControls(spec.list, spec.row, spec.rowIndex, spec.count)
484
- : makeSettings(spec))
528
+ : spec.kind === 'style'
529
+ ? makeStyleChip(spec)
530
+ : makeSettings(spec))
485
531
  unused.delete(member)
486
532
  updateMember(member, spec)
487
533
  if (controlsHidden && member.kind !== 'handle') member.node.hidden = true
@@ -498,18 +544,26 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
498
544
  spot.members = next
499
545
  }
500
546
 
501
- function reconcile(targetList, lists) {
547
+ function reconcile(targetList, lists, groups) {
502
548
  const desired = []
503
549
  const desiredIndex = new Map()
504
550
  const nextTargets = new Map()
505
551
  let nextHandleCount = 0
506
552
  for (const target of targetList || []) {
553
+ if (target.style) continue
507
554
  if (!nextTargets.has(target.el)) nextTargets.set(target.el, target)
508
555
  if (target.kind !== 'handle' || !isAnchorable(target.el)) continue
509
556
  addDesired(desired, desiredIndex, target.el, { kind: 'handle', target }, 'item')
510
557
  nextHandleCount++
511
558
  }
512
- for (const list of lists || []) addListControls(desired, desiredIndex, list)
559
+ const rowStyles = new Map()
560
+ for (const group of groups || []) {
561
+ if (group.scope === 'row') rowStyles.set(group.el, group)
562
+ else if (group.scope === 'section' && isAnchorable(group.el)) {
563
+ addDesired(desired, desiredIndex, group.el, { kind: 'style', group }, 'style')
564
+ }
565
+ }
566
+ for (const list of lists || []) addListControls(desired, desiredIndex, list, rowStyles)
513
567
 
514
568
  const unused = new Set(placements)
515
569
  const visibility = new Map()
@@ -540,8 +594,10 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
540
594
  reconcileMembers(spot, wanted.members)
541
595
  spot.kind = spot.members.some((member) => member.kind === 'row')
542
596
  ? 'row'
543
- : spot.members.some((member) => member.kind === 'handle') ? 'handle' : 'add'
544
- spot.node.setAttribute('aria-label', spot.category === 'add' ? 'List controls' : 'Item controls')
597
+ : spot.members.some((member) => member.kind === 'handle')
598
+ ? 'handle'
599
+ : spot.members.some((member) => member.kind === 'style') ? 'style' : 'add'
600
+ spot.node.setAttribute('aria-label', spot.category === 'add' ? 'List controls' : spot.category === 'style' ? 'Style controls' : 'Item controls')
545
601
  next.push(spot)
546
602
  }
547
603
  for (const spot of unused) spot.node.remove()
@@ -580,8 +636,8 @@ export function createInlineLayer({ doc, layerEl, onActivate, onListAction }) {
580
636
  }
581
637
 
582
638
  return {
583
- setTargets(list, lists) {
584
- reconcile(list, lists)
639
+ setTargets(list, lists, groups = []) {
640
+ reconcile(list, lists, groups)
585
641
  ghosts.setLists(lists)
586
642
  syncObservation()
587
643
  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
  }