@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/README.md +33 -0
- package/dist/hypercms.min.js +445 -38
- package/package.json +8 -5
- 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,110 @@
|
|
|
1
|
+
// GENERATED from clayjs/src/lib/content-dom.js. Edit ClayJS and run `npm run propagate:foundation`.
|
|
2
|
+
|
|
3
|
+
import { capabilitySelector, hasCapability } from './region-capabilities.js'
|
|
4
|
+
|
|
5
|
+
const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
|
|
6
|
+
|
|
7
|
+
function copyControlState(source, copy) {
|
|
8
|
+
if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
|
|
9
|
+
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
10
|
+
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
|
|
11
|
+
if ('checked' in source && 'checked' in copy) copy.checked = source.checked
|
|
12
|
+
if ('selected' in source && 'selected' in copy) copy.selected = source.selected
|
|
13
|
+
if (source.tagName === 'SELECT') {
|
|
14
|
+
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
15
|
+
}
|
|
16
|
+
if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function matchesWithin(node, selector, boundary, inherit) {
|
|
20
|
+
if (inherit) return !!node.closest?.(selector)
|
|
21
|
+
let current = node
|
|
22
|
+
while (current?.nodeType === 1) {
|
|
23
|
+
if (current.matches(selector)) return true
|
|
24
|
+
if (current === boundary) break
|
|
25
|
+
current = current.parentElement
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
|
|
31
|
+
if (source.nodeType === 1 && (
|
|
32
|
+
(inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
33
|
+
(exclude && matchesWithin(source, exclude, boundary, inherit))
|
|
34
|
+
)) return null
|
|
35
|
+
const copy = targetDocument.importNode(source, false)
|
|
36
|
+
maps.cloneToLive.set(copy, source)
|
|
37
|
+
maps.liveToClone.set(source, copy)
|
|
38
|
+
const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
|
|
39
|
+
const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
|
|
40
|
+
if (sourceChildren !== source) {
|
|
41
|
+
maps.cloneToLive.set(copyChildren, sourceChildren)
|
|
42
|
+
maps.liveToClone.set(sourceChildren, copyChildren)
|
|
43
|
+
}
|
|
44
|
+
for (const child of sourceChildren.childNodes || []) {
|
|
45
|
+
const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
|
|
46
|
+
if (childCopy) {
|
|
47
|
+
copyChildren.appendChild(childCopy)
|
|
48
|
+
if (child.nodeType === 1) copyControlState(child, childCopy)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (source.nodeType === 1) copyControlState(source, copy)
|
|
52
|
+
return copy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
|
|
56
|
+
if (!context) throw new TypeError('createContentView requires a DOM context')
|
|
57
|
+
const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
|
|
58
|
+
if (!sourceDocument?.implementation?.createHTMLDocument) {
|
|
59
|
+
throw new TypeError('createContentView requires an HTML DOM implementation')
|
|
60
|
+
}
|
|
61
|
+
const inertDocument = sourceDocument.implementation.createHTMLDocument('')
|
|
62
|
+
const cloneToLive = new WeakMap()
|
|
63
|
+
const liveToClone = new WeakMap()
|
|
64
|
+
const sourceRoot = context.nodeType === 9 ? context.documentElement : context
|
|
65
|
+
if (exclude) inertDocument.documentElement.matches(exclude)
|
|
66
|
+
const capabilityMatch = capabilitySelector(capability)
|
|
67
|
+
const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
|
|
68
|
+
if (context.nodeType === 9 && root) {
|
|
69
|
+
inertDocument.replaceChild(root, inertDocument.documentElement)
|
|
70
|
+
liveToClone.set(context, inertDocument)
|
|
71
|
+
cloneToLive.set(inertDocument, context)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const assertSelector = selector => {
|
|
75
|
+
if (UNSUPPORTED_SELECTOR.test(selector)) {
|
|
76
|
+
throw new Error(`Filtered content queries do not support stateful selector: ${selector}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
root,
|
|
82
|
+
document: inertDocument,
|
|
83
|
+
capability,
|
|
84
|
+
selector: capabilitySelector(capability),
|
|
85
|
+
cloneToLive,
|
|
86
|
+
liveToClone,
|
|
87
|
+
original(node) { return cloneToLive.get(node) || null },
|
|
88
|
+
cloneOf(node) { return liveToClone.get(node) || null },
|
|
89
|
+
query(selector, queryRoot = root) {
|
|
90
|
+
assertSelector(selector)
|
|
91
|
+
return Array.from(queryRoot.querySelectorAll(selector), node => cloneToLive.get(node) || node)
|
|
92
|
+
},
|
|
93
|
+
text(node = root) {
|
|
94
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
95
|
+
return projected?.textContent || ''
|
|
96
|
+
},
|
|
97
|
+
html(node = root) {
|
|
98
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
99
|
+
return projected?.innerHTML ?? ''
|
|
100
|
+
},
|
|
101
|
+
clone(node = root) {
|
|
102
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
103
|
+
return projected ? inertDocument.importNode(projected, true) : null
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function cleanContentClone(node, options) {
|
|
109
|
+
return createContentView(node, options).root
|
|
110
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// GENERATED from clayjs/src/lib/region-capabilities.js. Edit ClayJS and run `npm run propagate:foundation`.
|
|
2
|
+
|
|
3
|
+
export const CAPABILITIES = Object.freeze({
|
|
4
|
+
data: Object.freeze({ tokens: ['no-data'], bundles: ['editor-ui'] }),
|
|
5
|
+
save: Object.freeze({ tokens: ['no-save'], bundles: ['editor-ui'] }),
|
|
6
|
+
snapshot: Object.freeze({ tokens: ['no-snapshot'], bundles: ['editor-ui'] }),
|
|
7
|
+
watch: Object.freeze({ tokens: ['no-watch'], bundles: ['editor-ui'] }),
|
|
8
|
+
undo: Object.freeze({ tokens: ['no-undo'], bundles: ['editor-ui'] }),
|
|
9
|
+
history: Object.freeze({ tokens: [], bundles: ['editor-ui'] }),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const BUNDLES = Object.freeze({
|
|
13
|
+
'editor-ui': Object.freeze(['no-data', 'no-save', 'no-snapshot', 'no-watch', 'no-undo']),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const POLICY_TOKENS = Object.freeze([
|
|
17
|
+
'no-save',
|
|
18
|
+
'no-snapshot',
|
|
19
|
+
'no-trigger-autosave',
|
|
20
|
+
'no-dirty',
|
|
21
|
+
'no-watch',
|
|
22
|
+
'no-undo',
|
|
23
|
+
'no-data',
|
|
24
|
+
'freeze',
|
|
25
|
+
'editor-ui',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
export function hasPolicyToken(element, token) {
|
|
29
|
+
if (!element || element.nodeType !== 1) return false
|
|
30
|
+
const clay = element.getAttribute?.('clay')
|
|
31
|
+
return !!(
|
|
32
|
+
(clay && clay.split(/\s+/).includes(token)) ||
|
|
33
|
+
element.hasAttribute?.(token)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function expandsTo(element, token) {
|
|
38
|
+
if (hasPolicyToken(element, token)) return true
|
|
39
|
+
for (const [bundle, members] of Object.entries(BUNDLES)) {
|
|
40
|
+
if (members.includes(token) && hasPolicyToken(element, bundle)) return true
|
|
41
|
+
}
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function capabilitySelector(capability) {
|
|
46
|
+
const definition = CAPABILITIES[capability]
|
|
47
|
+
if (!definition) throw new Error(`Unknown region capability: ${capability}`)
|
|
48
|
+
return [...definition.tokens, ...definition.bundles]
|
|
49
|
+
.flatMap(token => [`[clay~="${token}"]`, `[${token}]`])
|
|
50
|
+
.join(', ')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function closestWithCapability(node, capability) {
|
|
54
|
+
let element = node && node.nodeType === 1 ? node : node?.parentElement
|
|
55
|
+
while (element && element.nodeType === 1) {
|
|
56
|
+
const definition = CAPABILITIES[capability]
|
|
57
|
+
if (!definition) throw new Error(`Unknown region capability: ${capability}`)
|
|
58
|
+
if (definition.tokens.some(token => expandsTo(element, token)) ||
|
|
59
|
+
definition.bundles.some(bundle => hasPolicyToken(element, bundle))) return element
|
|
60
|
+
element = element.parentElement
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasCapability(node, capability) {
|
|
66
|
+
return !!closestWithCapability(node, capability)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isPolicyAttribute(name) {
|
|
70
|
+
return name === 'clay' || POLICY_TOKENS.includes(name)
|
|
71
|
+
}
|
package/src/morph.js
CHANGED
package/src/place.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Where to put a floating thing next to a page element.
|
|
2
|
+
//
|
|
3
|
+
// COPIED, deliberately, from richclay/src/toolbar-float.js:1-59. Not imported:
|
|
4
|
+
// hypercms must not gain a hard richclay dependency to lay out a popover on a
|
|
5
|
+
// page that contains no rich text at all. The ladder is the same one richclay's
|
|
6
|
+
// floating toolbar has been using in production, and the copy is verbatim apart
|
|
7
|
+
// from the two notes below, so a fix on either side can be read across.
|
|
8
|
+
//
|
|
9
|
+
// Two differences from the original, both because a popover is not a toolbar:
|
|
10
|
+
//
|
|
11
|
+
// 1. A popover has ONE size. richclay measures a horizontal bar and a vertical
|
|
12
|
+
// rail because its toolbar reflows between them; ours does not. Callers pass
|
|
13
|
+
// the same box as both `bar` and `rail`, which collapses the side-rail rung
|
|
14
|
+
// of the ladder into a plain "put it beside the anchor" without changing a
|
|
15
|
+
// line of the math.
|
|
16
|
+
//
|
|
17
|
+
// 2. The `hidden` rung is a backstop here, not the visibility test. It compares
|
|
18
|
+
// the anchor to the viewport, which cannot see an anchor clipped by an
|
|
19
|
+
// overflow: hidden ancestor while still inside the viewport — the case that
|
|
20
|
+
// floats a control over whatever is visible in its place. The inline view
|
|
21
|
+
// decides visibility from an IntersectionObserver, which accounts for
|
|
22
|
+
// ancestor clipping by construction (measured, plan §3.1.2), and only asks
|
|
23
|
+
// for a placement once it knows the anchor is showing.
|
|
24
|
+
|
|
25
|
+
export const GAP = 16
|
|
26
|
+
export const GAP_LADDER = [16, 8, 0]
|
|
27
|
+
export const VIEWPORT_INSET = 8
|
|
28
|
+
export const PLACEMENT_SLACK = 4
|
|
29
|
+
|
|
30
|
+
// Pure placement ladder: above -> below -> beside -> pinned to viewport top.
|
|
31
|
+
// All coords are viewport-relative (the host is position: fixed). `current` gets
|
|
32
|
+
// PLACEMENT_SLACK of hysteresis so the mode doesn't flip-flop at exact
|
|
33
|
+
// boundaries while the page scrolls.
|
|
34
|
+
export function place({ anchor, bar, rail = bar, viewport, current = null }) {
|
|
35
|
+
const slack = (mode) => (current === mode ? PLACEMENT_SLACK : 0)
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
anchor.bottom <= 0 ||
|
|
39
|
+
anchor.top >= viewport.height ||
|
|
40
|
+
anchor.right <= 0 ||
|
|
41
|
+
anchor.left >= viewport.width
|
|
42
|
+
) {
|
|
43
|
+
return { mode: 'hidden', x: 0, y: 0 }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clampX = (width) =>
|
|
47
|
+
Math.max(VIEWPORT_INSET, Math.min(anchor.left, viewport.width - width - VIEWPORT_INSET))
|
|
48
|
+
|
|
49
|
+
const aboveY = anchor.top - GAP - bar.height
|
|
50
|
+
if (aboveY >= VIEWPORT_INSET - slack('above')) {
|
|
51
|
+
return { mode: 'above', x: clampX(bar.width), y: aboveY }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const belowY = anchor.bottom + GAP
|
|
55
|
+
if (belowY + bar.height <= viewport.height - VIEWPORT_INSET + slack('below')) {
|
|
56
|
+
return { mode: 'below', x: clampX(bar.width), y: belowY }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rightSpace = viewport.width - anchor.right - VIEWPORT_INSET
|
|
60
|
+
const leftSpace = anchor.left - VIEWPORT_INSET
|
|
61
|
+
const sides =
|
|
62
|
+
rightSpace >= leftSpace
|
|
63
|
+
? [['rail-right', rightSpace], ['rail-left', leftSpace]]
|
|
64
|
+
: [['rail-left', leftSpace], ['rail-right', rightSpace]]
|
|
65
|
+
|
|
66
|
+
for (const [mode, space] of sides) {
|
|
67
|
+
for (const gap of GAP_LADDER) {
|
|
68
|
+
if (rail.width + gap > space + slack(mode)) continue
|
|
69
|
+
const x =
|
|
70
|
+
mode === 'rail-right'
|
|
71
|
+
? Math.min(anchor.right + gap, viewport.width - rail.width - VIEWPORT_INSET)
|
|
72
|
+
: Math.max(anchor.left - gap - rail.width, VIEWPORT_INSET)
|
|
73
|
+
const maxY = Math.min(anchor.bottom, viewport.height - VIEWPORT_INSET) - rail.height
|
|
74
|
+
const y = Math.max(VIEWPORT_INSET, Math.min(Math.max(anchor.top, VIEWPORT_INSET), maxY))
|
|
75
|
+
return { mode, x, y, gap }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { mode: 'pinned', x: clampX(bar.width), y: VIEWPORT_INSET }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A handle sits ON its anchor's top-right corner, overlapping it, so it reads as
|
|
83
|
+
// belonging to that element rather than floating near it. No ladder: a handle is
|
|
84
|
+
// small enough that it always fits, and moving it around the anchor would make
|
|
85
|
+
// the page feel like it was shuffling under the pointer.
|
|
86
|
+
//
|
|
87
|
+
// The one thing it does need is the viewport clamp. A card flush against the
|
|
88
|
+
// right edge would otherwise put its handle half off-screen, and a heading at
|
|
89
|
+
// the very top of the document would put it above the fold.
|
|
90
|
+
//
|
|
91
|
+
// `prefer` picks which of the two placements below the caller wants:
|
|
92
|
+
// 'auto' adapts to the anchor's size, 'corner' always takes the corner.
|
|
93
|
+
export function placeHandle({ anchor, handle, viewport, inset = 6, prefer = 'auto' }) {
|
|
94
|
+
// Overlapping only reads as "belongs to this" when there is something left to
|
|
95
|
+
// belong to. On a 108x18 link a 36x31 handle covered 28% of it, including the
|
|
96
|
+
// last word — measured in a real browser. So a small anchor gets its handle
|
|
97
|
+
// set BESIDE it instead: still adjacent, nothing hidden.
|
|
98
|
+
//
|
|
99
|
+
// That rule is about a handle floating near a link. A control that BELONGS to
|
|
100
|
+
// a list row wants the opposite and asks for 'corner': measured in Chrome, a
|
|
101
|
+
// 31px strip beside a 36px card row landed wholly outside the row, and beside
|
|
102
|
+
// a run of 60px-pitch pills the strips covered each other. Such a caller pins
|
|
103
|
+
// the control to the corner and decides for itself whether the row is big
|
|
104
|
+
// enough to carry one at all. The clamp below is shared either way.
|
|
105
|
+
const roomy = anchor.height >= handle.height * 1.5 && anchor.width >= handle.width * 2
|
|
106
|
+
if (prefer === 'corner' || roomy) {
|
|
107
|
+
const x = Math.max(
|
|
108
|
+
VIEWPORT_INSET,
|
|
109
|
+
Math.min(anchor.right - handle.width + inset, viewport.width - handle.width - VIEWPORT_INSET)
|
|
110
|
+
)
|
|
111
|
+
const y = Math.max(
|
|
112
|
+
VIEWPORT_INSET,
|
|
113
|
+
Math.min(anchor.top - inset, viewport.height - handle.height - VIEWPORT_INSET)
|
|
114
|
+
)
|
|
115
|
+
return { x, y, mode: 'over' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Beside: to the right when it fits, otherwise to the left, vertically
|
|
119
|
+
// centred on the anchor. Both are clamped, so a handle never leaves the
|
|
120
|
+
// viewport even for an anchor flush against an edge.
|
|
121
|
+
const gap = 4
|
|
122
|
+
const fitsRight = anchor.right + gap + handle.width <= viewport.width - VIEWPORT_INSET
|
|
123
|
+
const rawX = fitsRight ? anchor.right + gap : anchor.left - gap - handle.width
|
|
124
|
+
// Only the left edge needs clamping. fitsRight is the test for "the right
|
|
125
|
+
// placement is inside the viewport", so the right branch is already in bounds
|
|
126
|
+
// by construction; the left branch can run off a narrow anchor's left side.
|
|
127
|
+
const x = Math.max(VIEWPORT_INSET, rawX)
|
|
128
|
+
const centred = anchor.top + anchor.height / 2 - handle.height / 2
|
|
129
|
+
const y = Math.max(VIEWPORT_INSET, Math.min(centred, viewport.height - handle.height - VIEWPORT_INSET))
|
|
130
|
+
return { x, y, mode: fitsRight ? 'beside-right' : 'beside-left' }
|
|
131
|
+
}
|
package/src/platform.js
CHANGED
|
@@ -16,6 +16,11 @@ const CAPABILITIES = {
|
|
|
16
16
|
Mutation: (clay, hyperclay) => clay?.Mutation ?? hyperclay?.Mutation,
|
|
17
17
|
undo: (clay, hyperclay) => clay?.undo ?? hyperclay?.undo,
|
|
18
18
|
onPrepareForSave: (clay, hyperclay) => clay?.addDocumentTransform ?? hyperclay?.onPrepareForSave,
|
|
19
|
+
// Distinct from onPrepareForSave above, and the difference is the whole point:
|
|
20
|
+
// onPrepareForSave is save-only, so a hook there runs AFTER live sync has
|
|
21
|
+
// already broadcast the document. onSnapshot runs on every snapshot, save and
|
|
22
|
+
// sync alike, which is the only place a cleanup can sit and be seen by both.
|
|
23
|
+
onSnapshot: (clay, hyperclay) => clay?.onSnapshot ?? hyperclay?.onSnapshot,
|
|
19
24
|
consent: (clay, hyperclay) => clay?.confirm ?? hyperclay?.consent,
|
|
20
25
|
RichClay: (clay, hyperclay) => clay?.RichClay ?? hyperclay?.RichClay,
|
|
21
26
|
quickcrop: (clay, hyperclay) => clay?.quickcrop ?? hyperclay?.quickcrop,
|
|
@@ -92,6 +97,13 @@ export function platform(name, win) {
|
|
|
92
97
|
export const MUTATION_READY = ['clay:mutation-ready', 'hyperclay:mutation-ready']
|
|
93
98
|
export const LIVESYNC_APPLIED = ['clay:sync-applied', 'hyperclay:livesync-applied']
|
|
94
99
|
|
|
100
|
+
// The readiness pair both clients publish once their public surface is
|
|
101
|
+
// assembled. clayjs fires clay:ready from its loader; hyperclayjs fires
|
|
102
|
+
// hyperclay:ready from exportToWindow. This is the handshake richclay uses for
|
|
103
|
+
// the same race, and it is the only reliable retry signal for a hook whose
|
|
104
|
+
// capability arrives later than hypercms does.
|
|
105
|
+
export const PLATFORM_READY = ['clay:ready', 'hyperclay:ready']
|
|
106
|
+
|
|
95
107
|
// Subscribe to every spelling of one signal, delivering the handler ONCE per
|
|
96
108
|
// occurrence. No client dispatches the pair today, but one that did would make a
|
|
97
109
|
// plain listener on each name fire the handler twice for one event: two form
|
package/src/refresh.js
CHANGED
|
@@ -4,29 +4,37 @@ import { morphForm } from './morph.js'
|
|
|
4
4
|
import { injectDefaults, injectComponents } from './templates.js'
|
|
5
5
|
import { deriveFormRules } from './form-rules.js'
|
|
6
6
|
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
|
-
import { findUnresolved, applyUnresolvedState } from './unresolved.js'
|
|
8
|
-
import { enhanceFields
|
|
7
|
+
import { findUnresolved, applyUnresolvedState, stripReadOnly } from './unresolved.js'
|
|
8
|
+
import { enhanceFields } from './enhance.js'
|
|
9
9
|
import { platform } from './platform.js'
|
|
10
10
|
import { rowIdentitySeeder } from './row-identity.js'
|
|
11
11
|
|
|
12
12
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
13
13
|
|
|
14
14
|
export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
15
|
+
return refreshPageForm(ctx, { ignoreActiveValue })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function refreshPageForm(ctx, { ignoreActiveValue }) {
|
|
15
19
|
// Re-resolve rules every refresh, source-aware: an object source returns the
|
|
16
20
|
// same literal (tagNode null); a token source re-resolves the tag so a
|
|
17
21
|
// livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
|
|
18
22
|
const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
|
|
19
23
|
if (found) {
|
|
20
|
-
// Re-apply the
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
+
// Re-apply the rule upgrade open() performed: the tag holds the author's
|
|
25
|
+
// bare rules, and using them raw would rebind upgraded fields back to
|
|
26
|
+
// textContent mid-session. Route it through the SAME seam open() used
|
|
27
|
+
// (session.js hands found.rules to view.prepareRules) rather than
|
|
28
|
+
// hardcoding one view's upgrade here — that undid a wider upgrade on the
|
|
29
|
+
// first refresh, silently flattening rich text back to plain.
|
|
30
|
+
ctx.pageRules = ctx.view.prepareRules(found.rules)
|
|
24
31
|
ctx.rulesTagNode = found.tagNode
|
|
25
32
|
}
|
|
26
33
|
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
27
34
|
injectDefaults(ctx.doc)
|
|
28
35
|
injectComponents(ctx.doc, ctx.pageRules)
|
|
29
36
|
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
37
|
+
ctx.writeRules = stripReadOnly(ctx.pageRules)
|
|
30
38
|
// Before the extract below, for the same readable-failure reason as open().
|
|
31
39
|
ctx.unresolved = findUnresolved(ctx.pageRoot, ctx.pageRules)
|
|
32
40
|
|
|
@@ -49,7 +57,7 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
49
57
|
seeder.seed(ctx.formRoot)
|
|
50
58
|
// The morph can rebuild field nodes, dropping autosize heights and richclay
|
|
51
59
|
// instances (instances are per-element); re-enhance whatever is new.
|
|
52
|
-
enhanceFields(ctx.formRoot, ctx.doc)
|
|
60
|
+
enhanceFields(ctx.formRoot, ctx.doc, ctx.view?.enhanceFormRichText !== false)
|
|
53
61
|
// morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
|
|
54
62
|
// the last error state so error messages survive across observer-driven refreshes
|
|
55
63
|
// (e.g., the rollback after a failed add).
|
|
@@ -64,14 +72,16 @@ export function installObserver({ debounce = 100, onRefresh }) {
|
|
|
64
72
|
if (!M || typeof M.onAnyChange !== 'function') {
|
|
65
73
|
throw new Error('hypercms: a mutation hub is required (clay.Mutation or hyperclay.Mutation). Load clayjs or hyperclayjs, or just the mutation utility, before initializing hypercms.')
|
|
66
74
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
// The hub hands every subscriber the batch of changes it collected
|
|
76
|
+
// ({ type: 'add' | 'remove' | 'attribute', element, parent, attribute,
|
|
77
|
+
// oldValue, newValue }, see clayjs/src/lib/mutation.js). Discarding them was
|
|
78
|
+
// fine while the only view was the sidebar, which refreshes wholesale. A view
|
|
79
|
+
// that edits the page in place needs to know whether a batch is its own doing,
|
|
80
|
+
// so pass them on and let the view decide.
|
|
81
|
+
const unsub = M.onAnyChange({ debounce }, (changes) => {
|
|
82
|
+
onRefresh(changes)
|
|
71
83
|
})
|
|
72
84
|
return {
|
|
73
85
|
unsubscribe: typeof unsub === 'function' ? unsub : () => {},
|
|
74
|
-
pause() { paused++ },
|
|
75
|
-
resume() { paused = Math.max(0, paused - 1) },
|
|
76
86
|
}
|
|
77
87
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// hypercms's bridge to richclay: how it finds the class, and how it cleans up
|
|
2
|
+
// after the instances it creates.
|
|
3
|
+
//
|
|
4
|
+
// The cleanup exists because hypercms constructs `new RichClay(el, { hyperclay:
|
|
5
|
+
// false })`, and richclay installs its own save bridge only when that option is
|
|
6
|
+
// true (`richclay/src/richclay.js:135`). So on a page whose only richclay usage
|
|
7
|
+
// is hypercms's, NOTHING strips the editor's runtime state out of the document:
|
|
8
|
+
// a contenteditable heading reaches the file and stays editable forever.
|
|
9
|
+
// hypercms created those instances, so hypercms cleans them up.
|
|
10
|
+
|
|
11
|
+
import { platform } from './platform.js'
|
|
12
|
+
|
|
13
|
+
// Written on the live page element at bind time, removed from the snapshot
|
|
14
|
+
// clone. It is the only way to tell an element hypercms made rich from one the
|
|
15
|
+
// author marked up themselves: element identity does not survive cloneNode, so
|
|
16
|
+
// an in-memory Set cannot answer the question on the clone the hook receives.
|
|
17
|
+
//
|
|
18
|
+
// Its VALUE is whether the element held markup before richclay touched it:
|
|
19
|
+
// 'rich' or 'plain'. That has to be frozen here, because Squire wraps the
|
|
20
|
+
// content of a block it binds in a <div>, so from the first click the live DOM
|
|
21
|
+
// can no longer answer whether the author wrote markup in this element.
|
|
22
|
+
export const BOUND_ATTR = 'data-hcms-bound'
|
|
23
|
+
|
|
24
|
+
// The binding's own id, written on the live element and carried into the clone
|
|
25
|
+
// by cloneNode. It is how the save hook, holding a clone, finds the live binding
|
|
26
|
+
// that knows what the author's markup was before richclay rewrote it. Identity
|
|
27
|
+
// cannot cross cloneNode, and the markup is far too big to ride in an attribute.
|
|
28
|
+
export const BOUND_ID_ATTR = 'data-hcms-bound-id'
|
|
29
|
+
|
|
30
|
+
// id -> the live binding. Populated at bind, dropped at unbind, read only by the
|
|
31
|
+
// save hook. Holding the binding rather than a copy of its markup is deliberate:
|
|
32
|
+
// whether the restore should happen at all is a question about the binding's
|
|
33
|
+
// state right now, not about its state when it was created.
|
|
34
|
+
const boundById = new Map()
|
|
35
|
+
|
|
36
|
+
export function registerBinding(id, binding) {
|
|
37
|
+
boundById.set(id, binding)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function releaseBinding(id) {
|
|
41
|
+
boundById.delete(id)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The author's markup for a clone element, or null when the clone must be left
|
|
45
|
+
// exactly as it stands. Null covers all three reasons: no live binding behind
|
|
46
|
+
// it, an editor the author owns, and any content this session has actually
|
|
47
|
+
// changed. See restorable() in inline.js, which asks the same question of the
|
|
48
|
+
// live element at teardown.
|
|
49
|
+
export function pristineMarkupFor(cloneEl) {
|
|
50
|
+
const id = cloneEl.getAttribute(BOUND_ID_ATTR)
|
|
51
|
+
if (!id) return null
|
|
52
|
+
const binding = boundById.get(id)
|
|
53
|
+
if (!binding || !binding.restorable()) return null
|
|
54
|
+
return binding.originalHTML
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Written beside the marker when hypercms's own construct call is what put
|
|
58
|
+
// data-richclay on the element. richclay 0.5.0 tracks the same provenance in
|
|
59
|
+
// data-richclay-runtime-marker and strips the attribute for us, but clayjs and
|
|
60
|
+
// hyperclayjs both vendor 0.4.0, which has no marker and leaves it in the file.
|
|
61
|
+
// data-richclay is richclay's mount selector, so left there it turns every
|
|
62
|
+
// heading anyone clicked into a permanent richclay region. hypercms knew the
|
|
63
|
+
// answer before it touched the element; it does not need to ask.
|
|
64
|
+
export const RC_OWNED_ATTR = 'data-hcms-owns-richclay'
|
|
65
|
+
|
|
66
|
+
export function resolveRichClay(win) {
|
|
67
|
+
return (
|
|
68
|
+
(win && win.richclay && win.richclay.RichClay) ||
|
|
69
|
+
platform('RichClay', win) ||
|
|
70
|
+
(win && typeof win.RichClay === 'function' ? win.RichClay : null)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function markBound(el, heldMarkup, richClayIsOurs) {
|
|
75
|
+
if (!el || typeof el.setAttribute !== 'function') return
|
|
76
|
+
el.setAttribute(BOUND_ATTR, heldMarkup ? 'rich' : 'plain')
|
|
77
|
+
if (richClayIsOurs) el.setAttribute(RC_OWNED_ATTR, 'true')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ⚠ THE ORDER IS LOAD-BEARING. richclay's strip iterates
|
|
81
|
+
// "[data-richclay], [richclay], [editable], [clay-editable]". Remove
|
|
82
|
+
// data-richclay first and a bare <h1> stops matching that selector, the strip
|
|
83
|
+
// skips it, and every runtime attribute richclay wrote reaches the file. Strip
|
|
84
|
+
// FIRST, unmark second. Both orders were run and the bytes compared; the other
|
|
85
|
+
// one ships a permanently contenteditable heading.
|
|
86
|
+
export function cleanRichClayFromSnapshot(cloneDocEl, win) {
|
|
87
|
+
if (!cloneDocEl || typeof cloneDocEl.querySelectorAll !== 'function') return
|
|
88
|
+
const bound = cloneDocEl.querySelectorAll(`[${BOUND_ATTR}]`)
|
|
89
|
+
if (!bound.length) return
|
|
90
|
+
|
|
91
|
+
const RichClay = resolveRichClay(win)
|
|
92
|
+
if (RichClay && typeof RichClay.stripFromClone === 'function') {
|
|
93
|
+
try {
|
|
94
|
+
// A second pass over an already-stripped clone is string-identical, so
|
|
95
|
+
// this stays correct on a page that also uses richclay directly and has
|
|
96
|
+
// richclay's own bridge installed.
|
|
97
|
+
RichClay.stripFromClone(cloneDocEl)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[hypercms] richclay strip failed; editor state may reach the save', err)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const el of bound) {
|
|
103
|
+
// Whether data-richclay comes off is decided by who put it there, not by
|
|
104
|
+
// which richclay is loaded. On 0.5.0 stripFromClone has already removed it
|
|
105
|
+
// and this is a no-op; on the 0.4.0 copies clayjs and hyperclayjs vendor,
|
|
106
|
+
// this is the only thing that removes it. An author's own opt-in never
|
|
107
|
+
// carries the marker, so it is never touched.
|
|
108
|
+
if (el.getAttribute(RC_OWNED_ATTR) === 'true') el.removeAttribute('data-richclay')
|
|
109
|
+
el.removeAttribute(RC_OWNED_ATTR)
|
|
110
|
+
el.removeAttribute(BOUND_ATTR)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A page element that carries the CMS marker but has no binding behind it: a
|
|
115
|
+
// clone the engine made of a bound row, or of a bound element it replaced while
|
|
116
|
+
// rolling back a failed apply. destroy() cannot reach it — destroy belongs to an
|
|
117
|
+
// instance and a clone has none — so it is left permanently editable with page
|
|
118
|
+
// undo switched off for it. The snapshot hook still cleans the saved file; this
|
|
119
|
+
// is the live page.
|
|
120
|
+
export function stripOrphanEditorState(el, win) {
|
|
121
|
+
if (!el || typeof el.removeAttribute !== 'function') return
|
|
122
|
+
const RichClay = resolveRichClay(win)
|
|
123
|
+
if (RichClay && typeof RichClay.stripElement === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
RichClay.stripElement(el)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.warn('[hypercms] richclay element strip failed; the clone stays editable', err)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// An older richclay with no per-element strip. These two are what actually
|
|
131
|
+
// harm the live page; the rest is cosmetic and the snapshot hook removes it
|
|
132
|
+
// from the file either way.
|
|
133
|
+
el.removeAttribute('contenteditable')
|
|
134
|
+
el.removeAttribute('no-undo')
|
|
135
|
+
}
|
|
136
|
+
if (el.getAttribute(RC_OWNED_ATTR) === 'true') el.removeAttribute('data-richclay')
|
|
137
|
+
el.removeAttribute(RC_OWNED_ATTR)
|
|
138
|
+
el.removeAttribute(BOUND_ATTR)
|
|
139
|
+
el.removeAttribute(BOUND_ID_ATTR)
|
|
140
|
+
}
|
package/src/row-identity.js
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
* form can, because it holds a handle the engine never sees, its own row
|
|
9
9
|
* elements.
|
|
10
10
|
*
|
|
11
|
-
* Those elements are stable across the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* new shape, so walking its rows in order gives the new item order with each
|
|
16
|
-
* row still carrying the page node it came from. The permutation falls out.
|
|
11
|
+
* Those elements are stable across the operation itself. Add, remove and move
|
|
12
|
+
* mutate the form DOM in place before commit() writes the page. Walking its
|
|
13
|
+
* rows then gives the new item order with each row still carrying the page node
|
|
14
|
+
* it came from. The permutation falls out.
|
|
17
15
|
*
|
|
18
16
|
* refreshForm does re-render the form, through hyper-morph, when the page
|
|
19
17
|
* changes underneath us (livesync, undo, another session). Rows rebuilt there
|