@panphora/hyper-cms 0.8.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/hypercms.min.js +442 -36
- package/package.json +7 -4
- package/packed-contract.json +1 -0
- package/src/anchor.js +24 -0
- package/src/apply-loop.js +79 -46
- package/src/enhance.js +79 -6
- package/src/events.js +102 -8
- package/src/hooks.js +100 -0
- package/src/hypercms-clayjs.js +10 -0
- package/src/hypercms.js +109 -379
- package/src/inline-icons.js +15 -0
- package/src/lib/content-dom.js +110 -0
- package/src/lib/region-capabilities.js +71 -0
- package/src/morph.js +1 -0
- package/src/place.js +131 -0
- package/src/platform.js +12 -0
- package/src/refresh.js +31 -14
- package/src/richclay-bridge.js +140 -0
- package/src/row-identity.js +109 -0
- package/src/session.js +303 -0
- package/src/shell.js +27 -6
- package/src/targets.js +149 -0
- package/src/theme/pixel-quiet.overrides.css +303 -9
- package/src/theme.generated.css +303 -9
- package/src/toggle.js +627 -34
- package/src/unresolved.js +87 -18
- package/src/views/inline-ghosts.js +139 -0
- package/src/views/inline-layer.js +663 -0
- package/src/views/inline.js +1092 -0
- package/src/views/sidebar.js +97 -0
package/src/hypercms.js
CHANGED
|
@@ -2,376 +2,161 @@ import { engine } from 'hyper-html-api'
|
|
|
2
2
|
import * as pathUtil from './path.js'
|
|
3
3
|
import { scaffold } from './scaffold.js'
|
|
4
4
|
import { morphForm } from './morph.js'
|
|
5
|
-
import { injectDefaults, injectComponents } from './templates.js'
|
|
6
|
-
import { deriveFormRules } from './form-rules.js'
|
|
7
|
-
import { buildForm } from './form-builder.js'
|
|
8
5
|
import {
|
|
9
|
-
bindEvents,
|
|
10
6
|
commit,
|
|
11
7
|
commitWithUndo,
|
|
12
|
-
suppressUndo,
|
|
13
8
|
onAdd as evOnAdd,
|
|
14
9
|
onRemove as evOnRemove,
|
|
15
10
|
extractFormData,
|
|
16
|
-
stableStringify,
|
|
17
11
|
restampAllSiblings,
|
|
18
|
-
coerceBooleans,
|
|
19
12
|
} from './events.js'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
13
|
+
import { setShellStyles, markStylesBundled, reensureStyles } from './shell.js'
|
|
14
|
+
import { createSidebarView } from './views/sidebar.js'
|
|
15
|
+
import { createInlineView } from './views/inline.js'
|
|
16
|
+
import {
|
|
17
|
+
state,
|
|
18
|
+
createSession,
|
|
19
|
+
startSession,
|
|
20
|
+
teardownSession,
|
|
21
|
+
nextSearchAfterClose,
|
|
22
|
+
shouldAutoOpenFromSearch,
|
|
23
|
+
} from './session.js'
|
|
24
|
+
import { findLeafField, writeFieldValue } from './events.js'
|
|
25
25
|
import { maybeInjectToggle } from './toggle.js'
|
|
26
|
-
import { platform, onPlatformEvent, MUTATION_READY
|
|
26
|
+
import { platform, onPlatformEvent, MUTATION_READY } from './platform.js'
|
|
27
|
+
import { installHooks } from './hooks.js'
|
|
28
|
+
|
|
29
|
+
export { nextSearchAfterClose, shouldAutoOpenFromSearch }
|
|
30
|
+
// The leaf-write pair api.setValue is built on. Exported because the inline
|
|
31
|
+
// view's text commit writes the same form leaf from the page element, and a
|
|
32
|
+
// second copy of this logic is how the two paths drift apart.
|
|
33
|
+
export { findLeafField, writeFieldValue }
|
|
27
34
|
|
|
28
35
|
export function installStyles(text) {
|
|
29
36
|
setShellStyles(text)
|
|
37
|
+
if (typeof document !== 'undefined') {
|
|
38
|
+
const fallback = document.getElementById('hcms-shell-styles')
|
|
39
|
+
if (fallback?.tagName === 'LINK') fallback.remove()
|
|
40
|
+
reensureStyles(document)
|
|
41
|
+
}
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
export function markBundledStyles(doc) {
|
|
33
45
|
markStylesBundled(doc)
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Re-sync the form to the page after a change hypercms did NOT originate.
|
|
44
|
-
// `reason` selects whether the field the user is currently typing in snaps:
|
|
45
|
-
// 'undo' / 'redo' — the user's own deliberate action, so the focused field
|
|
46
|
-
// SHOULD snap to the reverted value (ignoreActiveValue:false).
|
|
47
|
-
// 'livesync' — someone else's / the framework's change, so the user's
|
|
48
|
-
// in-progress focused field must NOT be yanked away
|
|
49
|
-
// (ignoreActiveValue:true). morphForm re-extracts every
|
|
50
|
-
// other field to the new page value, killing the stale-form
|
|
51
|
-
// clobber, while leaving the active field alone until blur.
|
|
52
|
-
// Both reasons route through this one handler so there is a single place to
|
|
53
|
-
// maintain the form-resync behavior.
|
|
54
|
-
function resyncForm(ctx, reason) {
|
|
55
|
-
// open() may have moved on (closed, or reopened with a new ctx) between an
|
|
56
|
-
// async morph completing and this firing.
|
|
57
|
-
if (state.ctx !== ctx) return
|
|
58
|
-
const ignoreActiveValue = reason === 'livesync'
|
|
59
|
-
if (reason === 'livesync') {
|
|
60
|
-
// A full-document morph can wipe the shell's <head> stylesheet and the
|
|
61
|
-
// <body> chrome classes (both live outside the save-ignore shell subtree).
|
|
62
|
-
// Re-assert them before re-extracting.
|
|
63
|
-
state.shell?.restoreChrome?.()
|
|
64
|
-
}
|
|
65
|
-
refreshForm(ctx, { ignoreActiveValue })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
|
|
69
|
-
// (the shell element itself is [save-remove], but the hcms-open class lives on
|
|
70
|
-
// <body>, outside the stripped subtree). Registered once at first open():
|
|
71
|
-
// onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
|
|
72
|
-
// and removing absent classes is a no-op when the shell is closed. Guarded so it
|
|
73
|
-
// degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
|
|
74
|
-
let prepareHookInstalled = false
|
|
75
|
-
function installSavePrepareHook() {
|
|
76
|
-
if (prepareHookInstalled) return
|
|
77
|
-
const onPrepareForSave = platform('onPrepareForSave')
|
|
78
|
-
if (typeof onPrepareForSave !== 'function') return
|
|
79
|
-
onPrepareForSave((clonedDocEl) => {
|
|
80
|
-
const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
|
|
81
|
-
if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
|
|
82
|
-
})
|
|
83
|
-
prepareHookInstalled = true
|
|
48
|
+
// The views a session can be rendered through. A name that is not in here is a
|
|
49
|
+
// caller error, never a fallback: silently opening a sidebar when someone asked
|
|
50
|
+
// for the inline editor would read as the inline editor being broken.
|
|
51
|
+
const VIEWS = {
|
|
52
|
+
sidebar: createSidebarView,
|
|
53
|
+
inline: createInlineView,
|
|
84
54
|
}
|
|
85
55
|
|
|
86
56
|
export function open(opts = {}) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
57
|
+
// Everything that can be rejected is rejected before anything is destroyed. A
|
|
58
|
+
// bad view name or a missing page must not leave a caller with the session
|
|
59
|
+
// 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 ')})`)
|
|
90
66
|
}
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
// A switch changes the presentation of one session, not the session. Someone
|
|
68
|
+
// calling open({ view: 'sidebar' }) means "show me this in the sidebar"; they
|
|
69
|
+
// are not restating the pageRoot, the rules and the callbacks they opened
|
|
70
|
+
// with. Resolving those from this call alone silently retargets the CMS at
|
|
71
|
+
// document.body with the default rules tag and stops calling onChange.
|
|
72
|
+
// state.opts holds the active session's options and teardown clears it, so it
|
|
73
|
+
// has to be read here, before anything is torn down.
|
|
74
|
+
const effective = state.isOpen ? { ...state.opts, ...opts, view: viewName } : opts
|
|
75
|
+
|
|
76
|
+
const pageRoot = effective.pageRoot || (typeof document !== 'undefined' ? document.body : null)
|
|
93
77
|
if (!pageRoot) throw new Error('hypercms: no pageRoot available')
|
|
94
78
|
const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
|
|
95
79
|
if (!doc) throw new Error('hypercms: no document available')
|
|
96
80
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
81
|
+
// Reopening the view that is already up is a silent no-op, not a warning: a
|
|
82
|
+
// host wiring a button to open({ view }) should be able to call it without
|
|
83
|
+
// first asking what is open. Switching views tears the old one down WITHOUT
|
|
84
|
+
// the close-only side effects — no hcms:close URL rewrite, no focus restore —
|
|
85
|
+
// because a switch is one continuous session from the person's side, not a
|
|
86
|
+
// close followed by an open.
|
|
87
|
+
let carriedFocus = null
|
|
88
|
+
let previous = null
|
|
89
|
+
if (state.isOpen) {
|
|
90
|
+
if (state.ctx.view.name === viewName) return
|
|
91
|
+
carriedFocus = state.ctx.previouslyFocused
|
|
92
|
+
previous = state.ctx.view.name
|
|
93
|
+
teardownSession(state.ctx, { restoreFocus: false, updateUrl: false, reason: 'switch' })
|
|
105
94
|
}
|
|
106
|
-
// Rich-text upgrade (default on): bare scalar rules whose page element
|
|
107
|
-
// contains child elements are rebound through @innerHTML so links and
|
|
108
|
-
// inline formatting survive the round-trip, and their form fields render
|
|
109
|
-
// the @richtext component. Opt out with cms.open({ richText: false }).
|
|
110
|
-
const richText = opts.richText !== false
|
|
111
|
-
const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
|
|
112
|
-
const rulesTagNode = found.tagNode
|
|
113
|
-
|
|
114
|
-
injectDefaults(doc)
|
|
115
|
-
injectComponents(doc, pageRules)
|
|
116
|
-
warnUnmatchedTemplates(doc, pageRules)
|
|
117
|
-
const formRules = deriveFormRules(pageRules, doc)
|
|
118
|
-
// Runs before the first extract so a rule with invalid CSS names its field
|
|
119
|
-
// instead of throwing a bare SyntaxError out of querySelectorAll.
|
|
120
|
-
const unresolved = findUnresolved(pageRoot, pageRules)
|
|
121
|
-
const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
|
|
122
95
|
|
|
123
|
-
|
|
124
|
-
// document.body (hcms-open, etc.) which would otherwise land as undoable
|
|
125
|
-
// page edits. The shell subtree itself is already filtered via save-ignore.
|
|
126
|
-
const shell = suppressUndo(() => mountShell({
|
|
127
|
-
mountTo: opts.mountTo || doc.body,
|
|
128
|
-
side: opts.side || 'right',
|
|
129
|
-
overlay: !!opts.overlay,
|
|
130
|
-
showSaveButton: !!opts.showSaveButton,
|
|
131
|
-
title: opts.title,
|
|
132
|
-
eyebrow: opts.eyebrow,
|
|
133
|
-
theme: opts.theme,
|
|
134
|
-
doc,
|
|
135
|
-
}))
|
|
96
|
+
installHooks()
|
|
136
97
|
|
|
137
|
-
const
|
|
98
|
+
const view = makeView({ doc, pageRoot, opts: effective })
|
|
99
|
+
const ctx = createSession({
|
|
100
|
+
view,
|
|
138
101
|
doc,
|
|
139
102
|
pageRoot,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
noticeEl: shell.noticeEl,
|
|
149
|
-
unresolved,
|
|
150
|
-
lastTwinSignature: null,
|
|
151
|
-
lastFingerprint: null,
|
|
152
|
-
lastData: null,
|
|
153
|
-
observerHandle: null,
|
|
154
|
-
undoUnsub: null,
|
|
155
|
-
livesyncUnsub: null,
|
|
156
|
-
onChange: opts.onChange,
|
|
157
|
-
onError: opts.onError,
|
|
158
|
-
confirmRemove: opts.confirmRemove,
|
|
159
|
-
previouslyFocused: doc.activeElement,
|
|
160
|
-
dispatch(name, detail) {
|
|
161
|
-
const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
|
|
162
|
-
if (!Ctor) return
|
|
163
|
-
const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
|
|
164
|
-
shell.root.dispatchEvent(ev)
|
|
165
|
-
},
|
|
166
|
-
onCloseRequested() {
|
|
167
|
-
close()
|
|
168
|
-
},
|
|
169
|
-
}
|
|
170
|
-
ctx.updateFingerprint = () => {
|
|
171
|
-
ctx.lastFingerprint = stableStringify(extractFormData(ctx))
|
|
172
|
-
}
|
|
103
|
+
opts: effective,
|
|
104
|
+
onCloseRequested: () => close(),
|
|
105
|
+
onViewRequested: (name) => open({ view: name }),
|
|
106
|
+
})
|
|
107
|
+
// Where focus was before the FIRST open, carried across the switch. Closing
|
|
108
|
+
// the second view should return the person where they started, not to
|
|
109
|
+
// whatever held focus after the first view was pulled out from under them.
|
|
110
|
+
if (carriedFocus) ctx.previouslyFocused = carriedFocus
|
|
173
111
|
|
|
174
|
-
// Everything past
|
|
175
|
-
//
|
|
112
|
+
// Everything past here touches the live DOM, the module-level state, and
|
|
113
|
+
// external subscriptions (observer, undo, livesync). A throw in here — most
|
|
176
114
|
// notably installObserver when window.hyperclay.Mutation isn't loaded — would
|
|
177
|
-
// otherwise strand the mounted
|
|
115
|
+
// otherwise strand the mounted view in the DOM with no way to close it (close()
|
|
178
116
|
// early-returns while state.isOpen is false). Tear down whatever was wired,
|
|
179
|
-
//
|
|
117
|
+
// without the close-only side effects (no hcms:close, no URL rewrite, no focus
|
|
118
|
+
// restore), then rethrow so host misuse still fails loudly.
|
|
180
119
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
applyUnresolvedState(ctx)
|
|
120
|
+
view.mount(ctx.initialData)
|
|
121
|
+
startSession(ctx)
|
|
122
|
+
view.focusOnOpen()
|
|
185
123
|
|
|
186
|
-
|
|
187
|
-
|
|
124
|
+
state.isOpen = true
|
|
125
|
+
state.ctx = ctx
|
|
126
|
+
state.opts = effective
|
|
188
127
|
|
|
189
|
-
|
|
190
|
-
onRefresh: () => refreshForm(ctx),
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
// Undo/redo is a deliberate local action (unlike livesync or in-flight typing),
|
|
194
|
-
// so the focused form field SHOULD snap to the reverted value. The observer above
|
|
195
|
-
// is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
|
|
196
|
-
// Fires synchronously before the debounced observer refresh, which then no-ops on
|
|
197
|
-
// the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
|
|
198
|
-
const u = platform('undo')
|
|
199
|
-
if (u && typeof u.on === 'function') {
|
|
200
|
-
const onRevert = () => {
|
|
201
|
-
if (state.ctx !== ctx) return
|
|
202
|
-
resyncForm(ctx, 'undo')
|
|
203
|
-
// A reverted change the consumer persists through onChange (e.g. the
|
|
204
|
-
// collection dashboard PUTs the record, not the page) would otherwise lag
|
|
205
|
-
// the undo: refreshForm re-syncs the form but never fires onChange, and a
|
|
206
|
-
// commit() here would be fingerprint-skipped (refreshForm already updated
|
|
207
|
-
// it). Extract the reverted state from the PAGE (the source of truth after
|
|
208
|
-
// an undo/redo; the form can lag a morph) and fire onChange directly, but
|
|
209
|
-
// only when it actually changed — guards spurious PUTs on undo/redo of
|
|
210
|
-
// unrelated edits elsewhere on the page.
|
|
211
|
-
const data = coerceBooleans(
|
|
212
|
-
engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
|
|
213
|
-
ctx.pageRules
|
|
214
|
-
)
|
|
215
|
-
if (stableStringify(data) !== stableStringify(ctx.lastData)) {
|
|
216
|
-
ctx.lastData = data
|
|
217
|
-
ctx.onChange?.(data, { path: '', structural: false })
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
u.on('undo', onRevert)
|
|
221
|
-
u.on('redo', onRevert)
|
|
222
|
-
ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Live-sync / version-restore / any framework-applied full-document morph runs
|
|
226
|
-
// inside window.hyperclay.Mutation.pause(), which the source-blind observer
|
|
227
|
-
// above is deaf to — so without this the form stays stale after a remote edit
|
|
228
|
-
// and the next local commit re-applies the stale data, clobbering the remote
|
|
229
|
-
// change. Subscribe to the morph-applied signal live-sync emits and re-extract
|
|
230
|
-
// with ignoreActiveValue:true (preserve the user's in-progress field). Same
|
|
231
|
-
// document live-sync dispatches on; unsubscribed in close().
|
|
232
|
-
const onLivesync = () => resyncForm(ctx, 'livesync')
|
|
233
|
-
ctx.livesyncUnsub = onPlatformEvent(doc, LIVESYNC_APPLIED, onLivesync)
|
|
234
|
-
|
|
235
|
-
// Wire global sortable callback to current ctx (replaced by close()).
|
|
236
|
-
globalCommitTarget.ctx = ctx
|
|
237
|
-
installGlobalSortableCommit(doc)
|
|
238
|
-
|
|
239
|
-
// Move focus into the shell — survives close+restore via previouslyFocused.
|
|
240
|
-
focusFirstIn(shell.root)
|
|
241
|
-
|
|
242
|
-
state.isOpen = true
|
|
243
|
-
state.ctx = ctx
|
|
244
|
-
state.shell = shell
|
|
245
|
-
state.opts = opts
|
|
246
|
-
|
|
247
|
-
ctx.dispatch('hcms:open', { pageRoot })
|
|
128
|
+
ctx.dispatch('hcms:open', { pageRoot, previous })
|
|
248
129
|
} catch (err) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
suppressUndo(() => shell.destroy())
|
|
255
|
-
state.isOpen = false
|
|
256
|
-
state.ctx = null
|
|
257
|
-
state.shell = null
|
|
258
|
-
state.opts = null
|
|
130
|
+
// A failed switch already destroyed the view that was up, so nothing is
|
|
131
|
+
// mounted and nothing can be reopened; the least bad outcome is handing
|
|
132
|
+
// focus back where it started. On a first open focus never moved, so
|
|
133
|
+
// restoring it there would be a no-op either way.
|
|
134
|
+
teardownSession(ctx, { dispatch: false, restoreFocus: !!carriedFocus, updateUrl: false })
|
|
259
135
|
throw err
|
|
260
136
|
}
|
|
261
137
|
}
|
|
262
138
|
|
|
263
|
-
function focusFirstIn(root) {
|
|
264
|
-
const sel = 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
265
|
-
const target = root.querySelector(sel)
|
|
266
|
-
if (target && typeof target.focus === 'function') target.focus()
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const globalCommitTarget = { ctx: null }
|
|
270
|
-
|
|
271
|
-
function installGlobalSortableCommit(doc) {
|
|
272
|
-
const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
|
|
273
|
-
if (!win) return
|
|
274
|
-
const hypercmsCommitFn = function hypercmsCommitGlobal() {
|
|
275
|
-
const ctx = globalCommitTarget.ctx
|
|
276
|
-
if (!ctx) return
|
|
277
|
-
restampAllSiblings(ctx.formRoot)
|
|
278
|
-
// Route through commitWithUndo so a drag-reorder lands as a labeled
|
|
279
|
-
// 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
|
|
280
|
-
// mutate+rollback is never recorded as a no-op commit. No-op pass-through
|
|
281
|
-
// when undo isn't loaded.
|
|
282
|
-
return commitWithUndo('Reorder', () =>
|
|
283
|
-
commit(extractFormData(ctx), { path: '', structural: true }, ctx)
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
|
|
287
|
-
// Also mirror to globalThis so non-window contexts (Node tests, workers)
|
|
288
|
-
// can resolve the bare name from `new Function('hypercmsCommit()')`.
|
|
289
|
-
if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
|
|
290
|
-
globalThis.hypercmsCommit = hypercmsCommitFn
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
|
|
295
|
-
// to cms=false (param kept) so a refresh/share of the post-close URL does not
|
|
296
|
-
// auto-reopen.
|
|
297
|
-
const AUTO_OPEN_PARAM = 'cms'
|
|
298
|
-
|
|
299
|
-
// Pure: given a location.search string, return the search string after a close.
|
|
300
|
-
// Only rewrites when cms=true is present; every other param + ordering is
|
|
301
|
-
// preserved, and a search without cms (or already cms=false) is returned
|
|
302
|
-
// unchanged so we never inject the param into a page that never carried it.
|
|
303
|
-
export function nextSearchAfterClose(search) {
|
|
304
|
-
const str = typeof search === 'string' ? search : ''
|
|
305
|
-
const qIndex = str.indexOf('?')
|
|
306
|
-
const query = qIndex === -1 ? str : str.slice(qIndex + 1)
|
|
307
|
-
if (!query) return str
|
|
308
|
-
const params = new URLSearchParams(query)
|
|
309
|
-
if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
|
|
310
|
-
params.set(AUTO_OPEN_PARAM, 'false')
|
|
311
|
-
return '?' + params.toString()
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Pure: should the CMS auto-open for this location.search? Exactly 'true'.
|
|
315
|
-
export function shouldAutoOpenFromSearch(search) {
|
|
316
|
-
const str = typeof search === 'string' ? search : ''
|
|
317
|
-
const qIndex = str.indexOf('?')
|
|
318
|
-
const query = qIndex === -1 ? str : str.slice(qIndex + 1)
|
|
319
|
-
if (!query) return false
|
|
320
|
-
return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
|
|
324
|
-
// every other param preserved). No-op when the param isn't present or the
|
|
325
|
-
// History API is unavailable.
|
|
326
|
-
function toggleCloseParam() {
|
|
327
|
-
if (typeof window === 'undefined' || !window.location || !window.history) return
|
|
328
|
-
if (typeof window.history.replaceState !== 'function') return
|
|
329
|
-
const current = window.location.search
|
|
330
|
-
const next = nextSearchAfterClose(current)
|
|
331
|
-
if (next === current) return
|
|
332
|
-
window.history.replaceState(window.history.state, '', next + window.location.hash)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
139
|
export function close() {
|
|
336
140
|
if (!state.isOpen) return
|
|
337
|
-
|
|
338
|
-
// Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
|
|
339
|
-
// host uploader) bails instead of mutating the page / firing onChange after
|
|
340
|
-
// teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
|
|
341
|
-
ctx.closed = true
|
|
342
|
-
// An upload still in flight is torn down like any other subscription. The
|
|
343
|
-
// ctx.closed check above already stops it writing; this stops it running.
|
|
344
|
-
for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
|
|
345
|
-
ctx.uploads?.clear()
|
|
346
|
-
const previouslyFocused = ctx.previouslyFocused
|
|
347
|
-
ctx.dispatch('hcms:close', null)
|
|
348
|
-
toggleCloseParam()
|
|
349
|
-
ctx.observerHandle?.unsubscribe?.()
|
|
350
|
-
ctx.undoUnsub?.()
|
|
351
|
-
ctx.livesyncUnsub?.()
|
|
352
|
-
ctx.detachEvents?.()
|
|
353
|
-
// destroy() removes the chrome-only body classes; suppress so the inverse
|
|
354
|
-
// class removal doesn't enter the undo stack either.
|
|
355
|
-
suppressUndo(() => shell.destroy())
|
|
356
|
-
state.isOpen = false
|
|
357
|
-
state.ctx = null
|
|
358
|
-
state.shell = null
|
|
359
|
-
state.opts = null
|
|
360
|
-
globalCommitTarget.ctx = null
|
|
361
|
-
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
362
|
-
try { previouslyFocused.focus() } catch (_) {}
|
|
363
|
-
}
|
|
141
|
+
teardownSession(state.ctx)
|
|
364
142
|
}
|
|
365
143
|
|
|
366
144
|
export function refresh() {
|
|
367
145
|
if (!state.isOpen) return
|
|
368
|
-
|
|
146
|
+
state.ctx.view.refresh('api')
|
|
369
147
|
}
|
|
370
148
|
|
|
371
149
|
export function isOpen() {
|
|
372
150
|
return state.isOpen
|
|
373
151
|
}
|
|
374
152
|
|
|
153
|
+
// Which view owns the active session. Nothing may infer this from
|
|
154
|
+
// body.hcms-open, which describes only the sidebar's page shift and says nothing
|
|
155
|
+
// about an inline session.
|
|
156
|
+
export function currentView() {
|
|
157
|
+
return state.isOpen && state.ctx ? state.ctx.view.name : null
|
|
158
|
+
}
|
|
159
|
+
|
|
375
160
|
export const api = {
|
|
376
161
|
getData() {
|
|
377
162
|
if (!state.isOpen) return null
|
|
@@ -425,72 +210,12 @@ export const api = {
|
|
|
425
210
|
},
|
|
426
211
|
}
|
|
427
212
|
|
|
428
|
-
function findLeafField(formRoot, path) {
|
|
429
|
-
const esc = cssEscape(path)
|
|
430
|
-
// Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
|
|
431
|
-
// stamps data-hcms-field on the wrapping container too, a bare attribute
|
|
432
|
-
// match would resolve to the container and lose the value write.
|
|
433
|
-
const leafSel =
|
|
434
|
-
`[data-hcms-path="${esc}"] input[data-hcms-field], ` +
|
|
435
|
-
`[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
|
|
436
|
-
`[data-hcms-path="${esc}"] select[data-hcms-field], ` +
|
|
437
|
-
`[data-hcms-path="${esc}"] img[data-hcms-field], ` +
|
|
438
|
-
`[data-hcms-path="${esc}"] a[data-hcms-field], ` +
|
|
439
|
-
`[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
|
|
440
|
-
// Leaf is the path-stamped element itself (inline-stamped fields):
|
|
441
|
-
`input[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
442
|
-
`textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
443
|
-
`select[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
444
|
-
`img[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
445
|
-
`a[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
446
|
-
`[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
|
|
447
|
-
return formRoot.querySelector(leafSel)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function writeFieldValue(el, value, formRoot, path) {
|
|
451
|
-
const tag = (el.tagName || '').toUpperCase()
|
|
452
|
-
const type = (el.getAttribute('type') || '').toLowerCase()
|
|
453
|
-
if (tag === 'INPUT' && type === 'checkbox') {
|
|
454
|
-
el.checked = value === true || value === 'true'
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
if (tag === 'INPUT' && type === 'radio') {
|
|
458
|
-
// Radios sharing the same path act as a group. Toggle the matching option.
|
|
459
|
-
const esc = cssEscape(path)
|
|
460
|
-
const group = formRoot.querySelectorAll(
|
|
461
|
-
`[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
|
|
462
|
-
)
|
|
463
|
-
if (group.length) {
|
|
464
|
-
group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
|
|
465
|
-
} else {
|
|
466
|
-
el.checked = String(el.value) === String(value ?? '')
|
|
467
|
-
}
|
|
468
|
-
return
|
|
469
|
-
}
|
|
470
|
-
if (tag === 'IMG') {
|
|
471
|
-
el.src = value == null ? '' : String(value)
|
|
472
|
-
return
|
|
473
|
-
}
|
|
474
|
-
if (tag === 'A') {
|
|
475
|
-
el.href = value == null ? '' : String(value)
|
|
476
|
-
return
|
|
477
|
-
}
|
|
478
|
-
if (el.hasAttribute && el.hasAttribute('contenteditable')) {
|
|
479
|
-
el.innerHTML = value == null ? '' : String(value)
|
|
480
|
-
return
|
|
481
|
-
}
|
|
482
|
-
if ('value' in el) {
|
|
483
|
-
el.value = value == null ? '' : String(value)
|
|
484
|
-
return
|
|
485
|
-
}
|
|
486
|
-
el.textContent = value == null ? '' : String(value)
|
|
487
|
-
}
|
|
488
|
-
|
|
489
213
|
// ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
|
|
490
214
|
// for everyone (no gating — this is a feature showcase; a non-admin's edits stay
|
|
491
215
|
// DOM-local and the host save path shows its view-mode notice). open() is
|
|
492
|
-
// idempotent-safe (it
|
|
493
|
-
// open() never double-mounts. No special opts — host
|
|
216
|
+
// idempotent-safe (it returns quietly if the same view is already open), so a
|
|
217
|
+
// host that also calls open() never double-mounts. No special opts — host
|
|
218
|
+
// defaults apply.
|
|
494
219
|
//
|
|
495
220
|
// Patience matters here: this runs at module load from the dist IIFE, which a
|
|
496
221
|
// host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
|
|
@@ -599,6 +324,10 @@ maybeInjectToggle({
|
|
|
599
324
|
open,
|
|
600
325
|
close,
|
|
601
326
|
isOpen,
|
|
327
|
+
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),
|
|
602
331
|
hasRules: (doc) => !!engine.findRules(doc, 'cms'),
|
|
603
332
|
})
|
|
604
333
|
|
|
@@ -608,6 +337,7 @@ const cms = {
|
|
|
608
337
|
refresh,
|
|
609
338
|
api,
|
|
610
339
|
get isOpen() { return state.isOpen },
|
|
340
|
+
currentView,
|
|
611
341
|
// Power-user exports (mostly for testing + advanced integration)
|
|
612
342
|
path: pathUtil,
|
|
613
343
|
scaffold,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const paths = {
|
|
2
|
+
edit: 'M8 10h2v6H8zm2 4h4v2h-4zm0-6h2v2h-2zm2-2h2v2h-2zm2-2h2v2h-2zm2-2h2v2h-2zm2 2h2v2h-2zm2 2h2v2h-2zm-2 2h2v2h-2zm-2 2h2v2h-2zm-2 2h2v2h-2zm-4 0h2v2h-2z',
|
|
3
|
+
'move-up': 'M11 4h2v2h2v2h2v2h2v2h-4v-2h-2v10h-2V10H9v2H5v-2h2V8h2V6h2z',
|
|
4
|
+
'move-down': 'M11 4h2v10h2v-2h4v2h-2v2h-2v2h-2v2h-2v-2H9v-2H7v-2H5v-2h4v2h2z',
|
|
5
|
+
remove: 'M5 5h3v3h3v3h2V8h3V5h3v3h-3v3h-3v2h3v3h3v3h-3v-3h-3v-3h-2v3H8v3H5v-3h3v-3h3v-2H8V8H5z',
|
|
6
|
+
add: 'M10 4h4v6h6v4h-6v6h-4v-6H4v-4h6z',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function inlineIcon(name) {
|
|
10
|
+
const viewBox = name === 'edit' ? '6 0 18 18' : '0 0 24 24'
|
|
11
|
+
const shape = name === 'settings'
|
|
12
|
+
? '<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>'
|
|
13
|
+
: `<path d="${paths[name]}"/>`
|
|
14
|
+
return `<svg class="hcms-inline-icon" xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="24" height="24" fill="currentColor" aria-hidden="true" focusable="false">${shape}</svg>`
|
|
15
|
+
}
|