@panphora/hyper-cms 0.4.2 → 0.6.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 +131 -5
- package/dist/hypercms.min.js +1204 -220
- package/package.json +5 -3
- package/src/events.js +298 -14
- package/src/form-builder.js +171 -19
- package/src/form-rules.js +23 -9
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +197 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +93 -18
- package/src/templates.js +431 -25
- package/src/theme/pixel-quiet.overrides.css +512 -0
- package/src/theme.generated.css +1175 -0
- package/src/vendor/mirk.vendor.js +197 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"HTML",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"module": "src/hypercms.js",
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./src/hypercms.js",
|
|
23
|
-
"./styles.css": "./src/
|
|
23
|
+
"./styles.css": "./src/theme.generated.css",
|
|
24
|
+
"./theme.css": "./src/theme.generated.css"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
26
27
|
"LICENSE",
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
"dist/*.js"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
|
-
"build": "
|
|
34
|
+
"build:theme": "node scripts/build-theme.js",
|
|
35
|
+
"build": "npm run build:theme && npx esbuild src/hypercms-bundle.js --bundle --minify --format=iife --global-name=hypercms --loader:.css=text --outfile=dist/hypercms.min.js",
|
|
34
36
|
"prepublishOnly": "npm run build",
|
|
35
37
|
"copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
|
|
36
38
|
"test:node": "node --test 'test-node/**/*.test.js'",
|
package/src/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { engine } from 'hyper-html-api'
|
|
2
2
|
import { applyWithRollback } from './apply-loop.js'
|
|
3
|
-
import { fromString as pathFromString } from './path.js'
|
|
4
|
-
import { buildItem } from './form-builder.js'
|
|
3
|
+
import { fromString as pathFromString, getRuleAtPath } from './path.js'
|
|
4
|
+
import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
|
|
5
5
|
import { scaffold } from './scaffold.js'
|
|
6
6
|
|
|
7
7
|
const BOUND = new WeakSet()
|
|
@@ -56,6 +56,11 @@ export function bindEvents(ctx) {
|
|
|
56
56
|
if (!target || !target.closest) return
|
|
57
57
|
if (!target.closest('[data-hcms-form-root]')) return
|
|
58
58
|
if (!target.matches('input, textarea, select')) return
|
|
59
|
+
// The upload picker fires `input` too, but its .value is the OS fake path
|
|
60
|
+
// (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
|
|
61
|
+
// carries data-hcms-field, so without this guard the closest() below would
|
|
62
|
+
// route the fake path into onScalarChange.
|
|
63
|
+
if (target.matches('input[type="file"]')) return
|
|
59
64
|
if (!target.closest('[data-hcms-field]') && !target.hasAttribute?.('data-hcms-field')) return
|
|
60
65
|
onScalarChange(target, ctx)
|
|
61
66
|
}
|
|
@@ -64,6 +69,10 @@ export function bindEvents(ctx) {
|
|
|
64
69
|
const target = e.target
|
|
65
70
|
if (!target || !target.closest) return
|
|
66
71
|
if (!target.closest('[data-hcms-form-root]')) return
|
|
72
|
+
if (target.matches('input[type="file"][data-hcms-upload]')) {
|
|
73
|
+
onUploadChange(target, ctx)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
67
76
|
if (target.matches('input[type="checkbox"], input[type="radio"], select')) {
|
|
68
77
|
onScalarChange(target, ctx)
|
|
69
78
|
}
|
|
@@ -75,11 +84,11 @@ export function bindEvents(ctx) {
|
|
|
75
84
|
const actionEl = target.closest('[data-hcms-action]')
|
|
76
85
|
if (!actionEl) return
|
|
77
86
|
const action = actionEl.getAttribute('data-hcms-action')
|
|
78
|
-
// add/remove/move must live inside the form root; close
|
|
87
|
+
// add/remove/move must live inside the form root; close inside the shell.
|
|
79
88
|
// Ignore stray data-hcms-action attributes elsewhere on the page.
|
|
80
|
-
if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down') {
|
|
89
|
+
if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload') {
|
|
81
90
|
if (!actionEl.closest('[data-hcms-form-root]')) return
|
|
82
|
-
} else if (action === 'close'
|
|
91
|
+
} else if (action === 'close') {
|
|
83
92
|
if (!actionEl.closest('[data-hcms-shell]')) return
|
|
84
93
|
}
|
|
85
94
|
if (action === 'add') {
|
|
@@ -90,15 +99,15 @@ export function bindEvents(ctx) {
|
|
|
90
99
|
} else if (action === 'remove') {
|
|
91
100
|
const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
|
|
92
101
|
if (!itemEl) return
|
|
93
|
-
|
|
102
|
+
requestRemove(itemEl, ctx)
|
|
94
103
|
} else if (action === 'move-up' || action === 'move-down') {
|
|
95
104
|
const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
|
|
96
105
|
if (!itemEl) return
|
|
97
106
|
onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
|
|
107
|
+
} else if (action === 'clear-upload') {
|
|
108
|
+
onClearUpload(actionEl, ctx)
|
|
98
109
|
} else if (action === 'close') {
|
|
99
110
|
ctx.onCloseRequested?.()
|
|
100
|
-
} else if (action === 'save') {
|
|
101
|
-
onSave(ctx)
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
|
|
@@ -115,10 +124,214 @@ export function bindEvents(ctx) {
|
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
// Element PROPERTIES a form field projects to that fire NO MutationRecord, so
|
|
128
|
+
// hyper-undo's observer can't see them (an input's live `value`, a checkbox's
|
|
129
|
+
// `checked`). Text projections ARE observed (characterData) and need nothing
|
|
130
|
+
// here. Kept narrow on purpose: the broader engine DOM_PROPERTIES_WRITE_SET
|
|
131
|
+
// includes observable writes (innerHTML/outerHTML) we must NOT double-record.
|
|
132
|
+
const UNOBSERVED_FIELD_PROPS = new Set(['value', 'checked'])
|
|
133
|
+
|
|
134
|
+
// Resolve the page element + property a scalar field projects to, but ONLY for
|
|
135
|
+
// the unobserved-property case above. `pathStr` is the field's FULL path (the
|
|
136
|
+
// scalar field element carries data-hcms-path = full path, e.g. "name" or
|
|
137
|
+
// "products.0.name"). Returns { el, prop, oldValue } captured from the live page
|
|
138
|
+
// BEFORE the apply, or null when the projection is text, targets an array item
|
|
139
|
+
// (the engine's ctx isn't pageRoot then), or can't be resolved.
|
|
140
|
+
function resolveUnobservedProjection(ctx, pathStr) {
|
|
141
|
+
if (!pathStr) return null
|
|
142
|
+
const fullPath = pathFromString(pathStr)
|
|
143
|
+
// Array-item fields project under a per-item engine ctx, not pageRoot. Resolving
|
|
144
|
+
// pageRoot.querySelector(selector) would grab the wrong item, so skip — the edit
|
|
145
|
+
// simply keeps today's no-undo behavior rather than recording a wrong revert.
|
|
146
|
+
if (fullPath.some((seg) => typeof seg === 'number' || seg === '*')) return null
|
|
147
|
+
const rule = getRuleAtPath(ctx.pageRules, fullPath)
|
|
148
|
+
if (typeof rule !== 'string') return null
|
|
149
|
+
const at = rule.lastIndexOf('@')
|
|
150
|
+
if (at === -1) return null // text projection — observed
|
|
151
|
+
const prop = rule.slice(at + 1)
|
|
152
|
+
if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
|
|
153
|
+
const selector = rule.slice(0, at)
|
|
154
|
+
const el = selector ? ctx.pageRoot.querySelector(selector) : ctx.pageRoot
|
|
155
|
+
if (!el) return null
|
|
156
|
+
return { el, prop, oldValue: el[prop] }
|
|
157
|
+
}
|
|
158
|
+
|
|
118
159
|
export function onScalarChange(target, ctx) {
|
|
119
160
|
const fieldEl = target.closest('[data-hcms-field]') || target
|
|
120
161
|
const pathStr = fieldEl.closest('[data-hcms-path]')?.getAttribute('data-hcms-path') || ''
|
|
162
|
+
// Capture the projected page value BEFORE commit() overwrites it. Property
|
|
163
|
+
// writes (@value/@checked) leave no MutationRecord, so without recording them
|
|
164
|
+
// explicitly they produce no undo step (text projections are observed).
|
|
165
|
+
const proj = resolveUnobservedProjection(ctx, pathStr)
|
|
121
166
|
commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
|
|
167
|
+
if (proj) {
|
|
168
|
+
const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
|
|
169
|
+
if (u && typeof u.recordValue === 'function') {
|
|
170
|
+
u.recordValue(proj.el, { prop: proj.prop, oldValue: proj.oldValue, newValue: proj.el[proj.prop] })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Crop adapter seam: ALL crop coupling lives here, between the file pick and
|
|
176
|
+
// the upload. A field opts in via data-hcms-crop on the page element (copied
|
|
177
|
+
// onto the built @image field by the form-builder); absent attr or absent
|
|
178
|
+
// quickcrop (standalone page) uploads raw. Returns { file, dataURL? }, or null
|
|
179
|
+
// to abort (crop cancelled / failed) — the caller resets the picker and skips
|
|
180
|
+
// the commit. quickcrop resolves null on cancel (it does NOT reject), so the
|
|
181
|
+
// cancel branch is the null check, not the catch.
|
|
182
|
+
const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeight: 2048 }
|
|
183
|
+
|
|
184
|
+
async function maybeCrop(file, fieldEl) {
|
|
185
|
+
const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
|
|
186
|
+
if (attr == null) return { file }
|
|
187
|
+
const crop = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.quickcrop) || null
|
|
188
|
+
if (typeof crop !== 'function') return { file }
|
|
189
|
+
try {
|
|
190
|
+
const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
|
|
191
|
+
if (r === null) return null
|
|
192
|
+
return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
|
|
193
|
+
} catch (err) {
|
|
194
|
+
showFieldUploadError(fieldEl, (err && err.message) || 'Crop failed')
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// "1:1" → 1, "16:9" → 16/9, "free" / "" / unparseable → null (freeform crop).
|
|
200
|
+
// Exported for tests.
|
|
201
|
+
export function parseCropAspect(value) {
|
|
202
|
+
const v = String(value ?? '').trim().toLowerCase()
|
|
203
|
+
if (v === '' || v === 'free') return null
|
|
204
|
+
const m = v.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/)
|
|
205
|
+
if (!m) return null
|
|
206
|
+
const w = parseFloat(m[1])
|
|
207
|
+
const h = parseFloat(m[2])
|
|
208
|
+
if (!w || !h) return null
|
|
209
|
+
return w / h
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// quickcrop returns a nameless Blob; uploadFileBasic rejects raw Blobs and the
|
|
213
|
+
// server keys content-type off the filename extension, so wrap it as a File
|
|
214
|
+
// whose extension matches the encoded mime. Exported for tests.
|
|
215
|
+
export function blobToFile(blob, originalName) {
|
|
216
|
+
const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
|
|
217
|
+
const base = String(originalName || 'image').replace(/\.[^.]+$/, '')
|
|
218
|
+
try {
|
|
219
|
+
return new File([blob], base + ext, { type: blob.type })
|
|
220
|
+
} catch {
|
|
221
|
+
return blob
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A file was picked in an @file/@image widget. Optionally crop, upload via the
|
|
226
|
+
// host's uploadFileBasic (or fall back to a local object-URL preview), write the
|
|
227
|
+
// resulting URL into the bound img.src / a.href leaf, then commit once. The leaf
|
|
228
|
+
// attribute change is an observed page mutation, so undo gets a clean step with
|
|
229
|
+
// no commitWithUndo. Async; failures surface inline and reset the picker.
|
|
230
|
+
export async function onUploadChange(inputEl, ctx) {
|
|
231
|
+
const file = inputEl.files && inputEl.files[0]
|
|
232
|
+
if (!file) return
|
|
233
|
+
const fieldEl = inputEl.closest('[data-hcms-path]')
|
|
234
|
+
if (!fieldEl) return
|
|
235
|
+
const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
|
|
236
|
+
|
|
237
|
+
// A fresh pick is a fresh attempt: clear any stale inline error (e.g. a
|
|
238
|
+
// crop failure from a previous attempt) before running the new flow.
|
|
239
|
+
showFieldUploadError(fieldEl, null)
|
|
240
|
+
|
|
241
|
+
const cropped = await maybeCrop(file, fieldEl)
|
|
242
|
+
if (!cropped || ctx.closed) { resetPicker(inputEl); return }
|
|
243
|
+
const fileLike = cropped.file
|
|
244
|
+
const previewDataUrl = cropped.dataURL || null
|
|
245
|
+
|
|
246
|
+
const upload = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.uploadFileBasic) || null
|
|
247
|
+
let url = null
|
|
248
|
+
if (typeof upload === 'function') {
|
|
249
|
+
try {
|
|
250
|
+
const res = await upload(fileLike)
|
|
251
|
+
// uploadFileBasic resolves the full envelope { uploads:[{name,nodeId,url}], … };
|
|
252
|
+
// the served URL is uploads[0].url, NOT res.url.
|
|
253
|
+
url = res && res.uploads && res.uploads[0] && res.uploads[0].url
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (ctx.closed) { resetPicker(inputEl); return }
|
|
256
|
+
showFieldUploadError(fieldEl, (err && err.message) || 'Upload failed')
|
|
257
|
+
ctx.dispatch?.('hcms:error', { error: err, path: pathStr })
|
|
258
|
+
resetPicker(inputEl)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// The editor may have been closed while the upload was in flight; bail before
|
|
263
|
+
// writing the (now detached) form leaf or committing to the live page.
|
|
264
|
+
if (ctx.closed) { resetPicker(inputEl); return }
|
|
265
|
+
if (!url) {
|
|
266
|
+
// No host uploader (or it returned nothing): local preview only, which the
|
|
267
|
+
// commit persists into the page as a blob:/data: URL — non-persistent, by
|
|
268
|
+
// design (documented), so the widget still works in a bare page / demo.
|
|
269
|
+
url = previewDataUrl || makeLocalPreviewUrl(fileLike)
|
|
270
|
+
}
|
|
271
|
+
if (!url) { resetPicker(inputEl); return }
|
|
272
|
+
|
|
273
|
+
showFieldUploadError(fieldEl, null)
|
|
274
|
+
writeUploadLeaf(fieldEl, url, fileLike.name)
|
|
275
|
+
commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
|
|
276
|
+
resetPicker(inputEl)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The × on an upload widget. Clears the bound leaf to empty and commits, so the
|
|
280
|
+
// :has()-driven chrome falls back to the empty state. Removes immediately (no
|
|
281
|
+
// consent gate) per the approved design; distinct from the "remove" action,
|
|
282
|
+
// which deletes a whole array item.
|
|
283
|
+
export function onClearUpload(actionEl, ctx) {
|
|
284
|
+
const fieldEl = actionEl.closest('[data-hcms-path]')
|
|
285
|
+
if (!fieldEl) return
|
|
286
|
+
const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
|
|
287
|
+
writeUploadLeaf(fieldEl, '', '')
|
|
288
|
+
const input = fieldEl.querySelector('input[type="file"][data-hcms-upload]')
|
|
289
|
+
if (input) resetPicker(input)
|
|
290
|
+
showFieldUploadError(fieldEl, null)
|
|
291
|
+
commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function findUploadLeaf(fieldEl) {
|
|
295
|
+
return fieldEl.querySelector
|
|
296
|
+
? fieldEl.querySelector('img[data-hcms-field], a[data-hcms-field]')
|
|
297
|
+
: null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Write a URL into the widget's bound leaf: an <img>'s src, or an <a>'s href
|
|
301
|
+
// plus its visible filename text. Empty url clears to the empty state.
|
|
302
|
+
function writeUploadLeaf(fieldEl, url, fileName) {
|
|
303
|
+
const leaf = findUploadLeaf(fieldEl)
|
|
304
|
+
if (!leaf) return
|
|
305
|
+
const tag = (leaf.tagName || '').toUpperCase()
|
|
306
|
+
if (tag === 'IMG') {
|
|
307
|
+
leaf.src = url || ''
|
|
308
|
+
} else if (tag === 'A') {
|
|
309
|
+
leaf.href = url || ''
|
|
310
|
+
leaf.textContent = url ? (fileName || fileNameFromUrl(url)) : ''
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resetPicker(inputEl) {
|
|
315
|
+
// Clear the selection so re-picking the same file fires `change` again.
|
|
316
|
+
try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function makeLocalPreviewUrl(file) {
|
|
320
|
+
const U = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL : null
|
|
321
|
+
if (!U) return ''
|
|
322
|
+
try { return U.createObjectURL(file) } catch { return '' }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function showFieldUploadError(fieldEl, message) {
|
|
326
|
+
const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
|
|
327
|
+
if (!slot) return
|
|
328
|
+
if (message) {
|
|
329
|
+
slot.textContent = message
|
|
330
|
+
slot.hidden = false
|
|
331
|
+
} else {
|
|
332
|
+
slot.textContent = ''
|
|
333
|
+
slot.hidden = true
|
|
334
|
+
}
|
|
122
335
|
}
|
|
123
336
|
|
|
124
337
|
export function onAdd(arrayPath, ctx) {
|
|
@@ -148,6 +361,9 @@ export function onAdd(arrayPath, ctx) {
|
|
|
148
361
|
pathArr: [...pathArr, nextIndex],
|
|
149
362
|
data: itemData,
|
|
150
363
|
doc: ctx.doc,
|
|
364
|
+
// Component arrays (chips) record their item-template key at build time so
|
|
365
|
+
// added items keep the same chrome.
|
|
366
|
+
itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
|
|
151
367
|
})
|
|
152
368
|
slot.appendChild(itemNode)
|
|
153
369
|
updateArrayButtonsVisibility(arrayEl)
|
|
@@ -186,6 +402,51 @@ export function onMove(itemEl, direction, ctx) {
|
|
|
186
402
|
)
|
|
187
403
|
}
|
|
188
404
|
|
|
405
|
+
const DEFAULT_CONFIRM_REMOVE = 'Delete this item?'
|
|
406
|
+
|
|
407
|
+
// Resolve whether removing an item from `arrayEl` should confirm first, and with
|
|
408
|
+
// what prompt. Most specific wins: a per-array `data-hcms-confirm-remove`
|
|
409
|
+
// attribute (a string overrides the prompt; "off"/"false"/"no"/"0" disables that
|
|
410
|
+
// one array), then the global `confirmRemove` open() option (a string overrides
|
|
411
|
+
// the prompt, true forces every list, false disables everything), then the
|
|
412
|
+
// built-in default. The default confirms card removals (object-array items hold
|
|
413
|
+
// several fields) and leaves scalar-array chips instant. Returns the prompt to
|
|
414
|
+
// confirm with, or null to remove without asking.
|
|
415
|
+
function resolveRemoveConfirm(arrayEl, ctx) {
|
|
416
|
+
const attr = arrayEl && arrayEl.getAttribute('data-hcms-confirm-remove')
|
|
417
|
+
if (attr != null) {
|
|
418
|
+
if (/^(off|false|no|0)$/i.test(attr.trim())) return null
|
|
419
|
+
return attr || DEFAULT_CONFIRM_REMOVE
|
|
420
|
+
}
|
|
421
|
+
const global = ctx && ctx.confirmRemove
|
|
422
|
+
if (global === false) return null
|
|
423
|
+
if (typeof global === 'string') return global || DEFAULT_CONFIRM_REMOVE
|
|
424
|
+
if (global === true) return DEFAULT_CONFIRM_REMOVE
|
|
425
|
+
return arrayEl && arrayEl.getAttribute('data-hcms-shape') === 'object-array'
|
|
426
|
+
? DEFAULT_CONFIRM_REMOVE
|
|
427
|
+
: null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Built-in delete confirmation: every list-item removal routes through here so
|
|
431
|
+
// the policy applies uniformly. Confirmation uses hyperclay's consent() (the
|
|
432
|
+
// styled modal) when the host page provides it, falls back to native confirm(),
|
|
433
|
+
// and proceeds when neither exists (non-browser). Programmatic api.removeItem()
|
|
434
|
+
// bypasses this by calling onRemove directly, the way undo treats deliberate
|
|
435
|
+
// actions.
|
|
436
|
+
export function requestRemove(itemEl, ctx) {
|
|
437
|
+
const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
|
|
438
|
+
const message = resolveRemoveConfirm(arrayEl, ctx)
|
|
439
|
+
if (message == null) return onRemove(itemEl, ctx)
|
|
440
|
+
const consent = typeof window !== 'undefined' && (window.hyperclay?.consent || window.consent)
|
|
441
|
+
if (typeof consent === 'function') {
|
|
442
|
+
Promise.resolve(consent(message)).then(() => onRemove(itemEl, ctx), () => {})
|
|
443
|
+
} else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
444
|
+
if (window.confirm(message)) onRemove(itemEl, ctx)
|
|
445
|
+
} else {
|
|
446
|
+
onRemove(itemEl, ctx)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
189
450
|
export function onRemove(itemEl, ctx) {
|
|
190
451
|
const path = itemEl.getAttribute('data-hcms-path') || ''
|
|
191
452
|
const parent = itemEl.parentElement
|
|
@@ -208,12 +469,6 @@ export function onRemove(itemEl, ctx) {
|
|
|
208
469
|
)
|
|
209
470
|
}
|
|
210
471
|
|
|
211
|
-
export function onSave(ctx) {
|
|
212
|
-
const data = extractFormData(ctx)
|
|
213
|
-
ctx.dispatch?.('hcms:save', { data })
|
|
214
|
-
ctx.onSave?.(data)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
472
|
export function commit(newData, info, ctx) {
|
|
218
473
|
const fingerprint = stableStringify(newData)
|
|
219
474
|
if (fingerprint === ctx.lastFingerprint) return { ok: true, skipped: true }
|
|
@@ -412,6 +667,16 @@ export function restampAllSiblings(formRoot) {
|
|
|
412
667
|
}
|
|
413
668
|
|
|
414
669
|
function restampSiblingPaths(parent) {
|
|
670
|
+
// Radio names are rewritten one item at a time below, so during a move or
|
|
671
|
+
// drag a just-renamed radio can transiently share a name with a
|
|
672
|
+
// not-yet-renamed sibling — and the browser enforces single-selection per
|
|
673
|
+
// group AT THAT INSTANT, permanently unchecking one of them (null extract →
|
|
674
|
+
// the whole-form commit clobbers that field's page value). Snapshot every
|
|
675
|
+
// radio's checked state and re-assert it once all names are final (and
|
|
676
|
+
// unique per field) again.
|
|
677
|
+
const radios = parent.querySelectorAll
|
|
678
|
+
? Array.from(parent.querySelectorAll('input[type="radio"][data-hcms-field]'), (r) => [r, r.checked])
|
|
679
|
+
: []
|
|
415
680
|
let i = 0
|
|
416
681
|
for (const child of parent.children) {
|
|
417
682
|
if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
|
|
@@ -423,6 +688,9 @@ function restampSiblingPaths(parent) {
|
|
|
423
688
|
if (newPath !== path) restampSubtree(child, path, newPath)
|
|
424
689
|
i++
|
|
425
690
|
}
|
|
691
|
+
for (const [r, wasChecked] of radios) {
|
|
692
|
+
if (r.checked !== wasChecked) r.checked = wasChecked
|
|
693
|
+
}
|
|
426
694
|
}
|
|
427
695
|
|
|
428
696
|
function restampSubtree(root, oldPrefix, newPrefix) {
|
|
@@ -436,6 +704,22 @@ function restampSubtree(root, oldPrefix, newPrefix) {
|
|
|
436
704
|
el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
|
|
437
705
|
}
|
|
438
706
|
}
|
|
707
|
+
syncRadioGroupNames(root)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Radio group names derive from the field path at build time (populateOptions),
|
|
711
|
+
// so a restamp that moves paths must recompute them. Otherwise a removed-then-
|
|
712
|
+
// re-added sibling gets a fresh name that collides with this item's stale one:
|
|
713
|
+
// the two fields share a browser radio group, checking one unchecks the other,
|
|
714
|
+
// and the whole-form commit clobbers the unchecked field's page value. Only
|
|
715
|
+
// names the builder minted ('hcms-' prefix) are touched — radios in inline or
|
|
716
|
+
// custom templates keep whatever name the author gave them.
|
|
717
|
+
function syncRadioGroupNames(root) {
|
|
718
|
+
for (const input of root.querySelectorAll('input[type="radio"][data-hcms-field]')) {
|
|
719
|
+
if (!input.name || !input.name.startsWith('hcms-')) continue
|
|
720
|
+
const host = input.closest('[data-hcms-path]')
|
|
721
|
+
if (host) input.name = radioGroupName(host.getAttribute('data-hcms-path'))
|
|
722
|
+
}
|
|
439
723
|
}
|
|
440
724
|
|
|
441
725
|
function stableStringify(value) {
|