@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.
- 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 +79 -46
- package/src/enhance.js +79 -6
- package/src/events.js +102 -8
- package/src/hooks.js +100 -0
- package/src/hypercms-clayjs.js +10 -0
- package/src/hypercms.js +109 -379
- 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 +31 -14
- package/src/richclay-bridge.js +140 -0
- package/src/row-identity.js +109 -0
- 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
package/src/unresolved.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|