@panphora/hyper-cms 0.11.5 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -6
- package/dist/hypercms.min.js +169 -84
- package/package.json +5 -3
- package/src/apply-loop.js +3 -2
- package/src/catalogs/colors.js +293 -0
- package/src/catalogs/fonts.js +126 -0
- package/src/catalogs/index.js +3 -0
- package/src/catalogs/variant-icons.js +24 -0
- package/src/color-control.js +196 -0
- package/src/events.js +25 -2
- package/src/font-control.js +142 -0
- package/src/fonts-link.js +83 -0
- package/src/form-builder.js +85 -3
- package/src/form-rules.js +48 -3
- package/src/hypercms.js +111 -21
- package/src/page-root.js +28 -0
- package/src/refresh.js +4 -1
- package/src/session.js +51 -3
- package/src/targets.js +55 -13
- package/src/templates.js +100 -6
- package/src/theme/pixel-quiet.overrides.css +91 -0
- package/src/theme.generated.css +91 -0
- package/src/toggle.js +80 -296
- package/src/variant-control.js +46 -0
- package/src/views/inline-layer.js +71 -15
- package/src/views/inline.js +87 -17
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { checkCustomPropertyValue } from 'hyper-html-api/engine'
|
|
2
|
+
import { COLORS, HUES, SHADES } from './catalogs/colors.js'
|
|
3
|
+
|
|
4
|
+
// The @color component: swatches from the Tailwind palette plus an optional
|
|
5
|
+
// custom picker, all writing one hidden field that the form commits like any
|
|
6
|
+
// other scalar. Built here rather than in form-builder.js because the events
|
|
7
|
+
// side (pick, clear, panel, custom entry) needs the same helpers.
|
|
8
|
+
|
|
9
|
+
const INLINE_LIMIT = 12
|
|
10
|
+
const BY_ID = new Map(COLORS.map((c) => [c.id, c]))
|
|
11
|
+
const BY_VALUE = new Map(COLORS.map((c) => [c.value, c]))
|
|
12
|
+
const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
|
13
|
+
const COLOR_FUNCTION = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\(.*\)$/i
|
|
14
|
+
const UNSAFE = /[;{}<>\\]|url\s*\(/i
|
|
15
|
+
|
|
16
|
+
// The form is rebuilt after every page change, so which panels are open is
|
|
17
|
+
// kept outside it: per document, by field path.
|
|
18
|
+
const OPEN = new WeakMap()
|
|
19
|
+
|
|
20
|
+
function openPanels(doc) {
|
|
21
|
+
let set = OPEN.get(doc)
|
|
22
|
+
if (!set) OPEN.set(doc, (set = new Set()))
|
|
23
|
+
return set
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A value the custom picker may write. The browser's own parser decides when it
|
|
27
|
+
// is there; without one (node tests) a conservative shape check stands in.
|
|
28
|
+
export function isColorValue(value, doc) {
|
|
29
|
+
const text = String(value ?? '').trim()
|
|
30
|
+
if (!text || UNSAFE.test(text) || checkCustomPropertyValue(text)) return false
|
|
31
|
+
const css = doc?.defaultView?.CSS
|
|
32
|
+
if (css && typeof css.supports === 'function') return css.supports('color', text)
|
|
33
|
+
return HEX.test(text) || COLOR_FUNCTION.test(text) || /^[a-z]+$/i.test(text)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function humanColor(c) {
|
|
37
|
+
return `${c.hue[0].toUpperCase()}${c.hue.slice(1)} ${c.shade}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toSwatch(c) {
|
|
41
|
+
return { id: c.id, label: humanColor(c), value: c.value }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function colorLabel(value) {
|
|
45
|
+
const text = String(value ?? '').trim()
|
|
46
|
+
const hit = BY_VALUE.get(text)
|
|
47
|
+
return hit ? humanColor(hit) : text
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseColorOptions(tokens, doc) {
|
|
51
|
+
if (!tokens || tokens.length === 0) {
|
|
52
|
+
return { swatches: COLORS.map(toSwatch), custom: true, full: true }
|
|
53
|
+
}
|
|
54
|
+
const swatches = []
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
let custom = false
|
|
57
|
+
const add = (s) => {
|
|
58
|
+
if (seen.has(s.value)) return
|
|
59
|
+
seen.add(s.value)
|
|
60
|
+
swatches.push(s)
|
|
61
|
+
}
|
|
62
|
+
for (const token of tokens) {
|
|
63
|
+
if (token === 'custom') custom = true
|
|
64
|
+
else if (BY_ID.has(token)) add(toSwatch(BY_ID.get(token)))
|
|
65
|
+
else if (HUES.includes(token)) COLORS.filter((c) => c.hue === token).forEach((c) => add(toSwatch(c)))
|
|
66
|
+
else if (SHADES.includes(token)) COLORS.filter((c) => c.shade === token).forEach((c) => add(toSwatch(c)))
|
|
67
|
+
else if (isColorValue(token, doc)) add({ id: null, label: token, value: token })
|
|
68
|
+
else console.warn(`[hypercms] color option "${token}" is not a Tailwind color, hue, shade, CSS color or "custom"; ignoring it`)
|
|
69
|
+
}
|
|
70
|
+
return { swatches, custom, full: false }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function setupColorControl(node, { value, tokens, path, doc }) {
|
|
74
|
+
const { swatches, custom, full } = parseColorOptions(tokens, doc)
|
|
75
|
+
const current = value == null ? '' : String(value).trim()
|
|
76
|
+
if (current && !swatches.some((s) => s.value === current)) {
|
|
77
|
+
swatches.unshift({ id: null, label: colorLabel(current), value: current })
|
|
78
|
+
}
|
|
79
|
+
const inline = swatches.length <= INLINE_LIMIT
|
|
80
|
+
node.setAttribute('data-hcms-color-path', path)
|
|
81
|
+
node.classList.toggle('hcms-color--inline', inline)
|
|
82
|
+
const grid = node.querySelector('.hcms-color-grid')
|
|
83
|
+
if (grid) {
|
|
84
|
+
grid.classList.toggle('hcms-color-grid--full', full)
|
|
85
|
+
for (const s of swatches) {
|
|
86
|
+
const b = doc.createElement('button')
|
|
87
|
+
b.type = 'button'
|
|
88
|
+
b.className = 'hcms-color-swatch'
|
|
89
|
+
b.setAttribute('data-hcms-action', 'color-pick')
|
|
90
|
+
b.setAttribute('data-hcms-color', s.value)
|
|
91
|
+
b.setAttribute('aria-label', s.label)
|
|
92
|
+
b.setAttribute('title', s.id || s.label)
|
|
93
|
+
b.setAttribute('style', `--hcms-swatch: ${s.value}`)
|
|
94
|
+
grid.appendChild(b)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!custom) node.querySelector('.hcms-color-custom')?.remove()
|
|
98
|
+
const toggle = node.querySelector('.hcms-color-current')
|
|
99
|
+
if (inline && toggle) {
|
|
100
|
+
toggle.removeAttribute('data-hcms-action')
|
|
101
|
+
toggle.removeAttribute('aria-expanded')
|
|
102
|
+
}
|
|
103
|
+
setPanelOpen(node, inline || openPanels(doc).has(path))
|
|
104
|
+
paintColorControl(node, current)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reflect a value in the control's own chrome: chip, name, pressed swatch,
|
|
108
|
+
// custom inputs. The hidden field is the value; this is only its picture.
|
|
109
|
+
export function paintColorControl(node, value) {
|
|
110
|
+
const current = value == null ? '' : String(value).trim()
|
|
111
|
+
node.toggleAttribute('data-hcms-color-empty', current === '')
|
|
112
|
+
const chip = node.querySelector('.hcms-color-chip')
|
|
113
|
+
if (chip) {
|
|
114
|
+
if (current) chip.setAttribute('style', `--hcms-swatch: ${current}`)
|
|
115
|
+
else chip.removeAttribute('style')
|
|
116
|
+
}
|
|
117
|
+
const name = node.querySelector('.hcms-color-name')
|
|
118
|
+
if (name) name.textContent = current ? colorLabel(current) : 'Not set'
|
|
119
|
+
node.querySelectorAll('.hcms-color-swatch').forEach((b) => {
|
|
120
|
+
b.setAttribute('aria-pressed', String(b.getAttribute('data-hcms-color') === current))
|
|
121
|
+
})
|
|
122
|
+
const text = node.querySelector('[data-hcms-color-input="text"]')
|
|
123
|
+
if (text && text.ownerDocument?.activeElement !== text) text.value = current
|
|
124
|
+
const native = node.querySelector('[data-hcms-color-input="native"]')
|
|
125
|
+
if (native && /^#[0-9a-f]{6}$/i.test(current)) native.value = current.toLowerCase()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setPanelOpen(node, open) {
|
|
129
|
+
const panel = node.querySelector('.hcms-color-panel')
|
|
130
|
+
if (panel) panel.hidden = !open
|
|
131
|
+
const toggle = node.querySelector('.hcms-color-current[data-hcms-action]')
|
|
132
|
+
if (toggle) toggle.setAttribute('aria-expanded', String(open))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function controlOf(el) {
|
|
136
|
+
return el?.closest ? el.closest('[data-hcms-color-path]') : null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hiddenField(node) {
|
|
140
|
+
return node.querySelector('input[type="hidden"][data-hcms-field]')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function showColorError(node, message) {
|
|
144
|
+
const slot = node.querySelector(':scope > .hcms-error')
|
|
145
|
+
if (!slot) return
|
|
146
|
+
slot.textContent = message || ''
|
|
147
|
+
slot.hidden = !message
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Each of these returns the hidden field to commit, or null when nothing changed.
|
|
151
|
+
function setColor(node, value) {
|
|
152
|
+
const field = hiddenField(node)
|
|
153
|
+
if (!field) return null
|
|
154
|
+
showColorError(node, null)
|
|
155
|
+
paintColorControl(node, value)
|
|
156
|
+
if (field.value === value) return null
|
|
157
|
+
field.value = value
|
|
158
|
+
return field
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function pickColor(actionEl) {
|
|
162
|
+
const node = controlOf(actionEl)
|
|
163
|
+
if (!node) return null
|
|
164
|
+
if (!node.classList.contains('hcms-color--inline')) {
|
|
165
|
+
openPanels(node.ownerDocument).delete(node.getAttribute('data-hcms-color-path'))
|
|
166
|
+
setPanelOpen(node, false)
|
|
167
|
+
}
|
|
168
|
+
return setColor(node, actionEl.getAttribute('data-hcms-color') || '')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function clearColor(actionEl) {
|
|
172
|
+
const node = controlOf(actionEl)
|
|
173
|
+
return node ? setColor(node, '') : null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function toggleColorPanel(actionEl) {
|
|
177
|
+
const node = controlOf(actionEl)
|
|
178
|
+
if (!node) return
|
|
179
|
+
const path = node.getAttribute('data-hcms-color-path')
|
|
180
|
+
const panels = openPanels(node.ownerDocument)
|
|
181
|
+
const open = !panels.has(path)
|
|
182
|
+
if (open) panels.add(path)
|
|
183
|
+
else panels.delete(path)
|
|
184
|
+
setPanelOpen(node, open)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function customColor(inputEl) {
|
|
188
|
+
const node = controlOf(inputEl)
|
|
189
|
+
if (!node) return null
|
|
190
|
+
const value = String(inputEl.value || '').trim()
|
|
191
|
+
if (value !== '' && !isColorValue(value, node.ownerDocument)) {
|
|
192
|
+
showColorError(node, `"${value}" is not a CSS color`)
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
return setColor(node, value)
|
|
196
|
+
}
|
package/src/events.js
CHANGED
|
@@ -5,6 +5,10 @@ import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
|
|
|
5
5
|
import { scaffold } from './scaffold.js'
|
|
6
6
|
import { autosizeTextarea, enhanceFields } from './enhance.js'
|
|
7
7
|
import { platform } from './platform.js'
|
|
8
|
+
import { contentRoot } from './page-root.js'
|
|
9
|
+
import { pickColor, clearColor, toggleColorPanel, customColor } from './color-control.js'
|
|
10
|
+
import { pickFont, toggleFontPanel } from './font-control.js'
|
|
11
|
+
import { reconcileFontsLink } from './fonts-link.js'
|
|
8
12
|
|
|
9
13
|
const BOUND = new WeakSet()
|
|
10
14
|
|
|
@@ -57,6 +61,9 @@ export function bindEvents(ctx) {
|
|
|
57
61
|
const target = e.target
|
|
58
62
|
if (!target || !target.closest) return
|
|
59
63
|
if (!target.closest('[data-hcms-form-root]')) return
|
|
64
|
+
// The color control's custom inputs commit on change, not per keystroke or
|
|
65
|
+
// per drag step: a commit rebuilds the form under the cursor.
|
|
66
|
+
if (target.matches('[data-hcms-color-input]')) return
|
|
60
67
|
if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
|
|
61
68
|
if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
|
|
62
69
|
// The upload picker fires `input` too, but its .value is the OS fake path
|
|
@@ -72,6 +79,11 @@ export function bindEvents(ctx) {
|
|
|
72
79
|
const target = e.target
|
|
73
80
|
if (!target || !target.closest) return
|
|
74
81
|
if (!target.closest('[data-hcms-form-root]')) return
|
|
82
|
+
if (target.matches('[data-hcms-color-input]')) {
|
|
83
|
+
const field = customColor(target)
|
|
84
|
+
if (field) onScalarChange(field, ctx)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
75
87
|
if (target.matches('input[type="file"][data-hcms-upload]')) {
|
|
76
88
|
onUploadChange(target, ctx)
|
|
77
89
|
return
|
|
@@ -89,7 +101,7 @@ export function bindEvents(ctx) {
|
|
|
89
101
|
const action = actionEl.getAttribute('data-hcms-action')
|
|
90
102
|
// add/remove/move must live inside the form root; close inside the shell.
|
|
91
103
|
// Ignore stray data-hcms-action attributes elsewhere on the page.
|
|
92
|
-
if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload') {
|
|
104
|
+
if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload' || action === 'color-pick' || action === 'color-clear' || action === 'color-panel' || action === 'font-pick' || action === 'font-panel') {
|
|
93
105
|
if (!actionEl.closest('[data-hcms-form-root]')) return
|
|
94
106
|
} else if (action === 'close') {
|
|
95
107
|
if (!actionEl.closest('[data-hcms-shell]')) return
|
|
@@ -109,6 +121,16 @@ export function bindEvents(ctx) {
|
|
|
109
121
|
onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
|
|
110
122
|
} else if (action === 'clear-upload') {
|
|
111
123
|
onClearUpload(actionEl, ctx)
|
|
124
|
+
} else if (action === 'color-pick' || action === 'color-clear') {
|
|
125
|
+
const field = action === 'color-pick' ? pickColor(actionEl) : clearColor(actionEl)
|
|
126
|
+
if (field) onScalarChange(field, ctx)
|
|
127
|
+
} else if (action === 'color-panel') {
|
|
128
|
+
toggleColorPanel(actionEl)
|
|
129
|
+
} else if (action === 'font-pick') {
|
|
130
|
+
const field = pickFont(actionEl)
|
|
131
|
+
if (field) onScalarChange(field, ctx)
|
|
132
|
+
} else if (action === 'font-panel') {
|
|
133
|
+
toggleFontPanel(actionEl)
|
|
112
134
|
} else if (action === 'close') {
|
|
113
135
|
ctx.onCloseRequested?.()
|
|
114
136
|
}
|
|
@@ -154,7 +176,7 @@ function resolveUnobservedProjection(ctx, pathStr) {
|
|
|
154
176
|
const prop = rule.slice(at + 1)
|
|
155
177
|
if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
|
|
156
178
|
const selector = rule.slice(0, at)
|
|
157
|
-
const el = selector ? ctx.pageRoot.querySelector(selector) : ctx.pageRoot
|
|
179
|
+
const el = selector ? ctx.pageRoot.querySelector(selector) : contentRoot(ctx.pageRoot)
|
|
158
180
|
if (!el) return null
|
|
159
181
|
return { el, prop, oldValue: el[prop] }
|
|
160
182
|
}
|
|
@@ -631,6 +653,7 @@ export function commit(newData, info, ctx) {
|
|
|
631
653
|
if (result.ok) {
|
|
632
654
|
ctx.lastFingerprint = fingerprint
|
|
633
655
|
ctx.lastData = newData
|
|
656
|
+
reconcileFontsLink(ctx, newData)
|
|
634
657
|
setError(ctx, null)
|
|
635
658
|
ctx.dispatch?.('hcms:change', { data: newData, path: info.path, structural: !!info.structural })
|
|
636
659
|
ctx.onChange?.(newData, info)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { FONTS, FONT_GROUPS, SYSTEM_FONT, fontStack, fontFromStack, fontsHref } from './catalogs/fonts.js'
|
|
2
|
+
|
|
3
|
+
// The @font component: a menu of catalog families, each shown in its own face,
|
|
4
|
+
// writing one hidden field that the form commits like any other scalar.
|
|
5
|
+
|
|
6
|
+
const GROUP_LABELS = { sans: 'Sans', serif: 'Serif', display: 'Display', mono: 'Mono' }
|
|
7
|
+
const PREVIEW = 'link[data-hcms-font-preview]'
|
|
8
|
+
|
|
9
|
+
const OPEN = new WeakMap()
|
|
10
|
+
|
|
11
|
+
function openPanels(doc) {
|
|
12
|
+
let set = OPEN.get(doc)
|
|
13
|
+
if (!set) OPEN.set(doc, (set = new Set()))
|
|
14
|
+
return set
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isSystemStack(stack) {
|
|
18
|
+
return String(stack ?? '').trim() === SYSTEM_FONT.fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fontLabel(stack) {
|
|
22
|
+
const text = String(stack ?? '').trim()
|
|
23
|
+
if (!text) return 'Not set'
|
|
24
|
+
if (isSystemStack(text)) return 'System'
|
|
25
|
+
const font = fontFromStack(text)
|
|
26
|
+
return font ? font.family : 'Current font'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseFontOptions(tokens) {
|
|
30
|
+
if (!tokens || tokens.length === 0) return { system: true, fonts: [...FONTS] }
|
|
31
|
+
let system = false
|
|
32
|
+
const picked = new Set()
|
|
33
|
+
for (const token of tokens) {
|
|
34
|
+
if (token === 'system') system = true
|
|
35
|
+
else if (FONT_GROUPS.includes(token)) FONTS.filter((f) => f.group === token).forEach((f) => picked.add(f))
|
|
36
|
+
else if (FONTS.some((f) => f.slug === token)) picked.add(FONTS.find((f) => f.slug === token))
|
|
37
|
+
else console.warn(`[hypercms] font option "${token}" is not a catalog font, a group or "system"; ignoring it`)
|
|
38
|
+
}
|
|
39
|
+
return { system, fonts: FONTS.filter((f) => picked.has(f)) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function option(doc, { value, label, family }) {
|
|
43
|
+
const b = doc.createElement('button')
|
|
44
|
+
b.type = 'button'
|
|
45
|
+
b.className = 'hcms-font-option'
|
|
46
|
+
b.setAttribute('role', 'option')
|
|
47
|
+
b.setAttribute('data-hcms-action', 'font-pick')
|
|
48
|
+
b.setAttribute('data-hcms-font', value)
|
|
49
|
+
b.setAttribute('style', `font-family: ${family}`)
|
|
50
|
+
b.textContent = label
|
|
51
|
+
return b
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setupFontControl(node, { value, tokens, path, doc }) {
|
|
55
|
+
const { system, fonts } = parseFontOptions(tokens)
|
|
56
|
+
const current = value == null ? '' : String(value).trim()
|
|
57
|
+
node.setAttribute('data-hcms-font-path', path)
|
|
58
|
+
const list = node.querySelector('.hcms-font-list')
|
|
59
|
+
if (list) {
|
|
60
|
+
const offered = new Set([...(system ? [SYSTEM_FONT.fallback] : []), ...fonts.map(fontStack)])
|
|
61
|
+
if (current && !offered.has(current)) {
|
|
62
|
+
list.appendChild(option(doc, { value: current, label: fontLabel(current), family: current }))
|
|
63
|
+
}
|
|
64
|
+
if (system) list.appendChild(option(doc, { value: SYSTEM_FONT.fallback, label: 'System', family: SYSTEM_FONT.fallback }))
|
|
65
|
+
for (const group of FONT_GROUPS) {
|
|
66
|
+
const members = fonts.filter((f) => f.group === group)
|
|
67
|
+
if (!members.length) continue
|
|
68
|
+
const heading = doc.createElement('div')
|
|
69
|
+
heading.className = 'hcms-font-group'
|
|
70
|
+
heading.textContent = GROUP_LABELS[group]
|
|
71
|
+
list.appendChild(heading)
|
|
72
|
+
for (const f of members) list.appendChild(option(doc, { value: fontStack(f), label: f.family, family: fontStack(f) }))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
setPanelOpen(node, openPanels(doc).has(path))
|
|
76
|
+
paintFontControl(node, current)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function paintFontControl(node, value) {
|
|
80
|
+
const current = value == null ? '' : String(value).trim()
|
|
81
|
+
const name = node.querySelector('.hcms-font-name')
|
|
82
|
+
if (name) {
|
|
83
|
+
name.textContent = fontLabel(current)
|
|
84
|
+
if (current) name.setAttribute('style', `font-family: ${current}`)
|
|
85
|
+
else name.removeAttribute('style')
|
|
86
|
+
}
|
|
87
|
+
node.querySelectorAll('.hcms-font-option').forEach((b) => {
|
|
88
|
+
b.setAttribute('aria-selected', String(b.getAttribute('data-hcms-font') === current))
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setPanelOpen(node, open) {
|
|
93
|
+
const panel = node.querySelector('.hcms-font-panel')
|
|
94
|
+
if (panel) panel.hidden = !open
|
|
95
|
+
node.querySelector('.hcms-font-current')?.setAttribute('aria-expanded', String(open))
|
|
96
|
+
if (open) ensurePreviewLink(node.ownerDocument)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One editor-only stylesheet for every family name in its own face. It asks
|
|
100
|
+
// Google for just the letters of the names, so it stays a few kilobytes.
|
|
101
|
+
function ensurePreviewLink(doc) {
|
|
102
|
+
const head = doc?.head
|
|
103
|
+
if (!head || head.querySelector(PREVIEW)) return
|
|
104
|
+
const href = fontsHref(FONTS.map(fontStack))
|
|
105
|
+
const letters = [...new Set(FONTS.map((f) => f.family).join(''))].join('')
|
|
106
|
+
const link = doc.createElement('link')
|
|
107
|
+
link.rel = 'stylesheet'
|
|
108
|
+
link.href = `${href}&text=${encodeURIComponent(letters)}`
|
|
109
|
+
link.setAttribute('data-hcms-font-preview', '')
|
|
110
|
+
for (const attr of ['editor-ui', 'no-save', 'save-remove', 'snapshot-remove']) link.setAttribute(attr, '')
|
|
111
|
+
head.appendChild(link)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function controlOf(el) {
|
|
115
|
+
return el?.closest ? el.closest('[data-hcms-font-path]') : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function toggleFontPanel(actionEl) {
|
|
119
|
+
const node = controlOf(actionEl)
|
|
120
|
+
if (!node) return
|
|
121
|
+
const path = node.getAttribute('data-hcms-font-path')
|
|
122
|
+
const panels = openPanels(node.ownerDocument)
|
|
123
|
+
const open = !panels.has(path)
|
|
124
|
+
if (open) panels.add(path)
|
|
125
|
+
else panels.delete(path)
|
|
126
|
+
setPanelOpen(node, open)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Returns the hidden field to commit, or null when nothing changed.
|
|
130
|
+
export function pickFont(actionEl) {
|
|
131
|
+
const node = controlOf(actionEl)
|
|
132
|
+
if (!node) return null
|
|
133
|
+
const field = node.querySelector('input[type="hidden"][data-hcms-field]')
|
|
134
|
+
if (!field) return null
|
|
135
|
+
openPanels(node.ownerDocument).delete(node.getAttribute('data-hcms-font-path'))
|
|
136
|
+
setPanelOpen(node, false)
|
|
137
|
+
const value = actionEl.getAttribute('data-hcms-font') || ''
|
|
138
|
+
paintFontControl(node, value)
|
|
139
|
+
if (field.value === value) return null
|
|
140
|
+
field.value = value
|
|
141
|
+
return field
|
|
142
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { componentForScalarRule } from './templates.js'
|
|
2
|
+
import { fontsHref, sameFamilies } from './catalogs/fonts.js'
|
|
3
|
+
|
|
4
|
+
// The page's one Google Fonts stylesheet is derived from its font fields: the
|
|
5
|
+
// families they hold, nothing else. Recomputed after commits and refreshes, so
|
|
6
|
+
// undo, live sync and outside edits all end in the right link without each
|
|
7
|
+
// path having to know about fonts.
|
|
8
|
+
|
|
9
|
+
const LINK = 'link[data-hcms-fonts]'
|
|
10
|
+
const PRECONNECT = 'link[data-hcms-fonts-preconnect]'
|
|
11
|
+
|
|
12
|
+
// Every font field's value, walking the rules and the extracted data together.
|
|
13
|
+
// `found` says whether these rules have any font field at all.
|
|
14
|
+
export function fontStacksIn(pageRules, data, doc) {
|
|
15
|
+
const stacks = []
|
|
16
|
+
let found = false
|
|
17
|
+
walk(pageRules, data, [])
|
|
18
|
+
return { stacks, found }
|
|
19
|
+
|
|
20
|
+
function walk(rule, value, pathArr) {
|
|
21
|
+
if (typeof rule === 'string') {
|
|
22
|
+
if (rule.endsWith('[]')) return
|
|
23
|
+
if (componentForScalarRule(rule, doc, pathArr, pageRules) !== '@font') return
|
|
24
|
+
found = true
|
|
25
|
+
if (value != null && value !== '') stacks.push(String(value))
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(rule)) {
|
|
29
|
+
const items = Array.isArray(value) ? value : []
|
|
30
|
+
items.forEach((item) => walk(rule[1], item, [...pathArr, '*']))
|
|
31
|
+
if (!items.length) walk(rule[1], undefined, [...pathArr, '*'])
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (rule && typeof rule === 'object') {
|
|
35
|
+
for (const [key, child] of Object.entries(rule)) walk(child, value?.[key], [...pathArr, key])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function syncFontsLink(doc, href) {
|
|
41
|
+
const head = doc?.head
|
|
42
|
+
if (!head) return
|
|
43
|
+
let link = head.querySelector(LINK)
|
|
44
|
+
let pre = head.querySelector(PRECONNECT)
|
|
45
|
+
if (!href) {
|
|
46
|
+
link?.remove()
|
|
47
|
+
pre?.remove()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (!pre) {
|
|
51
|
+
pre = doc.createElement('link')
|
|
52
|
+
pre.setAttribute('rel', 'preconnect')
|
|
53
|
+
pre.setAttribute('href', 'https://fonts.gstatic.com')
|
|
54
|
+
pre.setAttribute('crossorigin', '')
|
|
55
|
+
pre.setAttribute('data-hcms-fonts-preconnect', '')
|
|
56
|
+
pre.setAttribute('no-undo', '')
|
|
57
|
+
head.appendChild(pre)
|
|
58
|
+
}
|
|
59
|
+
if (!link) {
|
|
60
|
+
link = doc.createElement('link')
|
|
61
|
+
link.setAttribute('rel', 'stylesheet')
|
|
62
|
+
link.setAttribute('data-hcms-fonts', '')
|
|
63
|
+
link.setAttribute('no-undo', '')
|
|
64
|
+
pre.after(link)
|
|
65
|
+
}
|
|
66
|
+
// The CMS derives this link from the font fields, so its changes are never
|
|
67
|
+
// undo steps of their own, including on a link the author wrote by hand.
|
|
68
|
+
if (!link.hasAttribute('no-undo')) link.setAttribute('no-undo', '')
|
|
69
|
+
if (!pre.hasAttribute('no-undo')) pre.setAttribute('no-undo', '')
|
|
70
|
+
if (link.getAttribute('href') !== href) link.setAttribute('href', href)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function reconcileFontsLink(ctx, data) {
|
|
74
|
+
if (!ctx?.doc || ctx.pageRules == null) return
|
|
75
|
+
const { stacks, found } = fontStacksIn(ctx.pageRules, data, ctx.doc)
|
|
76
|
+
if (!found) return
|
|
77
|
+
const current = ctx.doc.head?.querySelector(LINK)?.getAttribute('href') || null
|
|
78
|
+
const href = fontsHref(stacks, current)
|
|
79
|
+
// A link that already loads exactly these families stays as it is, so
|
|
80
|
+
// opening the editor or an undo never rewrites the author's own URL.
|
|
81
|
+
if (href && current && sameFamilies(current, href)) return
|
|
82
|
+
syncFontsLink(ctx.doc, href)
|
|
83
|
+
}
|
package/src/form-builder.js
CHANGED
|
@@ -10,8 +10,12 @@ import {
|
|
|
10
10
|
declaredComponentKey,
|
|
11
11
|
readOptionsOverride,
|
|
12
12
|
readCropOverride,
|
|
13
|
+
styleRole,
|
|
13
14
|
} from './templates.js'
|
|
14
|
-
import { fieldPropertyFor } from './form-rules.js'
|
|
15
|
+
import { fieldPropertyFor, shapeOf } from './form-rules.js'
|
|
16
|
+
import { setupColorControl } from './color-control.js'
|
|
17
|
+
import { setupFontControl } from './font-control.js'
|
|
18
|
+
import { populateVariant } from './variant-control.js'
|
|
15
19
|
|
|
16
20
|
// The visible label for a file field is the basename of its URL value; the
|
|
17
21
|
// bound leaf still carries the full URL in href so it round-trips byte-stable.
|
|
@@ -28,11 +32,59 @@ export function fileNameFromUrl(url) {
|
|
|
28
32
|
|
|
29
33
|
export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
|
|
30
34
|
const fragment = doc.createDocumentFragment()
|
|
35
|
+
const pageStyle = buildPageStyle(pageRules, data, doc)
|
|
36
|
+
if (pageStyle) fragment.appendChild(pageStyle)
|
|
31
37
|
const root = buildNode(pageRules, [], data, doc, pageRules)
|
|
32
38
|
if (root) fragment.appendChild(root)
|
|
33
39
|
return fragment
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// Page style fields, gathered from wherever their rules sit (outside lists)
|
|
43
|
+
// into one group that renders first. Each field keeps its own data-hcms-path.
|
|
44
|
+
function buildPageStyle(pageRules, data, doc) {
|
|
45
|
+
const fields = []
|
|
46
|
+
collect(pageRules, data, [])
|
|
47
|
+
if (!fields.length) return null
|
|
48
|
+
const tpl = findTemplate(doc, '@object')
|
|
49
|
+
if (!tpl) return null
|
|
50
|
+
const node = cloneTemplate(tpl, doc)
|
|
51
|
+
node.setAttribute('data-hcms-page-style', '')
|
|
52
|
+
const label = node.querySelector('[data-hcms-label]')
|
|
53
|
+
if (label) label.textContent = 'Page style'
|
|
54
|
+
const slot = node.querySelector('.hcms-object-fields') || node
|
|
55
|
+
for (const [rule, pathArr, value] of fields) {
|
|
56
|
+
const field = buildNode(rule, pathArr, value, doc, pageRules)
|
|
57
|
+
if (field) slot.appendChild(field)
|
|
58
|
+
}
|
|
59
|
+
return node
|
|
60
|
+
|
|
61
|
+
function collect(rule, value, pathArr) {
|
|
62
|
+
if (typeof rule === 'string') {
|
|
63
|
+
if (pathArr.length && styleRole(rule, doc, pathArr, pageRules) === 'page') fields.push([rule, pathArr, value])
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return
|
|
67
|
+
if (pathArr.length) {
|
|
68
|
+
const tpl = resolveTemplate(pathArr, '@object', doc)
|
|
69
|
+
if (tpl && isInlineTemplate(tpl)) return
|
|
70
|
+
}
|
|
71
|
+
for (const [k, child] of Object.entries(rule)) collect(child, value == null ? null : value[k], [...pathArr, k])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Style fields first, each group keeping its own order; page style fields are
|
|
76
|
+
// left out here because buildPageStyle renders them.
|
|
77
|
+
function orderedEntries(shape, pathArr, doc, pageRules) {
|
|
78
|
+
const style = []
|
|
79
|
+
const content = []
|
|
80
|
+
for (const [k, child] of Object.entries(shape)) {
|
|
81
|
+
const role = styleRole(child, doc, [...pathArr, k], pageRules)
|
|
82
|
+
if (role === 'page') continue
|
|
83
|
+
;(role ? style : content).push([k, child])
|
|
84
|
+
}
|
|
85
|
+
return [...style, ...content]
|
|
86
|
+
}
|
|
87
|
+
|
|
36
88
|
// pageRules rides alongside doc so a scalar bound to its own context element
|
|
37
89
|
// ("." / "@attr") can resolve the row that carries its named control; doc alone
|
|
38
90
|
// cannot answer that, because the row is only knowable from the enclosing array
|
|
@@ -88,6 +140,17 @@ function buildScalar(rule, pathArr, data, doc, pageRules) {
|
|
|
88
140
|
if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
|
|
89
141
|
populateOptions(node, rule, pathArr, data, doc, componentKey, pageRules)
|
|
90
142
|
}
|
|
143
|
+
if (componentKey === '@variant' && winnerKey === '@variant') {
|
|
144
|
+
const tokens = readOptionsOverride(rule, doc, pathArr, pageRules)
|
|
145
|
+
if (!tokens) setFieldError(node, 'data-hcms-options required (value:icon tokens)')
|
|
146
|
+
const count = populateVariant(node, {
|
|
147
|
+
tokens,
|
|
148
|
+
value: data,
|
|
149
|
+
name: radioGroupName(pathArr.join('.')),
|
|
150
|
+
doc,
|
|
151
|
+
})
|
|
152
|
+
if (count === 0) node.querySelector('.hcms-variant-option')?.remove()
|
|
153
|
+
}
|
|
91
154
|
if (componentKey === '@image' && winnerKey === '@image') {
|
|
92
155
|
const crop = readCropOverride(rule, doc, pathArr, pageRules)
|
|
93
156
|
if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
|
|
@@ -100,6 +163,22 @@ function buildScalar(rule, pathArr, data, doc, pageRules) {
|
|
|
100
163
|
stampContainerField(node, lastKey(pathArr))
|
|
101
164
|
setLabel(node, lastKey(pathArr))
|
|
102
165
|
populateScalarValue(node, data)
|
|
166
|
+
if (componentKey === '@color' && winnerKey === '@color') {
|
|
167
|
+
setupColorControl(node, {
|
|
168
|
+
value: data,
|
|
169
|
+
tokens: readOptionsOverride(rule, doc, pathArr, pageRules),
|
|
170
|
+
path: pathArr.join('.'),
|
|
171
|
+
doc,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
if (componentKey === '@font' && winnerKey === '@font') {
|
|
175
|
+
setupFontControl(node, {
|
|
176
|
+
value: data,
|
|
177
|
+
tokens: readOptionsOverride(rule, doc, pathArr, pageRules),
|
|
178
|
+
path: pathArr.join('.'),
|
|
179
|
+
doc,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
103
182
|
if (componentKey === '@file') syncFileLeafText(node)
|
|
104
183
|
return node
|
|
105
184
|
}
|
|
@@ -212,11 +291,12 @@ function buildObject(rule, pathArr, data, doc, pageRules) {
|
|
|
212
291
|
}
|
|
213
292
|
|
|
214
293
|
const slot = requireSlot(node, '.hcms-object-fields', tpl, pathArr)
|
|
215
|
-
for (const [k, child] of
|
|
294
|
+
for (const [k, child] of orderedEntries(rule, pathArr, doc, pageRules)) {
|
|
216
295
|
const childData = data == null ? null : data[k]
|
|
217
296
|
const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
|
|
218
297
|
if (childNode) slot.appendChild(childNode)
|
|
219
298
|
}
|
|
299
|
+
if (pathArr.length && !slot.children.length && Object.keys(rule).length) return null
|
|
220
300
|
return node
|
|
221
301
|
}
|
|
222
302
|
|
|
@@ -259,7 +339,7 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc, pageRules) {
|
|
|
259
339
|
|
|
260
340
|
const slot = requireSlot(node, '.hcms-card-fields', tpl, pathArr)
|
|
261
341
|
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
262
|
-
for (const [k, child] of
|
|
342
|
+
for (const [k, child] of orderedEntries(itemShape, pathArr, doc, pageRules)) {
|
|
263
343
|
const childData = data == null ? null : data[k]
|
|
264
344
|
const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
|
|
265
345
|
if (childNode) slot.appendChild(childNode)
|
|
@@ -332,6 +412,7 @@ function cloneTemplate(tpl, doc) {
|
|
|
332
412
|
|
|
333
413
|
function stampPath(node, pathArr) {
|
|
334
414
|
node.setAttribute('data-hcms-path', pathArr.join('.'))
|
|
415
|
+
node.setAttribute('data-hcms-path-shape', shapeOf(pathArr))
|
|
335
416
|
}
|
|
336
417
|
|
|
337
418
|
function stampScalarField(node, key) {
|
|
@@ -481,6 +562,7 @@ function stampInlineFieldPaths(node, shape, parentPath) {
|
|
|
481
562
|
if (shape && typeof shape === 'object' && !(key in shape)) return
|
|
482
563
|
const childPath = [...parentPath, key].join('.')
|
|
483
564
|
el.setAttribute('data-hcms-path', childPath)
|
|
565
|
+
el.setAttribute('data-hcms-path-shape', shapeOf([...parentPath, key]))
|
|
484
566
|
})
|
|
485
567
|
}
|
|
486
568
|
|