@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/src/session.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The session core: everything an editing session is, independent of the chrome
|
|
2
|
+
// that renders it. Rule resolution, the ctx object every module reads, the first
|
|
3
|
+
// extract, the observer / undo / live-sync subscriptions, the global sortable
|
|
4
|
+
// wiring, event dispatch, and one idempotent teardown. A view (src/views/*)
|
|
5
|
+
// supplies the DOM; the session owns the state and the page.
|
|
6
|
+
|
|
7
|
+
import { engine } from 'hyper-html-api'
|
|
8
|
+
import { injectDefaults, injectComponents } from './templates.js'
|
|
9
|
+
import { deriveFormRules } from './form-rules.js'
|
|
10
|
+
import { warnUnmatchedTemplates } from './diagnostics.js'
|
|
11
|
+
import { findUnresolved, stripReadOnly } from './unresolved.js'
|
|
12
|
+
import { rowIdentitySeeder } from './row-identity.js'
|
|
13
|
+
import {
|
|
14
|
+
commit,
|
|
15
|
+
commitWithUndo,
|
|
16
|
+
suppressUndo,
|
|
17
|
+
extractFormData,
|
|
18
|
+
stableStringify,
|
|
19
|
+
restampAllSiblings,
|
|
20
|
+
coerceBooleans,
|
|
21
|
+
} from './events.js'
|
|
22
|
+
import { installObserver } from './refresh.js'
|
|
23
|
+
import { platform, onPlatformEvent, LIVESYNC_APPLIED } from './platform.js'
|
|
24
|
+
|
|
25
|
+
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
26
|
+
|
|
27
|
+
export const state = {
|
|
28
|
+
isOpen: false,
|
|
29
|
+
ctx: null,
|
|
30
|
+
opts: null,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build the session for one open(): resolve the rules, hand them to the view for
|
|
34
|
+
// its own preparation, derive the form schema, and take the first read of the
|
|
35
|
+
// page. The view is not mounted yet — ctx reads its DOM handles lazily, so the
|
|
36
|
+
// mount can stay inside open()'s try block.
|
|
37
|
+
export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested, onViewRequested }) {
|
|
38
|
+
// Resolve rules via the union: an explicit rules object, a token string, or
|
|
39
|
+
// the default token "cms". findRules is document-scoped, so a head-mounted
|
|
40
|
+
// rules tag is found even though pageRoot defaults to <body>.
|
|
41
|
+
const source = opts.rules !== undefined ? opts.rules : 'cms'
|
|
42
|
+
const found = engine.findRules(doc, source)
|
|
43
|
+
if (!found) {
|
|
44
|
+
const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
|
|
45
|
+
throw new Error(`hypercms: no rules found for ${what}`)
|
|
46
|
+
}
|
|
47
|
+
// The view's rule seam. The sidebar applies the rich-text upgrade (default on):
|
|
48
|
+
// bare scalar rules whose page element contains child elements are rebound
|
|
49
|
+
// through @innerHTML so links and inline formatting survive the round-trip, and
|
|
50
|
+
// their form fields render the @richtext component.
|
|
51
|
+
const pageRules = view.prepareRules(found.rules)
|
|
52
|
+
const rulesTagNode = found.tagNode
|
|
53
|
+
|
|
54
|
+
injectDefaults(doc)
|
|
55
|
+
injectComponents(doc, pageRules)
|
|
56
|
+
warnUnmatchedTemplates(doc, pageRules)
|
|
57
|
+
const formRules = deriveFormRules(pageRules, doc)
|
|
58
|
+
// The write half of the rules; see stripReadOnly. The form and every extract
|
|
59
|
+
// keep the full tree.
|
|
60
|
+
const writeRules = stripReadOnly(pageRules)
|
|
61
|
+
// Runs before the first extract so a rule with invalid CSS names its field
|
|
62
|
+
// instead of throwing a bare SyntaxError out of querySelectorAll.
|
|
63
|
+
const unresolved = findUnresolved(pageRoot, pageRules)
|
|
64
|
+
// Bind each form row to the page row it stands for while the two are still
|
|
65
|
+
// known to correspond, which is now: the form the view builds comes from
|
|
66
|
+
// exactly this read. Without it the first structural operation of a session has
|
|
67
|
+
// no identity to go on and falls back to content matching.
|
|
68
|
+
const seeder = rowIdentitySeeder()
|
|
69
|
+
const data = coerceBooleans(engine.extract(pageRoot, pageRules, { ...ENGINE_OPTS, ...seeder.hooks }), pageRules)
|
|
70
|
+
|
|
71
|
+
const ctx = {
|
|
72
|
+
doc,
|
|
73
|
+
pageRoot,
|
|
74
|
+
pageRules,
|
|
75
|
+
writeRules,
|
|
76
|
+
formRules,
|
|
77
|
+
rulesTagNode,
|
|
78
|
+
rulesSource: source,
|
|
79
|
+
richText: view.richText,
|
|
80
|
+
view,
|
|
81
|
+
seeder,
|
|
82
|
+
initialData: data,
|
|
83
|
+
get formRoot() { return view.formRoot },
|
|
84
|
+
get shellRoot() { return view.root },
|
|
85
|
+
get errorEl() { return view.errorEl },
|
|
86
|
+
get noticeEl() { return view.noticeEl },
|
|
87
|
+
unresolved,
|
|
88
|
+
lastTwinSignature: null,
|
|
89
|
+
lastFingerprint: null,
|
|
90
|
+
lastData: null,
|
|
91
|
+
observerHandle: null,
|
|
92
|
+
undoUnsub: null,
|
|
93
|
+
livesyncUnsub: null,
|
|
94
|
+
onChange: opts.onChange,
|
|
95
|
+
onError: opts.onError,
|
|
96
|
+
confirmRemove: opts.confirmRemove,
|
|
97
|
+
previouslyFocused: doc.activeElement,
|
|
98
|
+
dispatch(name, detail) {
|
|
99
|
+
const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
|
|
100
|
+
if (!Ctor) return
|
|
101
|
+
// Every event carries the page it belongs to and which view is rendering
|
|
102
|
+
// it, so a document-level listener can tell a sidebar session from an
|
|
103
|
+
// inline one without inspecting the DOM. Merged into the caller's detail
|
|
104
|
+
// rather than replacing it: hcms:change still carries .data, hcms:error
|
|
105
|
+
// still carries .error.
|
|
106
|
+
const payload = { ...(detail || {}), pageRoot, view: view.name }
|
|
107
|
+
const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail: payload })
|
|
108
|
+
// A full-document morph (live sync, a version restore) can replace the
|
|
109
|
+
// view root between mount and dispatch. Falling back to the page keeps the
|
|
110
|
+
// event on the document instead of firing it into a detached tree where no
|
|
111
|
+
// listener will ever see it.
|
|
112
|
+
const target = view.root && view.root.isConnected !== false ? view.root : pageRoot
|
|
113
|
+
target.dispatchEvent(ev)
|
|
114
|
+
},
|
|
115
|
+
onCloseRequested,
|
|
116
|
+
// How a view asks to be rendered as a different one. The switch runs through
|
|
117
|
+
// open({ view }), which carries this session's options across it, so the
|
|
118
|
+
// request changes the presentation and not the session.
|
|
119
|
+
onViewRequested,
|
|
120
|
+
}
|
|
121
|
+
ctx.updateFingerprint = () => {
|
|
122
|
+
ctx.lastFingerprint = stableStringify(extractFormData(ctx))
|
|
123
|
+
}
|
|
124
|
+
view.ctx = ctx
|
|
125
|
+
return ctx
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Everything that outlives the mount: the fingerprint the commit path compares
|
|
129
|
+
// against, and the three external subscriptions plus the global sortable hook.
|
|
130
|
+
// Runs after the view is mounted, so a throw here is caught by open() and routed
|
|
131
|
+
// through teardownSession.
|
|
132
|
+
export function startSession(ctx) {
|
|
133
|
+
ctx.updateFingerprint()
|
|
134
|
+
|
|
135
|
+
ctx.observerHandle = installObserver({
|
|
136
|
+
onRefresh: (changes) => ctx.view.refresh('observer', changes),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Undo/redo is a deliberate local action (unlike livesync or in-flight typing),
|
|
140
|
+
// so the focused form field SHOULD snap to the reverted value. The observer above
|
|
141
|
+
// is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
|
|
142
|
+
// Fires synchronously before the debounced observer refresh, which then no-ops on
|
|
143
|
+
// the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
|
|
144
|
+
const u = platform('undo')
|
|
145
|
+
if (u && typeof u.on === 'function') {
|
|
146
|
+
const onRevert = () => {
|
|
147
|
+
if (state.ctx !== ctx) return
|
|
148
|
+
resyncForm(ctx, 'undo')
|
|
149
|
+
// A reverted change the consumer persists through onChange (e.g. the
|
|
150
|
+
// collection dashboard PUTs the record, not the page) would otherwise lag
|
|
151
|
+
// the undo: refreshForm re-syncs the form but never fires onChange, and a
|
|
152
|
+
// commit() here would be fingerprint-skipped (refreshForm already updated
|
|
153
|
+
// it). Extract the reverted state from the PAGE (the source of truth after
|
|
154
|
+
// an undo/redo; the form can lag a morph) and fire onChange directly, but
|
|
155
|
+
// only when it actually changed — guards spurious PUTs on undo/redo of
|
|
156
|
+
// unrelated edits elsewhere on the page.
|
|
157
|
+
const data = coerceBooleans(
|
|
158
|
+
engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
|
|
159
|
+
ctx.pageRules
|
|
160
|
+
)
|
|
161
|
+
if (stableStringify(data) !== stableStringify(ctx.lastData)) {
|
|
162
|
+
ctx.lastData = data
|
|
163
|
+
ctx.onChange?.(data, { path: '', structural: false })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
u.on('undo', onRevert)
|
|
167
|
+
u.on('redo', onRevert)
|
|
168
|
+
ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Live-sync / version-restore / any framework-applied full-document morph runs
|
|
172
|
+
// inside window.hyperclay.Mutation.pause(), which the source-blind observer
|
|
173
|
+
// above is deaf to — so without this the form stays stale after a remote edit
|
|
174
|
+
// and the next local commit re-applies the stale data, clobbering the remote
|
|
175
|
+
// change. Subscribe to the morph-applied signal live-sync emits and re-extract
|
|
176
|
+
// with ignoreActiveValue:true (preserve the user's in-progress field). Same
|
|
177
|
+
// document live-sync dispatches on; unsubscribed in teardownSession.
|
|
178
|
+
const onLivesync = () => resyncForm(ctx, 'livesync')
|
|
179
|
+
ctx.livesyncUnsub = onPlatformEvent(ctx.doc, LIVESYNC_APPLIED, onLivesync)
|
|
180
|
+
|
|
181
|
+
// Wire global sortable callback to current ctx (cleared by teardownSession).
|
|
182
|
+
globalCommitTarget.ctx = ctx
|
|
183
|
+
installGlobalSortableCommit(ctx.doc)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Re-sync the view to the page after a change hypercms did NOT originate.
|
|
187
|
+
// `reason` selects whether the field the user is currently typing in snaps:
|
|
188
|
+
// 'undo' / 'redo' — the user's own deliberate action, so the focused field
|
|
189
|
+
// SHOULD snap to the reverted value (ignoreActiveValue:false).
|
|
190
|
+
// 'livesync' — someone else's / the framework's change, so the user's
|
|
191
|
+
// in-progress focused field must NOT be yanked away
|
|
192
|
+
// (ignoreActiveValue:true). morphForm re-extracts every
|
|
193
|
+
// other field to the new page value, killing the stale-form
|
|
194
|
+
// clobber, while leaving the active field alone until blur.
|
|
195
|
+
// Both reasons route through the view's one refresh entry point.
|
|
196
|
+
function resyncForm(ctx, reason) {
|
|
197
|
+
// open() may have moved on (closed, or reopened with a new ctx) between an
|
|
198
|
+
// async morph completing and this firing.
|
|
199
|
+
if (state.ctx !== ctx) return
|
|
200
|
+
ctx.view.refresh(reason)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The one teardown. close(), the failed-open path inside open(), and (later) a
|
|
204
|
+
// view switch all route through it; the options say only which of the three
|
|
205
|
+
// close-specific side effects apply.
|
|
206
|
+
export function teardownSession(ctx, { dispatch = true, restoreFocus = true, updateUrl = true, reason = 'close' } = {}) {
|
|
207
|
+
if (!ctx || ctx.closed) return
|
|
208
|
+
// Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
|
|
209
|
+
// host uploader) bails instead of mutating the page / firing onChange after
|
|
210
|
+
// teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
|
|
211
|
+
ctx.closed = true
|
|
212
|
+
// An upload still in flight is torn down like any other subscription. The
|
|
213
|
+
// ctx.closed check above already stops it writing; this stops it running.
|
|
214
|
+
for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
|
|
215
|
+
ctx.uploads?.clear()
|
|
216
|
+
const previouslyFocused = ctx.previouslyFocused
|
|
217
|
+
if (dispatch) ctx.dispatch('hcms:close', { reason })
|
|
218
|
+
if (updateUrl) toggleCloseParam()
|
|
219
|
+
ctx.observerHandle?.unsubscribe?.()
|
|
220
|
+
ctx.undoUnsub?.()
|
|
221
|
+
ctx.livesyncUnsub?.()
|
|
222
|
+
ctx.detachEvents?.()
|
|
223
|
+
// destroy() removes the chrome-only body classes; suppress so the inverse
|
|
224
|
+
// class removal doesn't enter the undo stack either.
|
|
225
|
+
suppressUndo(() => ctx.view.destroy())
|
|
226
|
+
clearState()
|
|
227
|
+
if (restoreFocus && typeof previouslyFocused?.focus === 'function') {
|
|
228
|
+
try { previouslyFocused.focus() } catch (_) {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function clearState() {
|
|
233
|
+
state.isOpen = false
|
|
234
|
+
state.ctx = null
|
|
235
|
+
state.opts = null
|
|
236
|
+
globalCommitTarget.ctx = null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const globalCommitTarget = { ctx: null }
|
|
240
|
+
|
|
241
|
+
function installGlobalSortableCommit(doc) {
|
|
242
|
+
const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
|
|
243
|
+
if (!win) return
|
|
244
|
+
const hypercmsCommitFn = function hypercmsCommitGlobal() {
|
|
245
|
+
const ctx = globalCommitTarget.ctx
|
|
246
|
+
if (!ctx) return
|
|
247
|
+
restampAllSiblings(ctx.formRoot)
|
|
248
|
+
// Route through commitWithUndo so a drag-reorder lands as a labeled
|
|
249
|
+
// 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
|
|
250
|
+
// mutate+rollback is never recorded as a no-op commit. No-op pass-through
|
|
251
|
+
// when undo isn't loaded.
|
|
252
|
+
return commitWithUndo('Reorder', () =>
|
|
253
|
+
commit(extractFormData(ctx), { path: '', structural: true }, ctx)
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
|
|
257
|
+
// Also mirror to globalThis so non-window contexts (Node tests, workers)
|
|
258
|
+
// can resolve the bare name from `new Function('hypercmsCommit()')`.
|
|
259
|
+
if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
|
|
260
|
+
globalThis.hypercmsCommit = hypercmsCommitFn
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
|
|
265
|
+
// to cms=false (param kept) so a refresh/share of the post-close URL does not
|
|
266
|
+
// auto-reopen.
|
|
267
|
+
const AUTO_OPEN_PARAM = 'cms'
|
|
268
|
+
|
|
269
|
+
// Pure: given a location.search string, return the search string after a close.
|
|
270
|
+
// Only rewrites when cms=true is present; every other param + ordering is
|
|
271
|
+
// preserved, and a search without cms (or already cms=false) is returned
|
|
272
|
+
// unchanged so we never inject the param into a page that never carried it.
|
|
273
|
+
export function nextSearchAfterClose(search) {
|
|
274
|
+
const str = typeof search === 'string' ? search : ''
|
|
275
|
+
const qIndex = str.indexOf('?')
|
|
276
|
+
const query = qIndex === -1 ? str : str.slice(qIndex + 1)
|
|
277
|
+
if (!query) return str
|
|
278
|
+
const params = new URLSearchParams(query)
|
|
279
|
+
if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
|
|
280
|
+
params.set(AUTO_OPEN_PARAM, 'false')
|
|
281
|
+
return '?' + params.toString()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Pure: should the CMS auto-open for this location.search? Exactly 'true'.
|
|
285
|
+
export function shouldAutoOpenFromSearch(search) {
|
|
286
|
+
const str = typeof search === 'string' ? search : ''
|
|
287
|
+
const qIndex = str.indexOf('?')
|
|
288
|
+
const query = qIndex === -1 ? str : str.slice(qIndex + 1)
|
|
289
|
+
if (!query) return false
|
|
290
|
+
return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
|
|
294
|
+
// every other param preserved). No-op when the param isn't present or the
|
|
295
|
+
// History API is unavailable.
|
|
296
|
+
function toggleCloseParam() {
|
|
297
|
+
if (typeof window === 'undefined' || !window.location || !window.history) return
|
|
298
|
+
if (typeof window.history.replaceState !== 'function') return
|
|
299
|
+
const current = window.location.search
|
|
300
|
+
const next = nextSearchAfterClose(current)
|
|
301
|
+
if (next === current) return
|
|
302
|
+
window.history.replaceState(window.history.state, '', next + window.location.hash)
|
|
303
|
+
}
|
package/src/shell.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
const SHELL_STYLE_ID = 'hcms-shell-styles'
|
|
2
2
|
const SHELL_BUNDLED_FLAG = 'hcms-bundled-styles-installed'
|
|
3
|
+
// "A session of either kind is open", so a page (or the toggle) can style off
|
|
4
|
+
// open state without reaching for hcms-open, which describes only this panel's
|
|
5
|
+
// page shift and is absent for an inline session. Additive: hcms-open keeps its
|
|
6
|
+
// meaning exactly. Both views add it on mount and drop it on destroy.
|
|
7
|
+
export const SESSION_OPEN_CLASS = 'hcms-session-open'
|
|
3
8
|
const FOCUSABLE = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
4
9
|
|
|
5
10
|
const styledDocs = new WeakSet()
|
|
@@ -17,6 +22,14 @@ export function markStylesBundled(doc) {
|
|
|
17
22
|
doc[SHELL_BUNDLED_FLAG] = true
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
export function markSessionOpen(doc) {
|
|
26
|
+
doc?.body?.classList.add(SESSION_OPEN_CLASS)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearSessionOpen(doc) {
|
|
30
|
+
doc?.body?.classList.remove(SESSION_OPEN_CLASS)
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
let titleIdCounter = 0
|
|
21
34
|
|
|
22
35
|
function escapeHtml(s) {
|
|
@@ -39,6 +52,7 @@ export function mountShell({
|
|
|
39
52
|
const titleId = `hcms-shell-title-${++titleIdCounter}`
|
|
40
53
|
const root = doc.createElement('div')
|
|
41
54
|
root.setAttribute('data-hcms-shell', '')
|
|
55
|
+
root.setAttribute('editor-ui', '')
|
|
42
56
|
root.setAttribute('save-remove', '')
|
|
43
57
|
root.setAttribute('save-ignore', '')
|
|
44
58
|
root.setAttribute('tabindex', '-1')
|
|
@@ -52,7 +66,7 @@ export function mountShell({
|
|
|
52
66
|
// (otherwise the panel follows the OS via prefers-color-scheme).
|
|
53
67
|
const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
|
|
54
68
|
root.className =
|
|
55
|
-
'hcms-shell pixel-quiet hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
|
|
69
|
+
'hcms-shell pixel-quiet hcms-panel hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
|
|
56
70
|
const titleHtml = escapeHtml(title)
|
|
57
71
|
const eyebrowHtml = escapeHtml(eyebrow)
|
|
58
72
|
// Pixel Quiet shell: a scroll-away header + in-flow Save inside .hcms-shell-body,
|
|
@@ -98,6 +112,7 @@ export function mountShell({
|
|
|
98
112
|
|
|
99
113
|
const body = doc.body
|
|
100
114
|
body.classList.add('hcms-open')
|
|
115
|
+
markSessionOpen(doc)
|
|
101
116
|
if (overlay) body.classList.add('hcms-overlay')
|
|
102
117
|
if (side === 'left') body.classList.add('hcms-side-left')
|
|
103
118
|
|
|
@@ -115,6 +130,7 @@ export function mountShell({
|
|
|
115
130
|
condense.detach()
|
|
116
131
|
root.remove()
|
|
117
132
|
body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
|
|
133
|
+
clearSessionOpen(doc)
|
|
118
134
|
},
|
|
119
135
|
// Re-assert the shell's out-of-subtree chrome after a full-document morph
|
|
120
136
|
// (e.g. a live-sync apply) wipes it: the stylesheet lives in <head> and the
|
|
@@ -123,6 +139,7 @@ export function mountShell({
|
|
|
123
139
|
restoreChrome() {
|
|
124
140
|
reensureStyles(doc)
|
|
125
141
|
body.classList.add('hcms-open')
|
|
142
|
+
markSessionOpen(doc)
|
|
126
143
|
if (overlay) body.classList.add('hcms-overlay')
|
|
127
144
|
if (side === 'left') body.classList.add('hcms-side-left')
|
|
128
145
|
},
|
|
@@ -184,11 +201,15 @@ function ensureStyles(doc) {
|
|
|
184
201
|
// No bundled CSS and no resolvable co-located theme (e.g. the IIFE bundle,
|
|
185
202
|
// where import.meta is undefined). Warn loudly rather than mount a silently
|
|
186
203
|
// unstyled shell — the host must call installStyles(themeText) before opening.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
const warn = () => {
|
|
205
|
+
if (!link.isConnected) return
|
|
206
|
+
console.warn(
|
|
207
|
+
'hypercms: shell stylesheet not applied — cssText is empty and the ' +
|
|
208
|
+
'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
|
|
209
|
+
'before opening the CMS.'
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
doc.defaultView?.queueMicrotask ? doc.defaultView.queueMicrotask(warn) : warn()
|
|
192
213
|
}
|
|
193
214
|
}
|
|
194
215
|
|
package/src/targets.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Every editable thing on the page, as the inline view needs to see it: the
|
|
2
|
+
// element a rule resolved to, the real data path that reaches it, and which
|
|
3
|
+
// affordance belongs on it.
|
|
4
|
+
//
|
|
5
|
+
// This is the same walk `unresolved.js` does, with the opposite output. That one
|
|
6
|
+
// reports paths as strings and collapses every row of a list to a single '*',
|
|
7
|
+
// because it answers "which fields are broken" once for the whole page. The
|
|
8
|
+
// inline view needs the resolved element and the real row index, because it puts
|
|
9
|
+
// a control on each one.
|
|
10
|
+
|
|
11
|
+
import { createDomAdapter } from 'hyper-html-api/dom'
|
|
12
|
+
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
13
|
+
|
|
14
|
+
const FIND_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
15
|
+
// templateAttr:null includes the hidden [cms-template] seed. A list that has been
|
|
16
|
+
// emptied down to its seed still has a place on the page — the seed is sitting in
|
|
17
|
+
// it — and that is the one list most in need of an "+ Add" row. Finding the seed
|
|
18
|
+
// is the only way to know where the row goes, so an empty list resolves its
|
|
19
|
+
// container this way and nothing else uses these opts.
|
|
20
|
+
const SEED_OPTS = { skip: '[data-hcms-shell]', templateAttr: null }
|
|
21
|
+
|
|
22
|
+
// How someone changes this field, in place:
|
|
23
|
+
//
|
|
24
|
+
// 'text' the page element IS the affordance. richclay binds to it and the
|
|
25
|
+
// text is edited where it sits; commit on blur.
|
|
26
|
+
// 'native' the page already carries the control — an enabled input, textarea
|
|
27
|
+
// or select bound through @value. Add nothing at all: let them use
|
|
28
|
+
// the control that is already there and commit on change. A readonly
|
|
29
|
+
// or disabled one is NOT this: the author disabled it on purpose, so
|
|
30
|
+
// it falls through to a handle rather than being quietly re-enabled.
|
|
31
|
+
// 'handle' everything else gets a small handle over the anchor's corner,
|
|
32
|
+
// which opens the popover holding that field's real form control.
|
|
33
|
+
const TEXT = 'text'
|
|
34
|
+
const NATIVE = 'native'
|
|
35
|
+
const HANDLE = 'handle'
|
|
36
|
+
|
|
37
|
+
const NATIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
|
|
38
|
+
|
|
39
|
+
// Elements that cannot hold editable text, either because they are void or
|
|
40
|
+
// replaced, or because their text content is their value and belongs to the
|
|
41
|
+
// control rather than to the document. A bare rule pointing at one of these is
|
|
42
|
+
// unusual but legal (it extracts ''), and binding a rich text editor to it would
|
|
43
|
+
// put a caret inside an <img>. They get a handle like anything else.
|
|
44
|
+
const NON_TEXT_TAGS = new Set([
|
|
45
|
+
'IMG', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'BR', 'HR', 'VIDEO', 'AUDIO',
|
|
46
|
+
'IFRAME', 'EMBED', 'OBJECT', 'CANVAS', 'SOURCE', 'TRACK', 'AREA', 'COL', 'PARAM',
|
|
47
|
+
'BUTTON',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
export function resolveTargets(root, rules) {
|
|
51
|
+
return resolvePageTargets(root, rules)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolvePageTargets(root, rules) {
|
|
55
|
+
const targets = []
|
|
56
|
+
const lists = []
|
|
57
|
+
const adapter = createDomAdapter(root)
|
|
58
|
+
walk(adapter, root, rules, [], targets, lists)
|
|
59
|
+
return { targets, lists }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function walk(adapter, ctx, rule, path, targets, lists) {
|
|
63
|
+
if (typeof rule === 'string') {
|
|
64
|
+
// A scalar array ("ul.tags li[]"): every match is its own editable item, and
|
|
65
|
+
// the run of them is a list that can be added to and removed from.
|
|
66
|
+
if (rule.endsWith('[]')) {
|
|
67
|
+
const selector = rule.slice(0, -2)
|
|
68
|
+
if (!selector) return
|
|
69
|
+
const items = find(adapter, ctx, selector)
|
|
70
|
+
items.forEach((el, i) => {
|
|
71
|
+
targets.push(describe([...path, i], el, rule, null))
|
|
72
|
+
})
|
|
73
|
+
lists.push(makeList(adapter, ctx, path, selector, items, true))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const at = ruleAttrIndex(rule)
|
|
77
|
+
const attr = at === -1 ? null : rule.slice(at + 1)
|
|
78
|
+
const selector = at === -1 ? rule : rule.slice(0, at)
|
|
79
|
+
// "." and a bare "@attr" both address the context node itself, so they can
|
|
80
|
+
// never fail to resolve and there is nothing to query.
|
|
81
|
+
const el = !selector || selector === '.' ? ctx : find(adapter, ctx, selector)[0]
|
|
82
|
+
if (el) targets.push(describe(path, el, rule, attr))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(rule)) {
|
|
87
|
+
const [selector, shape] = rule
|
|
88
|
+
if (typeof selector !== 'string' || !selector) return
|
|
89
|
+
const items = find(adapter, ctx, selector)
|
|
90
|
+
items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists))
|
|
91
|
+
lists.push(makeList(adapter, ctx, path, selector, items, typeof shape === 'string'))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (rule && typeof rule === 'object') {
|
|
96
|
+
for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `container` is where a new row goes. With rows present it is their shared
|
|
101
|
+
// parent; with none it comes from the seed, which is why an emptied list can
|
|
102
|
+
// still be grown back.
|
|
103
|
+
function makeList(adapter, ctx, path, selector, items, scalar) {
|
|
104
|
+
let container = items[0] ? items[0].parentElement : null
|
|
105
|
+
if (!container) {
|
|
106
|
+
const seed = find(adapter, ctx, selector, SEED_OPTS)[0]
|
|
107
|
+
container = seed ? seed.parentElement : null
|
|
108
|
+
}
|
|
109
|
+
return { path, items, container, scalar }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describe(path, el, rule, attr) {
|
|
113
|
+
return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function kindOf(el, attr) {
|
|
117
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
118
|
+
if (!attr || attr === 'innerHTML') return NON_TEXT_TAGS.has(tag) ? HANDLE : TEXT
|
|
119
|
+
if (attr === 'value' && NATIVE_TAGS.has(tag) && !el.readOnly && !isEffectivelyDisabled(el)) return NATIVE
|
|
120
|
+
return HANDLE
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// el.disabled is false for an input inside <fieldset disabled>, but :disabled
|
|
124
|
+
// matches it and the browser will not let anyone type in it. Checking only the
|
|
125
|
+
// property leaves an unusable field with no control at all.
|
|
126
|
+
function isEffectivelyDisabled(el) {
|
|
127
|
+
if (el.disabled) return true
|
|
128
|
+
return typeof el.matches === 'function' && el.matches(':disabled')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function iconOf(el, attr) {
|
|
132
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
133
|
+
if ((!attr || attr === 'innerHTML') && !NON_TEXT_TAGS.has(tag)) return null
|
|
134
|
+
if (tag === 'IMG' || attr === 'srcset') return 'camera'
|
|
135
|
+
if (tag === 'A' && attr === 'href') return 'paperclip'
|
|
136
|
+
return 'pencil'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function find(adapter, ctx, selector, opts = FIND_OPTS) {
|
|
140
|
+
try {
|
|
141
|
+
return adapter.find(ctx, selector, opts)
|
|
142
|
+
} catch (_) {
|
|
143
|
+
// unresolved.js already turns an invalid selector into a named
|
|
144
|
+
// InvalidRuleSelector before the first extract, so by the time the inline
|
|
145
|
+
// view walks, a throw here would be a duplicate of an error the session has
|
|
146
|
+
// already surfaced. Skip the rule instead of taking the whole view down.
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
}
|