@panphora/hyper-cms 0.10.0 → 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 +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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"HTML",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./theme.css": "./src/theme.generated.css"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
+
"packed-contract.json",
|
|
27
28
|
"LICENSE",
|
|
28
29
|
"README.md",
|
|
29
30
|
"src/**/*.js",
|
|
@@ -32,10 +33,12 @@
|
|
|
32
33
|
],
|
|
33
34
|
"scripts": {
|
|
34
35
|
"build:theme": "node scripts/build-theme.js",
|
|
35
|
-
"build": "
|
|
36
|
+
"build:js": "node scripts/build-js.js --standalone",
|
|
37
|
+
"build:preview": "node scripts/build-js.js --standalone --hyper-morph-source=../hyper-morph/src/hyper-morph.js --hyper-html-api-source=../hyper-html-api",
|
|
38
|
+
"build": "npm run build:theme && npm run build:js",
|
|
36
39
|
"prepublishOnly": "npm run build",
|
|
37
40
|
"copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
|
|
38
|
-
"copy-to-clayjs": "npm run build && node scripts/copy-to-clayjs.js",
|
|
41
|
+
"copy-to-clayjs": "npm run build:theme && node scripts/copy-to-clayjs.js",
|
|
39
42
|
"test:node": "node --test 'test-node/**/*.test.js'",
|
|
40
43
|
"test": "npm run test:node",
|
|
41
44
|
"demo": "http-server . -p 5694 -c-1 -o /demo/",
|
|
@@ -44,7 +47,7 @@
|
|
|
44
47
|
"test:all": "npm run test:node && npm run test:browser"
|
|
45
48
|
},
|
|
46
49
|
"dependencies": {
|
|
47
|
-
"hyper-html-api": "^0.8.
|
|
50
|
+
"hyper-html-api": "^0.8.1",
|
|
48
51
|
"hyper-morph": "^0.5.0"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"load":["@panphora/hyper-cms"],"members":["src/theme.generated.css"]}
|
package/src/anchor.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Can the inline view hang a control on this page element right now?
|
|
2
|
+
//
|
|
3
|
+
// Deliberately a question about the CURRENT state, never a cached verdict. A
|
|
4
|
+
// target inside a closed <details> or an inactive tab answers false now and
|
|
5
|
+
// true the moment the page reveals it, which is why the inline view re-runs
|
|
6
|
+
// this on every visibility change instead of classifying once at open.
|
|
7
|
+
//
|
|
8
|
+
// The area floor is not cosmetic padding and it is not redundant with
|
|
9
|
+
// IntersectionObserver. Measured in Chrome: an empty inline <span> reports
|
|
10
|
+
// isIntersecting true, ratio 1, on a zero-width box. Without the floor that
|
|
11
|
+
// draws a camera handle on a 0px target.
|
|
12
|
+
export const MIN_ANCHOR_PX = 8
|
|
13
|
+
|
|
14
|
+
// [cms-template] is the hidden list seed; [data-hcms-shell] is the CMS's own
|
|
15
|
+
// chrome, which both the sidebar panel and the inline host carry. Neither is
|
|
16
|
+
// page content, so neither ever gets a control.
|
|
17
|
+
const NEVER_ANCHOR = '[cms-template], [data-hcms-shell]'
|
|
18
|
+
|
|
19
|
+
export function isAnchorable(el) {
|
|
20
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return false
|
|
21
|
+
if (typeof el.closest === 'function' && el.closest(NEVER_ANCHOR)) return false
|
|
22
|
+
const r = el.getBoundingClientRect()
|
|
23
|
+
return r.width >= MIN_ANCHOR_PX && r.height >= MIN_ANCHOR_PX
|
|
24
|
+
}
|
package/src/apply-loop.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { engine } from 'hyper-html-api'
|
|
2
|
+
import { morph } from 'hyper-morph'
|
|
2
3
|
import { fromString as pathFromString, getRuleAtPath } from './path.js'
|
|
3
4
|
import { rowIdentityHooks } from './row-identity.js'
|
|
4
5
|
|
|
5
6
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
7
|
+
const ROLLBACK_UI = 'data-hcms-rollback-ui'
|
|
8
|
+
let rollbackUiId = 0
|
|
6
9
|
|
|
7
10
|
// Engine reads/writes against pageRoot pass skip + templateAttr so the
|
|
8
11
|
// engine never traverses into the form's own DOM, and so [cms-template]
|
|
@@ -15,42 +18,41 @@ const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
|
15
18
|
// rollback restores only the failing subtree; listeners and state elsewhere
|
|
16
19
|
// on the page survive.
|
|
17
20
|
export function applyWithRollback(pageRoot, pageRules, newData, options = {}) {
|
|
18
|
-
|
|
21
|
+
return applyPage(pageRoot, pageRules, newData, options)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function applyPage(pageRoot, pageRules, newData, options) {
|
|
25
|
+
const { shellRoot, structural, structuralPath, formRoot } = options
|
|
19
26
|
// The form's own row elements are the only stable handle across an apply, so
|
|
20
27
|
// when we have the form we let it say which page row each item is. Without it
|
|
21
28
|
// the engine matches by content, which is exact except between two rows that
|
|
22
29
|
// read identically.
|
|
23
30
|
const engineOpts = formRoot ? { ...ENGINE_OPTS, ...rowIdentityHooks(formRoot) } : ENGINE_OPTS
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
if (!structural) {
|
|
27
|
-
try {
|
|
28
|
-
engine.apply(pageRoot, pageRules, newData, engineOpts)
|
|
29
|
-
return { ok: true }
|
|
30
|
-
} catch (err) {
|
|
31
|
-
return { ok: false, error: err }
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Structural path: snapshot the smallest container that the change
|
|
36
|
-
// touches, falling back to non-shell page content if we can't resolve
|
|
37
|
-
// a specific container.
|
|
38
|
-
const target = resolveStructuralTarget(pageRoot, pageRules, structuralPath)
|
|
39
|
-
const subtreeSnapshot = target ? captureChildren(target) : null
|
|
40
|
-
const pageSnapshot = target ? null : captureNonShellSnapshot(pageRoot, shellRoot)
|
|
31
|
+
if (!structural) {
|
|
41
32
|
try {
|
|
42
33
|
engine.apply(pageRoot, pageRules, newData, engineOpts)
|
|
43
34
|
return { ok: true }
|
|
44
35
|
} catch (err) {
|
|
45
|
-
if (subtreeSnapshot) {
|
|
46
|
-
restoreChildren(target, subtreeSnapshot)
|
|
47
|
-
} else if (pageSnapshot) {
|
|
48
|
-
restoreNonShellSnapshot(pageRoot, shellRoot, pageSnapshot)
|
|
49
|
-
}
|
|
50
36
|
return { ok: false, error: err }
|
|
51
37
|
}
|
|
52
|
-
}
|
|
53
|
-
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Structural path: snapshot the smallest container that the change
|
|
41
|
+
// touches, falling back to non-shell page content if we can't resolve
|
|
42
|
+
// a specific container.
|
|
43
|
+
const target = resolveStructuralTarget(pageRoot, pageRules, structuralPath)
|
|
44
|
+
const subtreeSnapshot = target ? captureChildren(target) : null
|
|
45
|
+
const pageSnapshot = target ? null : captureNonShellSnapshot(pageRoot, shellRoot)
|
|
46
|
+
try {
|
|
47
|
+
engine.apply(pageRoot, pageRules, newData, engineOpts)
|
|
48
|
+
return { ok: true }
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (subtreeSnapshot) {
|
|
51
|
+
restoreChildren(target, subtreeSnapshot)
|
|
52
|
+
} else if (pageSnapshot) {
|
|
53
|
+
restoreNonShellSnapshot(pageRoot, shellRoot, pageSnapshot)
|
|
54
|
+
}
|
|
55
|
+
return { ok: false, error: err }
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -126,42 +128,67 @@ function resolveContainerByPath(pageRoot, pageRules, arrPath) {
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
function captureChildren(parent) {
|
|
129
|
-
const
|
|
131
|
+
const nodes = []
|
|
132
|
+
const retained = []
|
|
130
133
|
for (const child of Array.from(parent.childNodes)) {
|
|
131
|
-
|
|
134
|
+
nodes.push(cloneForRollback(child, retained))
|
|
132
135
|
}
|
|
133
|
-
return
|
|
136
|
+
return { nodes, retained }
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
function restoreChildren(parent,
|
|
137
|
-
|
|
138
|
-
for (const clone of
|
|
139
|
+
function restoreChildren(parent, snapshot) {
|
|
140
|
+
const source = parent.cloneNode(false)
|
|
141
|
+
for (const clone of snapshot.nodes) source.appendChild(clone)
|
|
142
|
+
morph(parent, Array.from(source.childNodes), rollbackMorphOptions())
|
|
143
|
+
restoreRetainedUi(parent, snapshot.retained)
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
function captureNonShellSnapshot(pageRoot, shellRoot) {
|
|
142
|
-
const
|
|
147
|
+
const nodes = []
|
|
148
|
+
const retained = []
|
|
143
149
|
for (const child of Array.from(pageRoot.childNodes)) {
|
|
144
150
|
if (child === shellRoot || (shellRoot && child.contains?.(shellRoot))) continue
|
|
145
|
-
|
|
151
|
+
nodes.push(cloneForRollback(child, retained))
|
|
146
152
|
}
|
|
147
|
-
return
|
|
153
|
+
return { nodes, retained }
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
function restoreNonShellSnapshot(pageRoot, shellRoot,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
function restoreNonShellSnapshot(pageRoot, shellRoot, snapshot) {
|
|
157
|
+
const source = pageRoot.cloneNode(false)
|
|
158
|
+
for (const clone of snapshot.nodes) source.appendChild(clone)
|
|
159
|
+
morph(pageRoot, Array.from(source.childNodes), rollbackMorphOptions())
|
|
160
|
+
restoreRetainedUi(pageRoot, snapshot.retained)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cloneForRollback(source, retained) {
|
|
164
|
+
const clone = source.cloneNode(false)
|
|
165
|
+
if (source.nodeType === 1 && source.matches('[editor-ui],[clay~="editor-ui"]')) {
|
|
166
|
+
const id = String(++rollbackUiId)
|
|
167
|
+
clone.setAttribute(ROLLBACK_UI, id)
|
|
168
|
+
retained.push({ id, node: source })
|
|
154
169
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
170
|
+
const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
|
|
171
|
+
const cloneChildren = clone.nodeType === 1 && clone.tagName === 'TEMPLATE' ? clone.content : clone
|
|
172
|
+
for (const child of Array.from(sourceChildren.childNodes || [])) {
|
|
173
|
+
cloneChildren.appendChild(cloneForRollback(child, retained))
|
|
158
174
|
}
|
|
175
|
+
return clone
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
178
|
+
function restoreRetainedUi(root, retained) {
|
|
179
|
+
for (const { id, node } of retained) {
|
|
180
|
+
const placeholder = root.querySelector(`[${ROLLBACK_UI}="${id}"]`)
|
|
181
|
+
if (placeholder === node) node.removeAttribute(ROLLBACK_UI)
|
|
182
|
+
else if (node.isConnected) placeholder?.remove()
|
|
183
|
+
else placeholder?.replaceWith(node)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function rollbackMorphOptions() {
|
|
188
|
+
return {
|
|
189
|
+
morphStyle: 'innerHTML',
|
|
190
|
+
policy: 'raw',
|
|
191
|
+
restoreFocus: false,
|
|
192
|
+
scripts: { handle: false, merge: false },
|
|
165
193
|
}
|
|
166
|
-
return null
|
|
167
194
|
}
|
package/src/enhance.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// whose element holds only text are left untouched. Hosts opt out with
|
|
10
10
|
// cms.open({ richText: false }).
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { BOUND_ATTR, resolveRichClay } from './richclay-bridge.js'
|
|
13
13
|
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
14
14
|
|
|
15
15
|
export function upgradeRichTextRules(rules, pageRoot) {
|
|
@@ -41,6 +41,73 @@ export function upgradeRichTextRules(rules, pageRoot) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// The inline view's upgrade. Identical to the one above for a scalar or an
|
|
45
|
+
// object rule, and different for the one case that one deliberately refuses:
|
|
46
|
+
// the rules inside an array.
|
|
47
|
+
//
|
|
48
|
+
// The narrow version cannot answer "does THIS row's element hold markup" from a
|
|
49
|
+
// document-level querySelector, so it leaves array subtrees alone. Here the item
|
|
50
|
+
// elements are resolved first, so the question IS answerable — once per rule
|
|
51
|
+
// rather than once per row, because the rule language has no per-row rule. ANY
|
|
52
|
+
// row holding markup upgrades the rule for every row, and that costs nothing:
|
|
53
|
+
// @innerHTML round-trips plain text unchanged.
|
|
54
|
+
//
|
|
55
|
+
// It matters because the inline view binds rich text to the row element itself.
|
|
56
|
+
// A row left on textContent loses its links and its bold on the first commit.
|
|
57
|
+
export function upgradeInlineTextRules(rules, pageRoot) {
|
|
58
|
+
if (!pageRoot || rules == null) return rules
|
|
59
|
+
return walk(rules, [pageRoot])
|
|
60
|
+
|
|
61
|
+
function walk(rule, contexts) {
|
|
62
|
+
if (typeof rule === 'string') {
|
|
63
|
+
if (rule.endsWith('[]')) return rule
|
|
64
|
+
if (ruleAttrIndex(rule) !== -1) return rule
|
|
65
|
+
return contexts.some((ctx) => holdsMarkup(ctx, rule)) ? rule + '@innerHTML' : rule
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(rule)) {
|
|
68
|
+
const [selector, shape] = rule
|
|
69
|
+
if (typeof selector !== 'string' || !selector) return rule
|
|
70
|
+
const items = queryAll(pageRoot, selector)
|
|
71
|
+
if (!items.length) return rule
|
|
72
|
+
return [selector, walk(shape, items)]
|
|
73
|
+
}
|
|
74
|
+
if (rule && typeof rule === 'object') {
|
|
75
|
+
const out = Object.create(null)
|
|
76
|
+
for (const [k, v] of Object.entries(rule)) out[k] = walk(v, contexts)
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
return rule
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function holdsMarkup(context, selector) {
|
|
84
|
+
let el = null
|
|
85
|
+
try {
|
|
86
|
+
el = context.querySelector(selector)
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
if (!el) return false
|
|
91
|
+
// An element hypercms has an editor on cannot answer this from its live DOM.
|
|
92
|
+
// Squire wraps the content of a block it binds in a <div>, so a plain
|
|
93
|
+
// paragraph grows a child the instant someone clicks it, and reading that
|
|
94
|
+
// back as author markup upgrades the rule to @innerHTML and turns a
|
|
95
|
+
// plain-text field into a rich-text one just by being clicked. The marker
|
|
96
|
+
// carries the verdict from before the editor arrived, which is the only
|
|
97
|
+
// moment the question had an honest answer. Measured in Chrome: the wrapper
|
|
98
|
+
// lands at t+0 and the upgrade followed on the next observer batch.
|
|
99
|
+
if (el.hasAttribute(BOUND_ATTR)) return el.getAttribute(BOUND_ATTR) === 'rich'
|
|
100
|
+
return el.children.length > 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function queryAll(root, selector) {
|
|
104
|
+
try {
|
|
105
|
+
return [...root.querySelectorAll(selector)]
|
|
106
|
+
} catch (_) {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
44
111
|
// Grow a form textarea to fit its content. Browsers with `field-sizing:
|
|
45
112
|
// content` handle this in CSS (the theme sets it); everywhere else the
|
|
46
113
|
// height is pinned to scrollHeight on populate and on every input.
|
|
@@ -57,15 +124,21 @@ export function autosizeTextarea(el) {
|
|
|
57
124
|
// page ships it (window.richclay / window.hyperclay.RichClay). Without
|
|
58
125
|
// richclay the surface stays a plain contenteditable bound via @innerHTML,
|
|
59
126
|
// which still round-trips markup — richclay only adds the editing chrome.
|
|
60
|
-
|
|
127
|
+
//
|
|
128
|
+
// `formRichText` is the view's, read off view.enhanceFormRichText. The inline
|
|
129
|
+
// view passes false: its form fields are never focused (rich text is edited on
|
|
130
|
+
// the page element itself), and each instance installs five document-level
|
|
131
|
+
// capture listeners that test event.target against their element on every
|
|
132
|
+
// keydown, beforeinput, cut, paste and drop. Twenty rich fields is a hundred
|
|
133
|
+
// dead listeners. The autosize pass runs either way — the popover shows those
|
|
134
|
+
// same fields for every non-text target.
|
|
135
|
+
export function enhanceFields(root, doc, formRichText = true) {
|
|
61
136
|
if (!root || !root.querySelectorAll) return
|
|
62
137
|
root.querySelectorAll('textarea[data-hcms-field]').forEach(autosizeTextarea)
|
|
138
|
+
if (formRichText === false) return
|
|
63
139
|
|
|
64
140
|
const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
|
|
65
|
-
const RichClay =
|
|
66
|
-
(win && win.richclay && win.richclay.RichClay) ||
|
|
67
|
-
platform('RichClay', win) ||
|
|
68
|
-
(win && typeof win.RichClay === 'function' ? win.RichClay : null)
|
|
141
|
+
const RichClay = resolveRichClay(win)
|
|
69
142
|
if (!RichClay) return
|
|
70
143
|
|
|
71
144
|
root.querySelectorAll('[contenteditable][data-hcms-field]').forEach((el) => {
|
package/src/events.js
CHANGED
|
@@ -484,7 +484,7 @@ export function onAdd(arrayPath, ctx) {
|
|
|
484
484
|
pageRules,
|
|
485
485
|
})
|
|
486
486
|
slot.appendChild(itemNode)
|
|
487
|
-
enhanceFields(itemNode, ctx.doc)
|
|
487
|
+
enhanceFields(itemNode, ctx.doc, ctx.view?.enhanceFormRichText !== false)
|
|
488
488
|
updateArrayButtonsVisibility(arrayEl)
|
|
489
489
|
return commitWithUndo(`Add ${arrayPath}`, () =>
|
|
490
490
|
commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
|
|
@@ -558,7 +558,15 @@ export function requestRemove(itemEl, ctx) {
|
|
|
558
558
|
if (message == null) return onRemove(itemEl, ctx)
|
|
559
559
|
const consent = platform('consent') || (typeof window !== 'undefined' && window.consent)
|
|
560
560
|
if (typeof consent === 'function') {
|
|
561
|
-
Promise.resolve(consent(message)).then(() =>
|
|
561
|
+
Promise.resolve(consent(message)).then(() => {
|
|
562
|
+
// The editor can be closed, or switched to another view, while the
|
|
563
|
+
// confirm is still on screen. ctx is dead by then, but itemEl still
|
|
564
|
+
// points into the detached form and commit would apply that stale form
|
|
565
|
+
// over the live page: the card goes, and every other field reverts to
|
|
566
|
+
// whatever it held when the dialog opened.
|
|
567
|
+
if (ctx.closed) return
|
|
568
|
+
onRemove(itemEl, ctx)
|
|
569
|
+
}, () => {})
|
|
562
570
|
} else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
563
571
|
if (window.confirm(message)) onRemove(itemEl, ctx)
|
|
564
572
|
} else {
|
|
@@ -589,6 +597,15 @@ export function onRemove(itemEl, ctx) {
|
|
|
589
597
|
}
|
|
590
598
|
|
|
591
599
|
export function commit(newData, info, ctx) {
|
|
600
|
+
// The last line of defence for any deferred path that reaches a torn-down
|
|
601
|
+
// session: a resolved confirmation, an upload that finished after close, a
|
|
602
|
+
// queued handler. ctx.pageRoot is still the live page.
|
|
603
|
+
//
|
|
604
|
+
// ok:false is deliberate and load-bearing — commitWithUndo records an undo
|
|
605
|
+
// entry on a truthy ok, so returning ok:true here would put a commit that
|
|
606
|
+
// never happened onto the undo stack. Returning before the error branch below
|
|
607
|
+
// is equally deliberate: setError would write into a detached shell.
|
|
608
|
+
if (ctx.closed) return { ok: false, skipped: true, closed: true }
|
|
592
609
|
const fingerprint = stableStringify(newData)
|
|
593
610
|
// Only a plain edit can be skipped on an unchanged fingerprint. A structural
|
|
594
611
|
// operation is never a no-op even when the data is identical: reordering two
|
|
@@ -599,8 +616,13 @@ export function commit(newData, info, ctx) {
|
|
|
599
616
|
return { ok: true, skipped: true }
|
|
600
617
|
}
|
|
601
618
|
|
|
602
|
-
|
|
603
|
-
|
|
619
|
+
// undefined is a legitimate value here: a rules tree that is nothing but a
|
|
620
|
+
// read-only projection strips to it, and engine.apply writes nothing when the
|
|
621
|
+
// rules are undefined. Only a ctx that never had the field at all — the
|
|
622
|
+
// hand-built stubs in older tests — falls back to the unstripped rules.
|
|
623
|
+
const writeRules = Object.hasOwn(ctx, 'writeRules') ? ctx.writeRules : ctx.pageRules
|
|
624
|
+
|
|
625
|
+
const result = applyWithRollback(ctx.pageRoot, writeRules, newData, {
|
|
604
626
|
shellRoot: ctx.shellRoot,
|
|
605
627
|
structural: !!info.structural,
|
|
606
628
|
structuralPath: info.path || null,
|
|
@@ -620,8 +642,72 @@ export function commit(newData, info, ctx) {
|
|
|
620
642
|
return result
|
|
621
643
|
}
|
|
622
644
|
|
|
645
|
+
// Resolve one form leaf by path, and write a value into it. Shared by
|
|
646
|
+
// cms.api.setValue and by the inline view's text commit, which reads the page
|
|
647
|
+
// element richclay just edited and mirrors it into the form before committing.
|
|
648
|
+
export function findLeafField(formRoot, path) {
|
|
649
|
+
const esc = cssEscape(path)
|
|
650
|
+
// Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
|
|
651
|
+
// stamps data-hcms-field on the wrapping container too, a bare attribute
|
|
652
|
+
// match would resolve to the container and lose the value write.
|
|
653
|
+
const leafSel =
|
|
654
|
+
`[data-hcms-path="${esc}"] input[data-hcms-field], ` +
|
|
655
|
+
`[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
|
|
656
|
+
`[data-hcms-path="${esc}"] select[data-hcms-field], ` +
|
|
657
|
+
`[data-hcms-path="${esc}"] img[data-hcms-field], ` +
|
|
658
|
+
`[data-hcms-path="${esc}"] a[data-hcms-field], ` +
|
|
659
|
+
`[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
|
|
660
|
+
// Leaf is the path-stamped element itself (inline-stamped fields):
|
|
661
|
+
`input[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
662
|
+
`textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
663
|
+
`select[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
664
|
+
`img[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
665
|
+
`a[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
666
|
+
`[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
|
|
667
|
+
return formRoot.querySelector(leafSel)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function writeFieldValue(el, value, formRoot, path) {
|
|
671
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
672
|
+
const type = (el.getAttribute('type') || '').toLowerCase()
|
|
673
|
+
if (tag === 'INPUT' && type === 'checkbox') {
|
|
674
|
+
el.checked = value === true || value === 'true'
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
if (tag === 'INPUT' && type === 'radio') {
|
|
678
|
+
// Radios sharing the same path act as a group. Toggle the matching option.
|
|
679
|
+
const esc = cssEscape(path)
|
|
680
|
+
const group = formRoot.querySelectorAll(
|
|
681
|
+
`[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
|
|
682
|
+
)
|
|
683
|
+
if (group.length) {
|
|
684
|
+
group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
|
|
685
|
+
} else {
|
|
686
|
+
el.checked = String(el.value) === String(value ?? '')
|
|
687
|
+
}
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
if (tag === 'IMG') {
|
|
691
|
+
el.src = value == null ? '' : String(value)
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (tag === 'A') {
|
|
695
|
+
el.href = value == null ? '' : String(value)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
if (el.hasAttribute && el.hasAttribute('contenteditable')) {
|
|
699
|
+
el.innerHTML = value == null ? '' : String(value)
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
if ('value' in el) {
|
|
703
|
+
el.value = value == null ? '' : String(value)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
el.textContent = value == null ? '' : String(value)
|
|
707
|
+
}
|
|
708
|
+
|
|
623
709
|
export function extractFormData(ctx) {
|
|
624
|
-
const raw = engine.extract(ctx.formRoot, ctx.formRules)
|
|
710
|
+
const raw = engine.extract(ctx.formRoot, ctx.formRules, { exclude: null })
|
|
625
711
|
return coerceBooleans(raw, ctx.formRules)
|
|
626
712
|
}
|
|
627
713
|
|
|
@@ -862,7 +948,7 @@ function stableStringify(value) {
|
|
|
862
948
|
})
|
|
863
949
|
}
|
|
864
950
|
|
|
865
|
-
function cssEscape(value) {
|
|
951
|
+
export function cssEscape(value) {
|
|
866
952
|
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
|
|
867
953
|
return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
|
|
868
954
|
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The two clone hooks hypercms installs on the host client, and the retry that
|
|
2
|
+
// covers a client arriving after hypercms does.
|
|
3
|
+
//
|
|
4
|
+
// Their own module rather than private state inside hypercms.js, because the
|
|
5
|
+
// inline view has to install the snapshot hook too: by the time someone clicks
|
|
6
|
+
// a heading the client is certainly loaded, which makes the bind the reliable
|
|
7
|
+
// second trigger for a page that lost the readiness race. Importing that back
|
|
8
|
+
// out of the entry module would have made the entry module part of a cycle.
|
|
9
|
+
|
|
10
|
+
import { platform, onPlatformEvent, PLATFORM_READY } from './platform.js'
|
|
11
|
+
import { BOUND_ID_ATTR, cleanRichClayFromSnapshot, pristineMarkupFor } from './richclay-bridge.js'
|
|
12
|
+
import { SESSION_OPEN_CLASS } from './shell.js'
|
|
13
|
+
|
|
14
|
+
// Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
|
|
15
|
+
// (the shell element itself is [save-remove], but the hcms-open class lives on
|
|
16
|
+
// <body>, outside the stripped subtree). Registered once at first open():
|
|
17
|
+
// onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
|
|
18
|
+
// and removing absent classes is a no-op when the shell is closed. Guarded so it
|
|
19
|
+
// degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
|
|
20
|
+
let prepareHookInstalled = false
|
|
21
|
+
export function installSavePrepareHook() {
|
|
22
|
+
if (prepareHookInstalled) return
|
|
23
|
+
const onPrepareForSave = platform('onPrepareForSave')
|
|
24
|
+
if (typeof onPrepareForSave !== 'function') return
|
|
25
|
+
onPrepareForSave((clonedDocEl) => {
|
|
26
|
+
restorePristineMarkup(clonedDocEl)
|
|
27
|
+
stripSessionChrome(clonedDocEl)
|
|
28
|
+
})
|
|
29
|
+
prepareHookInstalled = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// hypercms's own chrome lives on <body>, outside the [save-remove] shell, so it
|
|
33
|
+
// has to be taken off the clone by hand. Both hooks call this: the save hook
|
|
34
|
+
// because a client may offer onPrepareForSave and no onSnapshot, and the
|
|
35
|
+
// snapshot hook because that is the only one live sync reaches, and without it
|
|
36
|
+
// the session's own classes are broadcast to every other browser. Removing an
|
|
37
|
+
// absent class is a no-op, so running twice on a save costs nothing.
|
|
38
|
+
function stripSessionChrome(clonedDocEl) {
|
|
39
|
+
const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
|
|
40
|
+
if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left', SESSION_OPEN_CLASS)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A save can land while somebody is mid-session with an editor open on a
|
|
44
|
+
// heading. richclay rewrites a block's markup simply by being attached to it, so
|
|
45
|
+
// without this the file records that rewriting as though the author had typed
|
|
46
|
+
// it. The live editors are deliberately untouched: this is the clone, and the
|
|
47
|
+
// person editing keeps their caret and their selection.
|
|
48
|
+
function restorePristineMarkup(clonedDocEl) {
|
|
49
|
+
if (!clonedDocEl || typeof clonedDocEl.querySelectorAll !== 'function') return
|
|
50
|
+
for (const el of clonedDocEl.querySelectorAll(`[${BOUND_ID_ATTR}]`)) {
|
|
51
|
+
const html = pristineMarkupFor(el)
|
|
52
|
+
if (html !== null) el.innerHTML = html
|
|
53
|
+
el.removeAttribute(BOUND_ID_ATTR)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clean up after the richclay instances hypercms itself creates on page
|
|
58
|
+
// elements. Installed once at first open, alongside the save-prepare hook above,
|
|
59
|
+
// and for the same reason: the host client may not be loaded yet at module
|
|
60
|
+
// evaluation. onSnapshot rather than onPrepareForSave, because this has to run
|
|
61
|
+
// for live sync too — a hook on the save-only path fires after the editor's
|
|
62
|
+
// leftovers have already been broadcast to every other browser.
|
|
63
|
+
let snapshotHookInstalled = false
|
|
64
|
+
export function installSnapshotHook() {
|
|
65
|
+
if (snapshotHookInstalled) return
|
|
66
|
+
const onSnapshot = platform('onSnapshot')
|
|
67
|
+
if (typeof onSnapshot !== 'function') return
|
|
68
|
+
onSnapshot((clonedDocEl) => {
|
|
69
|
+
restorePristineMarkup(clonedDocEl)
|
|
70
|
+
cleanRichClayFromSnapshot(clonedDocEl, typeof window !== 'undefined' ? window : null)
|
|
71
|
+
stripSessionChrome(clonedDocEl)
|
|
72
|
+
})
|
|
73
|
+
snapshotHookInstalled = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What open() calls: install both, and arm the retry when either could not be
|
|
77
|
+
// installed yet.
|
|
78
|
+
export function installHooks() {
|
|
79
|
+
installSavePrepareHook()
|
|
80
|
+
installSnapshotHook()
|
|
81
|
+
if (!prepareHookInstalled || !snapshotHookInstalled) armHookRetry()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Both hooks read a capability that can arrive after hypercms does, and both
|
|
85
|
+
// return without recording success when it is missing. Retry on the clients'
|
|
86
|
+
// readiness pair and disarm once both are in, so a page that lost the import
|
|
87
|
+
// race still gets its clone cleanup.
|
|
88
|
+
let offPlatformReady = null
|
|
89
|
+
function armHookRetry() {
|
|
90
|
+
if (offPlatformReady || typeof document === 'undefined') return
|
|
91
|
+
const retry = () => {
|
|
92
|
+
installSavePrepareHook()
|
|
93
|
+
installSnapshotHook()
|
|
94
|
+
if (prepareHookInstalled && snapshotHookInstalled) {
|
|
95
|
+
offPlatformReady?.()
|
|
96
|
+
offPlatformReady = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
offPlatformReady = onPlatformEvent(document, PLATFORM_READY, retry)
|
|
100
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as hypercms from './hypercms-bundle.js'
|
|
2
|
+
|
|
3
|
+
if (typeof window !== 'undefined' && !window.__hyperclayNoAutoExport) {
|
|
4
|
+
window.hyperclay = window.hyperclay || {}
|
|
5
|
+
window.hyperclay.hypercms = hypercms.cms
|
|
6
|
+
window.h = window.hyperclay
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const cms = hypercms.cms
|
|
10
|
+
export default hypercms
|