@panphora/hyper-cms 0.6.0 → 0.6.1
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 +19 -5
- package/dist/hypercms.min.js +470 -77
- package/package.json +1 -1
- package/src/enhance.js +97 -0
- package/src/events.js +4 -1
- package/src/form-rules.js +2 -0
- package/src/hypercms.js +22 -1
- package/src/refresh.js +8 -1
- package/src/templates.js +17 -2
- package/src/theme/pixel-quiet.overrides.css +26 -1
- package/src/theme.generated.css +434 -61
- package/src/toggle.js +88 -0
- package/src/vendor/mirk.vendor.js +15 -0
package/package.json
CHANGED
package/src/enhance.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Post-mount field enhancements + the rich-text rule upgrade.
|
|
2
|
+
//
|
|
3
|
+
// Bare scalar rules bind textContent, which flattens inline HTML (links,
|
|
4
|
+
// bold, code) on extract and destroys it on the first apply. When the bound
|
|
5
|
+
// page element actually contains child elements, the rule is upgraded to
|
|
6
|
+
// @innerHTML so the round-trip preserves markup and the form renders the
|
|
7
|
+
// @richtext component (a contenteditable surface, richclay-powered when the
|
|
8
|
+
// host page ships richclay). Explicit @prop rules, array rules, and rules
|
|
9
|
+
// whose element holds only text are left untouched. Hosts opt out with
|
|
10
|
+
// cms.open({ richText: false }).
|
|
11
|
+
|
|
12
|
+
export function upgradeRichTextRules(rules, pageRoot) {
|
|
13
|
+
if (!pageRoot || rules == null) return rules
|
|
14
|
+
return walk(rules)
|
|
15
|
+
|
|
16
|
+
function walk(rule) {
|
|
17
|
+
if (typeof rule === 'string') {
|
|
18
|
+
if (rule.endsWith('[]')) return rule
|
|
19
|
+
if (rule.lastIndexOf('@') >= 0) return rule
|
|
20
|
+
let el = null
|
|
21
|
+
try {
|
|
22
|
+
el = pageRoot.querySelector(rule)
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return rule
|
|
25
|
+
}
|
|
26
|
+
return el && el.children.length > 0 ? rule + '@innerHTML' : rule
|
|
27
|
+
}
|
|
28
|
+
// Array rules ([itemSelector, shape]) scope their inner selectors per
|
|
29
|
+
// item; a document-level querySelector can't answer "does THIS item's
|
|
30
|
+
// element hold markup", so array subtrees are never upgraded.
|
|
31
|
+
if (Array.isArray(rule)) return rule
|
|
32
|
+
if (rule && typeof rule === 'object') {
|
|
33
|
+
const out = Object.create(null)
|
|
34
|
+
for (const [k, v] of Object.entries(rule)) out[k] = walk(v)
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
return rule
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Grow a form textarea to fit its content. Browsers with `field-sizing:
|
|
42
|
+
// content` handle this in CSS (the theme sets it); everywhere else the
|
|
43
|
+
// height is pinned to scrollHeight on populate and on every input.
|
|
44
|
+
export function autosizeTextarea(el) {
|
|
45
|
+
if (!el || el.tagName !== 'TEXTAREA') return
|
|
46
|
+
const win = el.ownerDocument.defaultView || (typeof window !== 'undefined' ? window : null)
|
|
47
|
+
if (win && win.CSS && win.CSS.supports && win.CSS.supports('field-sizing: content')) return
|
|
48
|
+
el.style.height = 'auto'
|
|
49
|
+
el.style.height = el.scrollHeight + 'px'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Runs after any form (or form fragment) lands in the live DOM: sizes the
|
|
53
|
+
// autosize fallback and mounts richclay on rich-text surfaces when the host
|
|
54
|
+
// page ships it (window.richclay / window.hyperclay.RichClay). Without
|
|
55
|
+
// richclay the surface stays a plain contenteditable bound via @innerHTML,
|
|
56
|
+
// which still round-trips markup — richclay only adds the editing chrome.
|
|
57
|
+
export function enhanceFields(root, doc) {
|
|
58
|
+
if (!root || !root.querySelectorAll) return
|
|
59
|
+
root.querySelectorAll('textarea[data-hcms-field]').forEach(autosizeTextarea)
|
|
60
|
+
|
|
61
|
+
const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
|
|
62
|
+
const RichClay =
|
|
63
|
+
(win && win.richclay && win.richclay.RichClay) ||
|
|
64
|
+
(win && win.hyperclay && win.hyperclay.RichClay) ||
|
|
65
|
+
(win && typeof win.RichClay === 'function' ? win.RichClay : null)
|
|
66
|
+
if (!RichClay) return
|
|
67
|
+
|
|
68
|
+
root.querySelectorAll('[contenteditable][data-hcms-field]').forEach((el) => {
|
|
69
|
+
if (el.__hcmsRichclay) return
|
|
70
|
+
let editor
|
|
71
|
+
try {
|
|
72
|
+
// hyperclay:false keeps the form field out of the page's save/undo
|
|
73
|
+
// machinery (page writes go through commit) and activates the editor
|
|
74
|
+
// regardless of the host's edit-mode signal.
|
|
75
|
+
editor = new RichClay(el, {
|
|
76
|
+
inline: true,
|
|
77
|
+
hyperclay: false,
|
|
78
|
+
toolbar: ['bold', 'italic', 'link', 'undo', 'redo'],
|
|
79
|
+
})
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn('[hypercms] richclay activation failed; field stays plain contenteditable', err)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
el.__hcmsRichclay = editor
|
|
85
|
+
// Toolbar commands mutate the DOM through squire, which doesn't always
|
|
86
|
+
// fire a native input event; mirror squire's own input signal so the
|
|
87
|
+
// form's delegated listener commits the change. Duplicate events are
|
|
88
|
+
// harmless — commits are fingerprinted.
|
|
89
|
+
const squire = editor && editor.squire
|
|
90
|
+
if (squire && typeof squire.addEventListener === 'function') {
|
|
91
|
+
squire.addEventListener('input', () => {
|
|
92
|
+
const Ev = (win && win.Event) || Event
|
|
93
|
+
el.dispatchEvent(new Ev('input', { bubbles: true }))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
package/src/events.js
CHANGED
|
@@ -3,6 +3,7 @@ import { applyWithRollback } from './apply-loop.js'
|
|
|
3
3
|
import { fromString as pathFromString, getRuleAtPath } from './path.js'
|
|
4
4
|
import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
|
|
5
5
|
import { scaffold } from './scaffold.js'
|
|
6
|
+
import { autosizeTextarea, enhanceFields } from './enhance.js'
|
|
6
7
|
|
|
7
8
|
const BOUND = new WeakSet()
|
|
8
9
|
|
|
@@ -55,7 +56,8 @@ export function bindEvents(ctx) {
|
|
|
55
56
|
const target = e.target
|
|
56
57
|
if (!target || !target.closest) return
|
|
57
58
|
if (!target.closest('[data-hcms-form-root]')) return
|
|
58
|
-
if (!target.matches('input, textarea, select')) return
|
|
59
|
+
if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
|
|
60
|
+
if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
|
|
59
61
|
// The upload picker fires `input` too, but its .value is the OS fake path
|
|
60
62
|
// (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
|
|
61
63
|
// carries data-hcms-field, so without this guard the closest() below would
|
|
@@ -366,6 +368,7 @@ export function onAdd(arrayPath, ctx) {
|
|
|
366
368
|
itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
|
|
367
369
|
})
|
|
368
370
|
slot.appendChild(itemNode)
|
|
371
|
+
enhanceFields(itemNode, ctx.doc)
|
|
369
372
|
updateArrayButtonsVisibility(arrayEl)
|
|
370
373
|
return commitWithUndo(`Add ${arrayPath}`, () =>
|
|
371
374
|
commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
|
package/src/form-rules.js
CHANGED
|
@@ -21,6 +21,8 @@ export function fieldPropertyFor(el) {
|
|
|
21
21
|
}
|
|
22
22
|
if (tag === 'TEXTAREA' || tag === 'SELECT') return 'value'
|
|
23
23
|
if (TAG_PROP_MAP[tag]) return TAG_PROP_MAP[tag]
|
|
24
|
+
// Rich-text surfaces (the @richtext component): the value IS the markup.
|
|
25
|
+
if (el.hasAttribute && el.hasAttribute('contenteditable')) return 'innerHTML'
|
|
24
26
|
return null
|
|
25
27
|
}
|
|
26
28
|
|
package/src/hypercms.js
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
|
|
21
21
|
import { refreshForm, installObserver } from './refresh.js'
|
|
22
22
|
import { warnUnmatchedTemplates } from './diagnostics.js'
|
|
23
|
+
import { enhanceFields, upgradeRichTextRules } from './enhance.js'
|
|
24
|
+
import { maybeInjectToggle } from './toggle.js'
|
|
23
25
|
|
|
24
26
|
export function installStyles(text) {
|
|
25
27
|
setShellStyles(text)
|
|
@@ -100,7 +102,12 @@ export function open(opts = {}) {
|
|
|
100
102
|
const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
|
|
101
103
|
throw new Error(`hypercms: no rules found for ${what}`)
|
|
102
104
|
}
|
|
103
|
-
|
|
105
|
+
// Rich-text upgrade (default on): bare scalar rules whose page element
|
|
106
|
+
// contains child elements are rebound through @innerHTML so links and
|
|
107
|
+
// inline formatting survive the round-trip, and their form fields render
|
|
108
|
+
// the @richtext component. Opt out with cms.open({ richText: false }).
|
|
109
|
+
const richText = opts.richText !== false
|
|
110
|
+
const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
|
|
104
111
|
const rulesTagNode = found.tagNode
|
|
105
112
|
|
|
106
113
|
injectDefaults(doc)
|
|
@@ -130,6 +137,7 @@ export function open(opts = {}) {
|
|
|
130
137
|
formRules,
|
|
131
138
|
rulesTagNode,
|
|
132
139
|
rulesSource: source,
|
|
140
|
+
richText,
|
|
133
141
|
formRoot: shell.formRoot,
|
|
134
142
|
shellRoot: shell.root,
|
|
135
143
|
errorEl: shell.errorEl,
|
|
@@ -165,6 +173,7 @@ export function open(opts = {}) {
|
|
|
165
173
|
try {
|
|
166
174
|
const fragment = buildForm({ pageRules, formRules, data, doc })
|
|
167
175
|
shell.formRoot.appendChild(fragment)
|
|
176
|
+
enhanceFields(shell.formRoot, doc)
|
|
168
177
|
|
|
169
178
|
bindEvents(ctx)
|
|
170
179
|
ctx.updateFingerprint()
|
|
@@ -455,6 +464,10 @@ function writeFieldValue(el, value, formRoot, path) {
|
|
|
455
464
|
el.href = value == null ? '' : String(value)
|
|
456
465
|
return
|
|
457
466
|
}
|
|
467
|
+
if (el.hasAttribute && el.hasAttribute('contenteditable')) {
|
|
468
|
+
el.innerHTML = value == null ? '' : String(value)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
458
471
|
if ('value' in el) {
|
|
459
472
|
el.value = value == null ? '' : String(value)
|
|
460
473
|
return
|
|
@@ -566,6 +579,14 @@ function whenReady(fn) {
|
|
|
566
579
|
// window, and the guards above no-op there).
|
|
567
580
|
maybeAutoOpen()
|
|
568
581
|
|
|
582
|
+
// Floating edit-mode toggle at module load (same browser-only no-op in node).
|
|
583
|
+
maybeInjectToggle({
|
|
584
|
+
open,
|
|
585
|
+
close,
|
|
586
|
+
isOpen,
|
|
587
|
+
hasRules: (doc) => !!engine.findRules(doc, 'cms'),
|
|
588
|
+
})
|
|
589
|
+
|
|
569
590
|
const cms = {
|
|
570
591
|
open,
|
|
571
592
|
close,
|
package/src/refresh.js
CHANGED
|
@@ -4,6 +4,7 @@ import { morphForm } from './morph.js'
|
|
|
4
4
|
import { injectDefaults, injectComponents } from './templates.js'
|
|
5
5
|
import { deriveFormRules } from './form-rules.js'
|
|
6
6
|
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
|
+
import { enhanceFields, upgradeRichTextRules } from './enhance.js'
|
|
7
8
|
|
|
8
9
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
9
10
|
|
|
@@ -13,7 +14,10 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
13
14
|
// livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
|
|
14
15
|
const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
|
|
15
16
|
if (found) {
|
|
16
|
-
|
|
17
|
+
// Re-apply the rich-text upgrade open() performed: the tag holds the
|
|
18
|
+
// author's bare rules, and using them raw would rebind upgraded fields
|
|
19
|
+
// back to textContent mid-session.
|
|
20
|
+
ctx.pageRules = ctx.richText ? upgradeRichTextRules(found.rules, ctx.pageRoot) : found.rules
|
|
17
21
|
ctx.rulesTagNode = found.tagNode
|
|
18
22
|
}
|
|
19
23
|
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
@@ -32,6 +36,9 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
32
36
|
doc: ctx.doc,
|
|
33
37
|
})
|
|
34
38
|
morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
|
|
39
|
+
// The morph can rebuild field nodes, dropping autosize heights and richclay
|
|
40
|
+
// instances (instances are per-element); re-enhance whatever is new.
|
|
41
|
+
enhanceFields(ctx.formRoot, ctx.doc)
|
|
35
42
|
// morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
|
|
36
43
|
// the last error state so error messages survive across observer-driven refreshes
|
|
37
44
|
// (e.g., the rollback after a failed add).
|
package/src/templates.js
CHANGED
|
@@ -23,10 +23,13 @@ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hi
|
|
|
23
23
|
const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
|
|
24
24
|
|
|
25
25
|
const DEFAULT_TEMPLATES = {
|
|
26
|
+
// The default scalar is a one-row auto-growing textarea, not an <input>:
|
|
27
|
+
// page copy is prose and routinely wraps. Growth comes from field-sizing:
|
|
28
|
+
// content in the theme, with a scrollHeight fallback (enhance.js).
|
|
26
29
|
'@scalar': `
|
|
27
30
|
<label class="hcms-field" data-hcms-shape="scalar">
|
|
28
31
|
<span class="hcms-label" data-hcms-label></span>
|
|
29
|
-
<
|
|
32
|
+
<textarea class="mirk-textarea" rows="1" data-hcms-field></textarea>
|
|
30
33
|
<div class="hcms-error" hidden></div>
|
|
31
34
|
</label>
|
|
32
35
|
`,
|
|
@@ -172,6 +175,17 @@ const DEFAULT_TEMPLATES = {
|
|
|
172
175
|
<div class="hcms-error" hidden></div>
|
|
173
176
|
</label>
|
|
174
177
|
`,
|
|
178
|
+
// Rich-text surface for @innerHTML-bound rules: a contenteditable div whose
|
|
179
|
+
// value interface is innerHTML (fieldPropertyFor), so links and inline
|
|
180
|
+
// formatting survive the round-trip. richclay mounts on it when the host
|
|
181
|
+
// page ships it (enhance.js); otherwise it stays a plain contenteditable.
|
|
182
|
+
'@richtext': `
|
|
183
|
+
<div class="hcms-field" data-hcms-shape="scalar">
|
|
184
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
185
|
+
<div class="mirk-textarea hcms-richtext" contenteditable="true" data-hcms-field></div>
|
|
186
|
+
<div class="hcms-error" hidden></div>
|
|
187
|
+
</div>
|
|
188
|
+
`,
|
|
175
189
|
'@number': `
|
|
176
190
|
<label class="hcms-field" data-hcms-shape="scalar">
|
|
177
191
|
<span class="hcms-label" data-hcms-label></span>
|
|
@@ -233,7 +247,7 @@ export function injectComponentTemplate(doc, key) {
|
|
|
233
247
|
// common than uploading a file, so an a@href rule stays a plain URL field and
|
|
234
248
|
// the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
|
|
235
249
|
// see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
|
|
236
|
-
const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox' }
|
|
250
|
+
const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
|
|
237
251
|
// data-hcms-component values for scalar rules. "chips" is array-only (below).
|
|
238
252
|
const SCALAR_COMPONENT_BY_NAME = {
|
|
239
253
|
image: '@image',
|
|
@@ -244,6 +258,7 @@ const SCALAR_COMPONENT_BY_NAME = {
|
|
|
244
258
|
radio: '@radio',
|
|
245
259
|
textarea: '@textarea',
|
|
246
260
|
number: '@number',
|
|
261
|
+
richtext: '@richtext',
|
|
247
262
|
}
|
|
248
263
|
// Every opt-in component injects on demand; only the 6 shape templates are
|
|
249
264
|
// always present.
|
|
@@ -227,7 +227,7 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
227
227
|
display: flex;
|
|
228
228
|
flex-direction: column;
|
|
229
229
|
gap: 26px;
|
|
230
|
-
padding:
|
|
230
|
+
padding: 12px 24px 28px;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
/* one labeled scalar field */
|
|
@@ -253,6 +253,31 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
253
253
|
padding: 9px 14px 10px;
|
|
254
254
|
}
|
|
255
255
|
.hcms-shell.pixel-quiet textarea.mirk-input { min-height: 76px; resize: vertical; }
|
|
256
|
+
|
|
257
|
+
/* default scalar fields: one-row textareas that grow with their content.
|
|
258
|
+
Browsers without field-sizing get a scrollHeight fallback (enhance.js). */
|
|
259
|
+
.hcms-shell.pixel-quiet .hcms-form textarea.mirk-textarea {
|
|
260
|
+
field-sizing: content;
|
|
261
|
+
resize: none;
|
|
262
|
+
overflow: hidden;
|
|
263
|
+
min-height: 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* rich-text surface (@richtext): a contenteditable styled like a textarea */
|
|
267
|
+
.hcms-shell.pixel-quiet .hcms-richtext {
|
|
268
|
+
min-height: 2.5em;
|
|
269
|
+
cursor: text;
|
|
270
|
+
overflow-wrap: break-word;
|
|
271
|
+
}
|
|
272
|
+
.hcms-shell.pixel-quiet .hcms-richtext a {
|
|
273
|
+
color: inherit;
|
|
274
|
+
text-decoration: underline;
|
|
275
|
+
text-underline-offset: 2px;
|
|
276
|
+
}
|
|
277
|
+
.hcms-shell.pixel-quiet .hcms-richtext:empty::before {
|
|
278
|
+
content: attr(data-hcms-placeholder);
|
|
279
|
+
color: var(--mirk-placeholder-color);
|
|
280
|
+
}
|
|
256
281
|
.hcms-shell.pixel-quiet .mirk-select__field {
|
|
257
282
|
font-size: 15px;
|
|
258
283
|
padding: 8px 40px 9px 14px;
|