@panphora/hyper-cms 0.11.5 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -6
- package/dist/hypercms.min.js +169 -84
- package/package.json +5 -3
- package/src/apply-loop.js +3 -2
- package/src/catalogs/colors.js +293 -0
- package/src/catalogs/fonts.js +126 -0
- package/src/catalogs/index.js +3 -0
- package/src/catalogs/variant-icons.js +24 -0
- package/src/color-control.js +196 -0
- package/src/events.js +25 -2
- package/src/font-control.js +142 -0
- package/src/fonts-link.js +83 -0
- package/src/form-builder.js +85 -3
- package/src/form-rules.js +48 -3
- package/src/hypercms.js +111 -21
- package/src/page-root.js +28 -0
- package/src/refresh.js +4 -1
- package/src/session.js +51 -3
- package/src/targets.js +55 -13
- package/src/templates.js +100 -6
- package/src/theme/pixel-quiet.overrides.css +91 -0
- package/src/theme.generated.css +91 -0
- package/src/toggle.js +80 -296
- package/src/variant-control.js +46 -0
- package/src/views/inline-layer.js +71 -15
- package/src/views/inline.js +87 -17
package/src/form-rules.js
CHANGED
|
@@ -5,12 +5,19 @@ import {
|
|
|
5
5
|
componentForScalarRule,
|
|
6
6
|
winningScalarArrayComponent,
|
|
7
7
|
} from './templates.js'
|
|
8
|
+
import { getRuleAtPath } from './path.js'
|
|
8
9
|
|
|
9
10
|
const TAG_PROP_MAP = {
|
|
10
11
|
IMG: 'src',
|
|
11
12
|
A: 'href',
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
// The path with every row index replaced by "*": what a field IS, whichever
|
|
16
|
+
// row it sits in. Form rules scope repeated keys by it.
|
|
17
|
+
export function shapeOf(pathArr) {
|
|
18
|
+
return pathArr.map((seg) => (typeof seg === 'number' || /^[0-9]+$/.test(String(seg)) ? '*' : String(seg))).join('.')
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export function fieldPropertyFor(el) {
|
|
15
22
|
if (!el) return 'value'
|
|
16
23
|
const tag = (el.tagName || '').toUpperCase()
|
|
@@ -81,7 +88,29 @@ function cssEscape(value) {
|
|
|
81
88
|
|
|
82
89
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
83
90
|
|
|
91
|
+
// How many scalar leaves, anywhere in the rules, end in each key.
|
|
92
|
+
function countLeafKeys(pageRules) {
|
|
93
|
+
const counts = new Map()
|
|
94
|
+
walk(pageRules, null)
|
|
95
|
+
return counts
|
|
96
|
+
|
|
97
|
+
function walk(rule, key) {
|
|
98
|
+
if (typeof rule === 'string') {
|
|
99
|
+
if (typeof key === 'string' && !rule.endsWith('[]')) counts.set(key, (counts.get(key) || 0) + 1)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(rule)) {
|
|
103
|
+
walk(rule[1], null)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (rule && typeof rule === 'object') {
|
|
107
|
+
for (const [k, child] of Object.entries(rule)) walk(child, k)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
export function deriveFormRules(pageRules, doc) {
|
|
113
|
+
const leafKeyCounts = countLeafKeys(pageRules)
|
|
85
114
|
return derive(pageRules, [])
|
|
86
115
|
|
|
87
116
|
function derive(rule, pathArr) {
|
|
@@ -106,15 +135,30 @@ export function deriveFormRules(pageRules, doc) {
|
|
|
106
135
|
const key = pathArr.length ? pathArr[pathArr.length - 1] : null
|
|
107
136
|
const fieldKey = typeof key === 'string' ? key : '__value'
|
|
108
137
|
const inlineEl = findInlineFieldEl(pathArr, fieldKey)
|
|
109
|
-
if (inlineEl) return fieldSelectorFor(inlineEl, fieldKey)
|
|
138
|
+
if (inlineEl) return scopeRepeatedKey(pathArr, fieldKey, fieldSelectorFor(inlineEl, fieldKey))
|
|
110
139
|
// No path-bound override: resolve the component this rule opts into
|
|
111
140
|
// (@scalar / @image / @file) and derive the selector from THAT template's
|
|
112
141
|
// leaf tag, so an @image field reads img@src and an @file field reads
|
|
113
142
|
// a@href, while a plain scalar still respects a global @scalar override
|
|
114
143
|
// (e.g. a <textarea>) instead of assuming <input>.
|
|
115
144
|
const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr, pageRules), fieldKey)
|
|
116
|
-
if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
|
|
117
|
-
return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
|
|
145
|
+
if (shapeEl) return scopeRepeatedKey(pathArr, fieldKey, fieldSelectorFor(shapeEl, fieldKey))
|
|
146
|
+
return scopeRepeatedKey(pathArr, fieldKey, `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Two scalars with the same last key (hero.background, features.background,
|
|
150
|
+
// or a card's title and its links' titles) stamp the same data-hcms-field, so
|
|
151
|
+
// a bare key selector reads the first one for all of them and a commit copies
|
|
152
|
+
// one field's value into another. A repeated key is scoped by the field's
|
|
153
|
+
// shape (its path with row indices as "*"), matched exactly, on the field
|
|
154
|
+
// itself or on the wrapper around it.
|
|
155
|
+
function scopeRepeatedKey(pathArr, fieldKey, selector) {
|
|
156
|
+
if ((leafKeyCounts.get(fieldKey) || 0) < 2) return selector
|
|
157
|
+
const scope = `[data-hcms-path-shape="${cssEscape(shapeOf(pathArr))}"]`
|
|
158
|
+
const at = selector.lastIndexOf('@')
|
|
159
|
+
const sel = at === -1 ? selector : selector.slice(0, at)
|
|
160
|
+
const prop = at === -1 ? '' : selector.slice(at)
|
|
161
|
+
return `${scope} ${sel}, ${sel}${scope}${prop}`
|
|
118
162
|
}
|
|
119
163
|
|
|
120
164
|
function scalarArrayRule(rule, pathArr) {
|
|
@@ -190,6 +234,7 @@ export function deriveFormRules(pageRules, doc) {
|
|
|
190
234
|
const candidates = [pathStr, wildcardKey]
|
|
191
235
|
for (let i = pathArr.length - 1; i >= 0; i--) {
|
|
192
236
|
const slice = pathArr.slice(0, i).map((s) => (typeof s === 'number' ? '*' : s))
|
|
237
|
+
if (shapeKindOf(getRuleAtPath(pageRules, slice)) === 'object') candidates.push(slice.join('.'))
|
|
193
238
|
slice.push('*')
|
|
194
239
|
candidates.push(slice.join('.'))
|
|
195
240
|
}
|
package/src/hypercms.js
CHANGED
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
teardownSession,
|
|
21
21
|
nextSearchAfterClose,
|
|
22
22
|
shouldAutoOpenFromSearch,
|
|
23
|
+
modeLifecycle,
|
|
24
|
+
canWriteNow,
|
|
25
|
+
pendingWork,
|
|
26
|
+
quiesceSession,
|
|
27
|
+
resumeSession,
|
|
23
28
|
} from './session.js'
|
|
24
29
|
import { findLeafField, writeFieldValue } from './events.js'
|
|
25
30
|
import { maybeInjectToggle } from './toggle.js'
|
|
@@ -53,17 +58,36 @@ const VIEWS = {
|
|
|
53
58
|
inline: createInlineView,
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
// The page says how it wants to be edited, on the rules tag the session will
|
|
62
|
+
// use: data-cms-view="inline". No tag to read it from (a literal rules object),
|
|
63
|
+
// no attribute, or "sidebar" means the sidebar. An unknown value is a page
|
|
64
|
+
// authoring mistake, not a caller error, so it warns and opens the sidebar.
|
|
65
|
+
function declaredView(doc, rules) {
|
|
66
|
+
const source = rules !== undefined ? rules : 'cms'
|
|
67
|
+
if (typeof source !== 'string') return 'sidebar'
|
|
68
|
+
const declared = engine.findRules(doc, source)?.tagNode?.getAttribute?.('data-cms-view')
|
|
69
|
+
if (!declared || declared === 'sidebar') return 'sidebar'
|
|
70
|
+
if (VIEWS[declared]) return declared
|
|
71
|
+
console.warn(`hypercms: unknown data-cms-view "${declared}" on the rules tag, opening the sidebar`)
|
|
72
|
+
return 'sidebar'
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
export function open(opts = {}) {
|
|
76
|
+
return openSession(opts)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function modeExit(doc) {
|
|
80
|
+
const win = doc && doc.defaultView
|
|
81
|
+
if (!modeLifecycle(win)) return null
|
|
82
|
+
const setMode = win.clay && win.clay.setMode
|
|
83
|
+
if (typeof setMode !== 'function') return null
|
|
84
|
+
return setMode.call(win.clay, 'view')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openSession(opts = {}, admitted = false) {
|
|
57
88
|
// Everything that can be rejected is rejected before anything is destroyed. A
|
|
58
89
|
// bad view name or a missing page must not leave a caller with the session
|
|
59
90
|
// they had already torn down.
|
|
60
|
-
// A bare open() means "make sure the editor is up", not "put it in the
|
|
61
|
-
// sidebar". Defaulting to sidebar turned every such call into a view switch.
|
|
62
|
-
const viewName = opts.view || (state.isOpen ? state.ctx.view.name : 'sidebar')
|
|
63
|
-
const makeView = VIEWS[viewName]
|
|
64
|
-
if (!makeView) {
|
|
65
|
-
throw new Error(`hypercms: unknown view "${viewName}" (expected ${Object.keys(VIEWS).join(' or ')})`)
|
|
66
|
-
}
|
|
67
91
|
// A switch changes the presentation of one session, not the session. Someone
|
|
68
92
|
// calling open({ view: 'sidebar' }) means "show me this in the sidebar"; they
|
|
69
93
|
// are not restating the pageRoot, the rules and the callbacks they opened
|
|
@@ -71,12 +95,23 @@ export function open(opts = {}) {
|
|
|
71
95
|
// document.body with the default rules tag and stops calling onChange.
|
|
72
96
|
// state.opts holds the active session's options and teardown clears it, so it
|
|
73
97
|
// has to be read here, before anything is torn down.
|
|
74
|
-
const
|
|
98
|
+
const merged = state.isOpen ? { ...state.opts, ...opts } : opts
|
|
75
99
|
|
|
76
|
-
const pageRoot =
|
|
100
|
+
const pageRoot = merged.pageRoot || (typeof document !== 'undefined' ? document : null)
|
|
77
101
|
if (!pageRoot) throw new Error('hypercms: no pageRoot available')
|
|
78
|
-
const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
|
|
102
|
+
const doc = (pageRoot.nodeType === 9 ? pageRoot : pageRoot.ownerDocument) || (typeof document !== 'undefined' ? document : null)
|
|
79
103
|
if (!doc) throw new Error('hypercms: no document available')
|
|
104
|
+
if (!admitted && !canWriteNow(doc.defaultView)) return { ok: false, code: 'not-allowed' }
|
|
105
|
+
|
|
106
|
+
// A bare open() means "make sure the editor is up", not "put it in the
|
|
107
|
+
// sidebar": with a session up it keeps that view, otherwise it opens the view
|
|
108
|
+
// the page declares.
|
|
109
|
+
const viewName = opts.view || (state.isOpen ? state.ctx.view.name : declaredView(doc, merged.rules))
|
|
110
|
+
const makeView = VIEWS[viewName]
|
|
111
|
+
if (!makeView) {
|
|
112
|
+
throw new Error(`hypercms: unknown view "${viewName}" (expected ${Object.keys(VIEWS).join(' or ')})`)
|
|
113
|
+
}
|
|
114
|
+
const effective = state.isOpen ? { ...merged, view: viewName } : opts
|
|
80
115
|
|
|
81
116
|
// Reopening the view that is already up is a silent no-op, not a warning: a
|
|
82
117
|
// host wiring a button to open({ view }) should be able to call it without
|
|
@@ -87,7 +122,7 @@ export function open(opts = {}) {
|
|
|
87
122
|
let carriedFocus = null
|
|
88
123
|
let previous = null
|
|
89
124
|
if (state.isOpen) {
|
|
90
|
-
if (state.ctx.view.name === viewName) return
|
|
125
|
+
if (state.ctx.view.name === viewName) return { ok: true, view: viewName }
|
|
91
126
|
carriedFocus = state.ctx.previouslyFocused
|
|
92
127
|
previous = state.ctx.view.name
|
|
93
128
|
teardownSession(state.ctx, { restoreFocus: false, updateUrl: false, reason: 'switch' })
|
|
@@ -101,8 +136,8 @@ export function open(opts = {}) {
|
|
|
101
136
|
doc,
|
|
102
137
|
pageRoot,
|
|
103
138
|
opts: effective,
|
|
104
|
-
onCloseRequested: () => close(),
|
|
105
|
-
onViewRequested: (name) =>
|
|
139
|
+
onCloseRequested: () => modeExit(doc) || close(),
|
|
140
|
+
onViewRequested: (name) => openSession({ view: name }, true),
|
|
106
141
|
})
|
|
107
142
|
// Where focus was before the FIRST open, carried across the switch. Closing
|
|
108
143
|
// the second view should return the person where they started, not to
|
|
@@ -134,11 +169,19 @@ export function open(opts = {}) {
|
|
|
134
169
|
teardownSession(ctx, { dispatch: false, restoreFocus: !!carriedFocus, updateUrl: false })
|
|
135
170
|
throw err
|
|
136
171
|
}
|
|
172
|
+
return { ok: true, view: viewName }
|
|
137
173
|
}
|
|
138
174
|
|
|
139
175
|
export function close() {
|
|
140
|
-
if (!state.isOpen) return
|
|
176
|
+
if (!state.isOpen) return { ok: false, code: 'no-session' }
|
|
177
|
+
const doc = state.ctx.doc
|
|
178
|
+
if (modeLifecycle(doc && doc.defaultView)) {
|
|
179
|
+
const pending = pendingWork(state.ctx)
|
|
180
|
+
if (pending.length) return { ok: false, code: 'pending-input', pending }
|
|
181
|
+
if (!canWriteNow(doc && doc.defaultView)) return { ok: false, code: 'busy' }
|
|
182
|
+
}
|
|
141
183
|
teardownSession(state.ctx)
|
|
184
|
+
return { ok: true }
|
|
142
185
|
}
|
|
143
186
|
|
|
144
187
|
export function refresh() {
|
|
@@ -157,13 +200,58 @@ export function currentView() {
|
|
|
157
200
|
return state.isOpen && state.ctx ? state.ctx.view.name : null
|
|
158
201
|
}
|
|
159
202
|
|
|
203
|
+
export function prepareModeLease({ doc, pageRoot, opts = {} } = {}) {
|
|
204
|
+
let owned = null
|
|
205
|
+
let disposed = false
|
|
206
|
+
return {
|
|
207
|
+
ready: Promise.resolve(),
|
|
208
|
+
enable() {
|
|
209
|
+
if (disposed) return
|
|
210
|
+
const target = { ...opts }
|
|
211
|
+
if (pageRoot) target.pageRoot = pageRoot
|
|
212
|
+
if (!target.pageRoot) target.pageRoot = (doc && doc.body) || doc || null
|
|
213
|
+
openSession(target, true)
|
|
214
|
+
owned = state.ctx
|
|
215
|
+
},
|
|
216
|
+
quiesce() {
|
|
217
|
+
if (disposed || !owned || state.ctx !== owned) return { ok: true }
|
|
218
|
+
return quiesceSession(owned)
|
|
219
|
+
},
|
|
220
|
+
resume() {
|
|
221
|
+
if (disposed || !owned || state.ctx !== owned) return
|
|
222
|
+
resumeSession(owned)
|
|
223
|
+
},
|
|
224
|
+
dispose() {
|
|
225
|
+
if (disposed) return
|
|
226
|
+
disposed = true
|
|
227
|
+
if (owned && state.ctx === owned) teardownSession(owned)
|
|
228
|
+
owned = null
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function modeParticipant(options = {}) {
|
|
234
|
+
return {
|
|
235
|
+
id: 'hypercms',
|
|
236
|
+
order: 1000,
|
|
237
|
+
prepare() {
|
|
238
|
+
return prepareModeLease(options)
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function assertAdmitting() {
|
|
244
|
+
if (!state.isOpen) throw new Error('hypercms: cms is not open')
|
|
245
|
+
if (state.ctx.quiesced) throw new Error('hypercms: the CMS is not admitting new changes')
|
|
246
|
+
}
|
|
247
|
+
|
|
160
248
|
export const api = {
|
|
161
249
|
getData() {
|
|
162
250
|
if (!state.isOpen) return null
|
|
163
251
|
return extractFormData(state.ctx)
|
|
164
252
|
},
|
|
165
253
|
setValue(path, value) {
|
|
166
|
-
|
|
254
|
+
assertAdmitting()
|
|
167
255
|
const ctx = state.ctx
|
|
168
256
|
const pathArr = pathUtil.fromString(path)
|
|
169
257
|
const rule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr)
|
|
@@ -177,11 +265,11 @@ export const api = {
|
|
|
177
265
|
commit(extractFormData(ctx), { path, structural: false }, ctx)
|
|
178
266
|
},
|
|
179
267
|
addItem(arrayPath) {
|
|
180
|
-
|
|
268
|
+
assertAdmitting()
|
|
181
269
|
evOnAdd(arrayPath, state.ctx)
|
|
182
270
|
},
|
|
183
271
|
removeItem(itemPath) {
|
|
184
|
-
|
|
272
|
+
assertAdmitting()
|
|
185
273
|
const ctx = state.ctx
|
|
186
274
|
const pathArr = pathUtil.fromString(itemPath)
|
|
187
275
|
// Validate path: must be an array item — last segment is a non-negative
|
|
@@ -201,7 +289,7 @@ export const api = {
|
|
|
201
289
|
},
|
|
202
290
|
refresh,
|
|
203
291
|
_commit() {
|
|
204
|
-
if (!state.isOpen) return
|
|
292
|
+
if (!state.isOpen || state.ctx.quiesced) return
|
|
205
293
|
const ctx = state.ctx
|
|
206
294
|
restampAllSiblings(ctx.formRoot)
|
|
207
295
|
return commitWithUndo('Update', () =>
|
|
@@ -232,6 +320,7 @@ export function maybeAutoOpen() {
|
|
|
232
320
|
if (state.isOpen) return
|
|
233
321
|
whenReady(() => {
|
|
234
322
|
if (state.isOpen) return
|
|
323
|
+
if (!canWriteNow()) return
|
|
235
324
|
try {
|
|
236
325
|
open()
|
|
237
326
|
} catch (err) {
|
|
@@ -325,9 +414,6 @@ maybeInjectToggle({
|
|
|
325
414
|
close,
|
|
326
415
|
isOpen,
|
|
327
416
|
getTheme: () => state.opts?.theme,
|
|
328
|
-
// The same map open() resolves a view name against, so the split button
|
|
329
|
-
// offers exactly the views this build can actually render.
|
|
330
|
-
views: Object.keys(VIEWS),
|
|
331
417
|
hasRules: (doc) => !!engine.findRules(doc, 'cms'),
|
|
332
418
|
})
|
|
333
419
|
|
|
@@ -336,6 +422,10 @@ const cms = {
|
|
|
336
422
|
close,
|
|
337
423
|
refresh,
|
|
338
424
|
api,
|
|
425
|
+
// Whether this page declares CMS rules, so a launcher outside the CMS knows to offer it.
|
|
426
|
+
hasRules: (doc = document) => !!engine.findRules(doc, 'cms'),
|
|
427
|
+
prepareModeLease,
|
|
428
|
+
modeParticipant,
|
|
339
429
|
get isOpen() { return state.isOpen },
|
|
340
430
|
currentView,
|
|
341
431
|
// Power-user exports (mostly for testing + advanced integration)
|
package/src/page-root.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The CMS reads and writes the whole document, so a rule can bind <html> (the
|
|
2
|
+
// page's theme properties) or the <head>. Everything that needs an element to
|
|
3
|
+
// stand for the page (event payloads and dispatch, structural rollback, the
|
|
4
|
+
// inline view's page listeners) keeps using <body>, where the content lives.
|
|
5
|
+
export function contentRoot(pageRoot) {
|
|
6
|
+
if (pageRoot && pageRoot.nodeType === 9) return pageRoot.body || pageRoot.documentElement
|
|
7
|
+
return pageRoot
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A rule with no selector ("@data-mode", ".") addresses the page root itself.
|
|
11
|
+
// A Document has no attributes or text of its own, so outside a list those
|
|
12
|
+
// rules address <body>, which is what they addressed when <body> was the
|
|
13
|
+
// default root. Inside a list the row is the context and nothing changes.
|
|
14
|
+
export function rootRules(rules, pageRoot) {
|
|
15
|
+
if (!pageRoot || pageRoot.nodeType !== 9) return rules
|
|
16
|
+
return walk(rules)
|
|
17
|
+
function walk(rule) {
|
|
18
|
+
if (typeof rule === 'string') {
|
|
19
|
+
if (rule === '.') return 'body'
|
|
20
|
+
if (rule.startsWith('@')) return `body${rule}`
|
|
21
|
+
return rule
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(rule) || !rule || typeof rule !== 'object') return rule
|
|
24
|
+
const out = {}
|
|
25
|
+
for (const [k, v] of Object.entries(rule)) out[k] = walk(v)
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/refresh.js
CHANGED
|
@@ -8,6 +8,8 @@ import { findUnresolved, applyUnresolvedState, stripReadOnly } from './unresolve
|
|
|
8
8
|
import { enhanceFields } from './enhance.js'
|
|
9
9
|
import { platform } from './platform.js'
|
|
10
10
|
import { rowIdentitySeeder } from './row-identity.js'
|
|
11
|
+
import { reconcileFontsLink } from './fonts-link.js'
|
|
12
|
+
import { rootRules } from './page-root.js'
|
|
11
13
|
|
|
12
14
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
13
15
|
|
|
@@ -27,7 +29,7 @@ function refreshPageForm(ctx, { ignoreActiveValue }) {
|
|
|
27
29
|
// (session.js hands found.rules to view.prepareRules) rather than
|
|
28
30
|
// hardcoding one view's upgrade here — that undid a wider upgrade on the
|
|
29
31
|
// first refresh, silently flattening rich text back to plain.
|
|
30
|
-
ctx.pageRules = ctx.view.prepareRules(found.rules)
|
|
32
|
+
ctx.pageRules = ctx.view.prepareRules(rootRules(found.rules, ctx.pageRoot))
|
|
31
33
|
ctx.rulesTagNode = found.tagNode
|
|
32
34
|
}
|
|
33
35
|
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
@@ -54,6 +56,7 @@ function refreshPageForm(ctx, { ignoreActiveValue }) {
|
|
|
54
56
|
doc: ctx.doc,
|
|
55
57
|
})
|
|
56
58
|
morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
|
|
59
|
+
reconcileFontsLink(ctx, newData)
|
|
57
60
|
seeder.seed(ctx.formRoot)
|
|
58
61
|
// The morph can rebuild field nodes, dropping autosize heights and richclay
|
|
59
62
|
// instances (instances are per-element); re-enhance whatever is new.
|
package/src/session.js
CHANGED
|
@@ -10,6 +10,7 @@ import { deriveFormRules } from './form-rules.js'
|
|
|
10
10
|
import { warnUnmatchedTemplates } from './diagnostics.js'
|
|
11
11
|
import { findUnresolved, stripReadOnly } from './unresolved.js'
|
|
12
12
|
import { rowIdentitySeeder } from './row-identity.js'
|
|
13
|
+
import { contentRoot, rootRules } from './page-root.js'
|
|
13
14
|
import {
|
|
14
15
|
commit,
|
|
15
16
|
commitWithUndo,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from './events.js'
|
|
22
23
|
import { installObserver } from './refresh.js'
|
|
23
24
|
import { platform, onPlatformEvent, LIVESYNC_APPLIED } from './platform.js'
|
|
25
|
+
import { reconcileFontsLink } from './fonts-link.js'
|
|
24
26
|
|
|
25
27
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
26
28
|
|
|
@@ -48,7 +50,7 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
|
|
|
48
50
|
// bare scalar rules whose page element contains child elements are rebound
|
|
49
51
|
// through @innerHTML so links and inline formatting survive the round-trip, and
|
|
50
52
|
// their form fields render the @richtext component.
|
|
51
|
-
const pageRules = view.prepareRules(found.rules)
|
|
53
|
+
const pageRules = view.prepareRules(rootRules(found.rules, pageRoot))
|
|
52
54
|
const rulesTagNode = found.tagNode
|
|
53
55
|
|
|
54
56
|
injectDefaults(doc)
|
|
@@ -95,6 +97,7 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
|
|
|
95
97
|
onError: opts.onError,
|
|
96
98
|
confirmRemove: opts.confirmRemove,
|
|
97
99
|
previouslyFocused: doc.activeElement,
|
|
100
|
+
quiesced: false,
|
|
98
101
|
dispatch(name, detail) {
|
|
99
102
|
const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
|
|
100
103
|
if (!Ctor) return
|
|
@@ -103,13 +106,13 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
|
|
|
103
106
|
// inline one without inspecting the DOM. Merged into the caller's detail
|
|
104
107
|
// rather than replacing it: hcms:change still carries .data, hcms:error
|
|
105
108
|
// still carries .error.
|
|
106
|
-
const payload = { ...(detail || {}), pageRoot, view: view.name }
|
|
109
|
+
const payload = { ...(detail || {}), pageRoot: contentRoot(pageRoot), view: view.name }
|
|
107
110
|
const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail: payload })
|
|
108
111
|
// A full-document morph (live sync, a version restore) can replace the
|
|
109
112
|
// view root between mount and dispatch. Falling back to the page keeps the
|
|
110
113
|
// event on the document instead of firing it into a detached tree where no
|
|
111
114
|
// listener will ever see it.
|
|
112
|
-
const target = view.root && view.root.isConnected !== false ? view.root : pageRoot
|
|
115
|
+
const target = view.root && view.root.isConnected !== false ? view.root : contentRoot(pageRoot)
|
|
113
116
|
target.dispatchEvent(ev)
|
|
114
117
|
},
|
|
115
118
|
onCloseRequested,
|
|
@@ -132,6 +135,10 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
|
|
|
132
135
|
export function startSession(ctx) {
|
|
133
136
|
ctx.updateFingerprint()
|
|
134
137
|
|
|
138
|
+
// A font set outside the CMS (the JSON API, a hand edit) left the fonts link
|
|
139
|
+
// behind; opening the CMS brings it back in step.
|
|
140
|
+
reconcileFontsLink(ctx, coerceBooleans(engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS), ctx.pageRules))
|
|
141
|
+
|
|
135
142
|
ctx.observerHandle = installObserver({
|
|
136
143
|
onRefresh: (changes) => ctx.view.refresh('observer', changes),
|
|
137
144
|
})
|
|
@@ -236,6 +243,47 @@ function clearState() {
|
|
|
236
243
|
globalCommitTarget.ctx = null
|
|
237
244
|
}
|
|
238
245
|
|
|
246
|
+
export function modeLifecycle(win) {
|
|
247
|
+
const scope = win || (typeof window !== 'undefined' ? window : null)
|
|
248
|
+
const lifecycle = scope && scope.clay ? scope.clay.modeLifecycle : null
|
|
249
|
+
return lifecycle && typeof lifecycle.canWrite === 'function' ? lifecycle : null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function canWriteNow(win) {
|
|
253
|
+
const lifecycle = modeLifecycle(win)
|
|
254
|
+
if (!lifecycle) return true
|
|
255
|
+
try {
|
|
256
|
+
return lifecycle.canWrite() !== false
|
|
257
|
+
} catch (_) {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const UPLOADING_SELECTOR = '[data-hcms-uploading]'
|
|
263
|
+
|
|
264
|
+
export function pendingWork(ctx) {
|
|
265
|
+
if (!ctx) return []
|
|
266
|
+
const pending = []
|
|
267
|
+
if (ctx.uploads && ctx.uploads.size > 0) pending.push('upload')
|
|
268
|
+
if (ctx.pageRoot && typeof ctx.pageRoot.querySelector === 'function' && ctx.pageRoot.querySelector(UPLOADING_SELECTOR)) {
|
|
269
|
+
pending.push('preview')
|
|
270
|
+
}
|
|
271
|
+
return pending
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function quiesceSession(ctx) {
|
|
275
|
+
if (!ctx || ctx.closed) return { ok: true }
|
|
276
|
+
const pending = pendingWork(ctx)
|
|
277
|
+
if (pending.length) return { ok: false, code: 'pending-input', pending }
|
|
278
|
+
ctx.quiesced = true
|
|
279
|
+
return { ok: true }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function resumeSession(ctx) {
|
|
283
|
+
if (!ctx || ctx.closed) return
|
|
284
|
+
ctx.quiesced = false
|
|
285
|
+
}
|
|
286
|
+
|
|
239
287
|
const globalCommitTarget = { ctx: null }
|
|
240
288
|
|
|
241
289
|
function installGlobalSortableCommit(doc) {
|
package/src/targets.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createDomAdapter } from 'hyper-html-api/dom'
|
|
12
12
|
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
13
|
+
import { styleRole } from './templates.js'
|
|
13
14
|
|
|
14
15
|
const FIND_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
15
16
|
// templateAttr:null includes the hidden [cms-template] seed. A list that has been
|
|
@@ -44,22 +45,21 @@ const NATIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
|
|
|
44
45
|
const NON_TEXT_TAGS = new Set([
|
|
45
46
|
'IMG', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'BR', 'HR', 'VIDEO', 'AUDIO',
|
|
46
47
|
'IFRAME', 'EMBED', 'OBJECT', 'CANVAS', 'SOURCE', 'TRACK', 'AREA', 'COL', 'PARAM',
|
|
47
|
-
'BUTTON',
|
|
48
|
+
'BUTTON', 'STYLE', 'SCRIPT', 'LINK', 'META',
|
|
48
49
|
])
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function resolvePageTargets(root, rules) {
|
|
51
|
+
// `doc` lets each scalar target say whether it is a style field (`style`:
|
|
52
|
+
// 'page', 'section' or null). Without it every target is content.
|
|
53
|
+
export function resolveTargets(root, rules, doc = null) {
|
|
55
54
|
const targets = []
|
|
56
55
|
const lists = []
|
|
57
56
|
const adapter = createDomAdapter(root)
|
|
58
|
-
|
|
57
|
+
const roleOf = doc ? (rule, path) => styleRole(rule, doc, path, rules) : () => null
|
|
58
|
+
walk(adapter, root, rules, [], targets, lists, roleOf)
|
|
59
59
|
return { targets, lists }
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function walk(adapter, ctx, rule, path, targets, lists) {
|
|
62
|
+
function walk(adapter, ctx, rule, path, targets, lists, roleOf) {
|
|
63
63
|
if (typeof rule === 'string') {
|
|
64
64
|
// A scalar array ("ul.tags li[]"): every match is its own editable item, and
|
|
65
65
|
// the run of them is a list that can be added to and removed from.
|
|
@@ -79,7 +79,7 @@ function walk(adapter, ctx, rule, path, targets, lists) {
|
|
|
79
79
|
// "." and a bare "@attr" both address the context node itself, so they can
|
|
80
80
|
// never fail to resolve and there is nothing to query.
|
|
81
81
|
const el = !selector || selector === '.' ? ctx : find(adapter, ctx, selector)[0]
|
|
82
|
-
if (el) targets.push(describe(path, el, rule, attr))
|
|
82
|
+
if (el) targets.push(describe(path, el, rule, attr, roleOf(rule, path)))
|
|
83
83
|
return
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -87,13 +87,13 @@ function walk(adapter, ctx, rule, path, targets, lists) {
|
|
|
87
87
|
const [selector, shape] = rule
|
|
88
88
|
if (typeof selector !== 'string' || !selector) return
|
|
89
89
|
const items = find(adapter, ctx, selector)
|
|
90
|
-
items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists))
|
|
90
|
+
items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists, roleOf))
|
|
91
91
|
lists.push(makeList(adapter, ctx, path, selector, items, typeof shape === 'string'))
|
|
92
92
|
return
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if (rule && typeof rule === 'object') {
|
|
96
|
-
for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists)
|
|
96
|
+
for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists, roleOf)
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -109,8 +109,8 @@ function makeList(adapter, ctx, path, selector, items, scalar) {
|
|
|
109
109
|
return { path, items, container, scalar }
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function describe(path, el, rule, attr) {
|
|
113
|
-
return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr) }
|
|
112
|
+
function describe(path, el, rule, attr, style = null) {
|
|
113
|
+
return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr), style }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function kindOf(el, attr) {
|
|
@@ -147,3 +147,45 @@ function find(adapter, ctx, selector, opts = FIND_OPTS) {
|
|
|
147
147
|
return []
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
// Style fields grouped by what they style: every page-level one together, the
|
|
152
|
+
// ones inside a list row with that row, any other with the element it sits on.
|
|
153
|
+
export function styleGroups(targets, lists) {
|
|
154
|
+
const groups = []
|
|
155
|
+
const byEl = new Map()
|
|
156
|
+
let page = null
|
|
157
|
+
for (const target of targets) {
|
|
158
|
+
if (!target.style) continue
|
|
159
|
+
const path = target.path.join('.')
|
|
160
|
+
if (target.style === 'page') {
|
|
161
|
+
if (!page) {
|
|
162
|
+
page = { scope: 'page', el: target.el, list: null, paths: [] }
|
|
163
|
+
groups.push(page)
|
|
164
|
+
}
|
|
165
|
+
page.paths.push(path)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const owner = rowOf(target.path, lists)
|
|
169
|
+
const el = owner ? owner.row : target.el
|
|
170
|
+
let group = byEl.get(el)
|
|
171
|
+
if (!group) {
|
|
172
|
+
group = { scope: owner ? 'row' : 'section', el, list: owner ? owner.list : null, paths: [] }
|
|
173
|
+
byEl.set(el, group)
|
|
174
|
+
groups.push(group)
|
|
175
|
+
}
|
|
176
|
+
group.paths.push(path)
|
|
177
|
+
}
|
|
178
|
+
return groups
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The innermost list row a target path runs through, as { list, row }.
|
|
182
|
+
function rowOf(path, lists) {
|
|
183
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
184
|
+
if (typeof path[i] !== 'number') continue
|
|
185
|
+
const prefix = path.slice(0, i).join('.')
|
|
186
|
+
const list = lists.find((candidate) => candidate.path.join('.') === prefix)
|
|
187
|
+
const row = list && list.items[path[i]]
|
|
188
|
+
if (row) return { list, row }
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|