@panphora/hyper-cms 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/unresolved.js CHANGED
@@ -1,5 +1,5 @@
1
- import domAdapter from 'hyper-html-api/dom'
2
- import { ruleAttrIndex } from 'hyper-html-api/engine'
1
+ import { createDomAdapter } from 'hyper-html-api/dom'
2
+ import { ruleAttrIndex, DOM_PROPERTIES_READ_ONLY_SET } from 'hyper-html-api/engine'
3
3
 
4
4
  const PREFIX = '[hypercms]'
5
5
 
@@ -28,17 +28,61 @@ export class InvalidRuleSelector extends Error {
28
28
  export function findUnresolved(root, rules) {
29
29
  const missing = []
30
30
  const twins = []
31
- walk(root, rules, [], missing, twins)
32
- return { missing: unique(missing), twins: uniqueTwins(twins) }
31
+ const readOnly = []
32
+ walk(createDomAdapter(root), root, rules, [], missing, twins, readOnly)
33
+ return { missing: unique(missing), twins: uniqueTwins(twins), readOnly: unique(readOnly) }
33
34
  }
34
35
 
35
- function walk(ctx, rule, path, missing, twins) {
36
+ // The rules to WRITE with. Extract keeps the full tree, because a read-only
37
+ // projection still READS (".box@classList" reads its class list) and dropping
38
+ // it would change what onChange reports. apply() ignores data keys its rules do
39
+ // not mention, so narrowing only the write side costs nothing and removes the
40
+ // failure class: apply throws RuleTargetReadOnly part-way through its walk, and
41
+ // the ordinary edit path does not snapshot, so the writes ordered before the
42
+ // throw stay and the ones after never happen.
43
+ export function stripReadOnly(rules) {
44
+ return strip(rules)
45
+ }
46
+
47
+ function strip(rule) {
48
+ if (typeof rule === 'string') return isReadOnlyRule(rule) ? undefined : rule
49
+ if (Array.isArray(rule)) {
50
+ const [selector, shape] = rule
51
+ // Keep the tuple even when the item shape strips to nothing. The selector is
52
+ // what engine.apply needs in order to add, remove and reorder rows, so
53
+ // dropping it made every structural operation on a list whose only field is
54
+ // read-only report success while the page did not change. An undefined item
55
+ // shape writes no value and leaves row reconciliation intact.
56
+ return [selector, strip(shape)]
57
+ }
58
+ if (rule && typeof rule === 'object') {
59
+ const out = {}
60
+ for (const [key, sub] of Object.entries(rule)) {
61
+ const kept = strip(sub)
62
+ if (kept !== undefined) out[key] = kept
63
+ }
64
+ return out
65
+ }
66
+ return rule
67
+ }
68
+
69
+ function walk(adapter, ctx, rule, path, missing, twins, readOnly) {
36
70
  if (typeof rule === 'string') {
37
71
  const selector = selectorOf(rule)
72
+ // Resolve the selector even for a read-only rule, so an invalid one still
73
+ // becomes InvalidRuleSelector here rather than a raw SyntaxError out of the
74
+ // extract that follows.
75
+ const matches = selector ? find(adapter, ctx, selector, FIND_OPTS, path) : []
76
+ // Reported on its own and never also as missing: the selector may well
77
+ // match, and the reason the edit cannot land is the property, not the
78
+ // element.
79
+ if (isReadOnlyRule(rule)) {
80
+ readOnly.push(pathStr(path))
81
+ return
82
+ }
38
83
  if (!selector) return
39
- const matches = find(ctx, selector, FIND_OPTS, path)
40
84
  if (rule.endsWith('[]')) {
41
- if (matches.length === 0 && find(ctx, selector, SEED_OPTS, path).length === 0) {
85
+ if (matches.length === 0 && find(adapter, ctx, selector, SEED_OPTS, path).length === 0) {
42
86
  missing.push(pathStr(path))
43
87
  }
44
88
  return
@@ -51,25 +95,25 @@ function walk(ctx, rule, path, missing, twins) {
51
95
  if (Array.isArray(rule)) {
52
96
  const [selector, shape] = rule
53
97
  if (typeof selector !== 'string' || !selector) return
54
- const matches = find(ctx, selector, FIND_OPTS, path)
98
+ const matches = find(adapter, ctx, selector, FIND_OPTS, path)
55
99
  if (matches.length === 0) {
56
- if (find(ctx, selector, SEED_OPTS, path).length === 0) missing.push(pathStr(path))
100
+ if (find(adapter, ctx, selector, SEED_OPTS, path).length === 0) missing.push(pathStr(path))
57
101
  return
58
102
  }
59
103
  // Rows collapse to one '*' segment, so a field broken on every row is
60
104
  // reported once rather than once per row.
61
- for (const node of matches) walk(node, shape, [...path, '*'], missing, twins)
105
+ for (const node of matches) walk(adapter, node, shape, [...path, '*'], missing, twins, readOnly)
62
106
  return
63
107
  }
64
108
 
65
109
  if (rule && typeof rule === 'object') {
66
- for (const [key, sub] of Object.entries(rule)) walk(ctx, sub, [...path, key], missing, twins)
110
+ for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], missing, twins, readOnly)
67
111
  }
68
112
  }
69
113
 
70
- function find(ctx, selector, opts, path) {
114
+ function find(adapter, ctx, selector, opts, path) {
71
115
  try {
72
- return domAdapter.find(ctx, selector, opts)
116
+ return adapter.find(ctx, selector, opts)
73
117
  } catch (err) {
74
118
  throw new InvalidRuleSelector(pathStr(path), selector, err)
75
119
  }
@@ -86,6 +130,20 @@ function selectorOf(rule) {
86
130
  return (at === -1 ? rule : rule.slice(0, at)) || null
87
131
  }
88
132
 
133
+ // The attribute or property half of a scalar rule, or null when the rule is a
134
+ // bare selector, an array rule, or ".". ruleAttrIndex returns the index of the
135
+ // last top-level @, so a leading @ (the context node itself) resolves here too.
136
+ function attrOf(rule) {
137
+ if (rule.endsWith('[]')) return null
138
+ const at = ruleAttrIndex(rule)
139
+ return at === -1 ? null : rule.slice(at + 1) || null
140
+ }
141
+
142
+ function isReadOnlyRule(rule) {
143
+ const attr = attrOf(rule)
144
+ return attr != null && DOM_PROPERTIES_READ_ONLY_SET.has(attr)
145
+ }
146
+
89
147
  export function applyUnresolvedState(ctx) {
90
148
  renderNotice(ctx)
91
149
  warnTwins(ctx)
@@ -98,15 +156,26 @@ function renderNotice(ctx) {
98
156
  const el = ctx.noticeEl
99
157
  if (!el) return
100
158
  const missing = (ctx.unresolved && ctx.unresolved.missing) || []
101
- if (missing.length === 0) {
159
+ const readOnly = (ctx.unresolved && ctx.unresolved.readOnly) || []
160
+ if (missing.length === 0 && readOnly.length === 0) {
102
161
  el.textContent = ''
103
162
  el.hidden = true
104
163
  return
105
164
  }
106
- const count = missing.length === 1
107
- ? '1 field no longer matches this page'
108
- : `${missing.length} fields no longer match this page`
109
- el.textContent = `${count}: ${missing.join(', ')}`
165
+ const lines = []
166
+ if (missing.length) {
167
+ const count = missing.length === 1
168
+ ? '1 field no longer matches this page'
169
+ : `${missing.length} fields no longer match this page`
170
+ lines.push(`${count}: ${missing.join(', ')}`)
171
+ }
172
+ if (readOnly.length) {
173
+ const count = readOnly.length === 1
174
+ ? '1 field reads a property the browser will not let anything write'
175
+ : `${readOnly.length} fields read properties the browser will not let anything write`
176
+ lines.push(`${count}: ${readOnly.join(', ')}`)
177
+ }
178
+ el.textContent = lines.join('\n')
110
179
  el.hidden = false
111
180
  }
112
181
 
@@ -0,0 +1,139 @@
1
+ import { inlineIcon } from '../inline-icons.js'
2
+ import { capabilitySelector } from '../lib/region-capabilities.js'
3
+
4
+ const MARKER = 'data-hcms-ghost'
5
+ const EDITOR_UI_SELECTOR = capabilitySelector('history')
6
+
7
+ function placementFor(list) {
8
+ let parent = list.container
9
+ let anchor = null
10
+ while (parent && (parent.namespaceURI !== 'http://www.w3.org/1999/xhtml' || ['SELECT', 'OPTGROUP', 'DATALIST'].includes(parent.tagName))) {
11
+ anchor = parent
12
+ parent = parent.parentElement
13
+ }
14
+ return { parent, anchor }
15
+ }
16
+
17
+ export function createInlineGhosts({ doc, themeRoot, onAdd, onResize }) {
18
+ const win = doc.defaultView
19
+ let entries = []
20
+ let hidden = false
21
+ let resizeObserver = null
22
+ const sorting = new Map()
23
+
24
+ function setStyle(node, property, value) {
25
+ if (node.style[property] !== value) node.style[property] = value
26
+ }
27
+
28
+ function protectSorting() {
29
+ const parents = new Set(entries.map((entry) => entry.node.parentElement))
30
+ for (const [parent, record] of sorting) {
31
+ if (parents.has(parent) && win?.Sortable?.get(parent) === record.instance) continue
32
+ if (record.instance.option('draggable') === record.selector) record.instance.option('draggable', record.original)
33
+ sorting.delete(parent)
34
+ }
35
+ for (const parent of parents) {
36
+ const instance = win?.Sortable?.get(parent)
37
+ if (!instance) continue
38
+ const current = instance.option('draggable')
39
+ if (sorting.get(parent)?.selector === current) continue
40
+ const original = current
41
+ const direct = original.trim().startsWith('>')
42
+ const selector = `${direct ? '> ' : ''}:is(${original.trim().replace(/^>\s*/, '')}):not(${EDITOR_UI_SELECTOR})`
43
+ instance.option('draggable', selector)
44
+ sorting.set(parent, { instance, original, selector })
45
+ }
46
+ }
47
+
48
+ function update() {
49
+ for (const entry of entries) {
50
+ const { node, slot, list } = entry
51
+ const boxes = list.items.filter((el) => el.isConnected).map((el) => el.getBoundingClientRect())
52
+ .filter((rect) => rect.width > 0 && rect.height > 0)
53
+ if (boxes.length) {
54
+ entry.width = boxes.reduce((sum, rect) => sum + rect.width, 0) / boxes.length
55
+ entry.height = boxes.reduce((sum, rect) => sum + rect.height, 0) / boxes.length
56
+ }
57
+ setStyle(node, 'width', entry.width ? `${Math.round(entry.width)}px` : '100%')
58
+ setStyle(slot, 'height', `${Math.max(48, Math.round(entry.height || 64))}px`)
59
+ if (slot !== node) slot.colSpan = Math.max(1, ...list.items.map((row) => [...row.children].reduce((sum, cell) => sum + (cell.colSpan || 1), 0)))
60
+ if (node.hidden !== hidden) node.hidden = hidden
61
+ }
62
+ protectSorting()
63
+ }
64
+
65
+ function make(list, parent) {
66
+ const parentTag = parent.tagName
67
+ const tableRow = ['TBODY', 'THEAD', 'TFOOT', 'TABLE'].includes(parentTag)
68
+ const node = doc.createElement(tableRow ? 'tr' : ['UL', 'OL', 'MENU'].includes(parentTag) ? 'li' : 'div')
69
+ for (const attr of [MARKER, 'data-hcms-shell', 'editor-ui', 'no-watch', 'no-save', 'save-remove', 'snapshot-remove']) node.setAttribute(attr, '')
70
+ node.setAttribute('draggable', 'false')
71
+ node.setAttribute('contenteditable', 'false')
72
+ node.className = 'hcms-shell pixel-quiet hcms-inline-ghost'
73
+ for (const theme of ['light', 'dark']) node.classList.toggle(theme, !!themeRoot?.classList.contains(theme))
74
+ const slot = tableRow ? doc.createElement('td') : node
75
+ if (slot !== node) {
76
+ slot.className = 'hcms-inline-ghost-cell'
77
+ node.appendChild(slot)
78
+ }
79
+ const button = doc.createElement('button')
80
+ button.type = 'button'
81
+ button.className = 'hcms-inline-list-button hcms-inline-list-add mirk-button mirk-button--small'
82
+ button.setAttribute('data-hcms-list-action', 'add')
83
+ button.innerHTML = `<span class="mirk-button__label">${inlineIcon('add')}<span>Add</span></span>`
84
+ slot.appendChild(button)
85
+ const entry = { node, slot, button, list, width: 0, height: 0 }
86
+ button.addEventListener('click', (event) => {
87
+ event.preventDefault()
88
+ event.stopPropagation()
89
+ onAdd(entry.list)
90
+ })
91
+ for (const type of ['pointerdown', 'mousedown', 'touchstart', 'click']) node.addEventListener(type, (event) => event.stopPropagation())
92
+ node.addEventListener('dragstart', (event) => { event.preventDefault(); event.stopPropagation() })
93
+ return entry
94
+ }
95
+
96
+ return {
97
+ setLists(lists) {
98
+ const unused = new Set(entries)
99
+ const next = []
100
+ const owned = new Set(entries.map(entry => entry.node))
101
+ const cleanedParents = new Set()
102
+ for (const list of lists || []) {
103
+ if (!list.container || !list.container.isConnected) continue
104
+ const { parent, anchor } = placementFor(list)
105
+ if (!parent) continue
106
+ if (!cleanedParents.has(parent)) {
107
+ for (const stale of parent.querySelectorAll(`:scope > [${MARKER}]`)) {
108
+ if (!owned.has(stale)) stale.remove()
109
+ }
110
+ cleanedParents.add(parent)
111
+ }
112
+ const entry = entries.find((candidate) => unused.has(candidate) && candidate.list.container === list.container && candidate.list.path.join('.') === list.path.join('.')) || make(list, parent)
113
+ unused.delete(entry)
114
+ entry.list = list
115
+ entry.button.setAttribute('data-hcms-list', list.path.join('.'))
116
+ entry.button.setAttribute('aria-label', `Add to ${list.path.join('.') || 'the list'}`)
117
+ const last = anchor || list.items.filter((el) => el.parentElement === parent).at(-1)
118
+ if (last) {
119
+ if (last.nextSibling !== entry.node) last.after(entry.node)
120
+ } else if (entry.node.parentElement !== parent) parent.appendChild(entry.node)
121
+ next.push(entry)
122
+ }
123
+ for (const entry of unused) entry.node.remove()
124
+ entries = next
125
+ resizeObserver?.disconnect()
126
+ if (!resizeObserver && typeof win?.ResizeObserver === 'function') resizeObserver = new win.ResizeObserver(() => { update(); onResize?.() })
127
+ for (const entry of entries) for (const item of entry.list.items) resizeObserver?.observe(item)
128
+ update()
129
+ },
130
+ update,
131
+ setHidden(value) { hidden = !!value; update() },
132
+ destroy() {
133
+ resizeObserver?.disconnect()
134
+ for (const entry of entries) entry.node.remove()
135
+ entries = []
136
+ protectSorting()
137
+ },
138
+ }
139
+ }