@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.
@@ -0,0 +1,110 @@
1
+ // GENERATED from clayjs/src/lib/content-dom.js. Edit ClayJS and run `npm run propagate:foundation`.
2
+
3
+ import { capabilitySelector, hasCapability } from './region-capabilities.js'
4
+
5
+ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
6
+
7
+ function copyControlState(source, copy) {
8
+ if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
9
+ const type = (source.getAttribute?.('type') || '').toLowerCase()
10
+ if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
11
+ if ('checked' in source && 'checked' in copy) copy.checked = source.checked
12
+ if ('selected' in source && 'selected' in copy) copy.selected = source.selected
13
+ if (source.tagName === 'SELECT') {
14
+ for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
15
+ }
16
+ if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
17
+ }
18
+
19
+ function matchesWithin(node, selector, boundary, inherit) {
20
+ if (inherit) return !!node.closest?.(selector)
21
+ let current = node
22
+ while (current?.nodeType === 1) {
23
+ if (current.matches(selector)) return true
24
+ if (current === boundary) break
25
+ current = current.parentElement
26
+ }
27
+ return false
28
+ }
29
+
30
+ function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
31
+ if (source.nodeType === 1 && (
32
+ (inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
+ (exclude && matchesWithin(source, exclude, boundary, inherit))
34
+ )) return null
35
+ const copy = targetDocument.importNode(source, false)
36
+ maps.cloneToLive.set(copy, source)
37
+ maps.liveToClone.set(source, copy)
38
+ const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
39
+ const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
40
+ if (sourceChildren !== source) {
41
+ maps.cloneToLive.set(copyChildren, sourceChildren)
42
+ maps.liveToClone.set(sourceChildren, copyChildren)
43
+ }
44
+ for (const child of sourceChildren.childNodes || []) {
45
+ const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
46
+ if (childCopy) {
47
+ copyChildren.appendChild(childCopy)
48
+ if (child.nodeType === 1) copyControlState(child, childCopy)
49
+ }
50
+ }
51
+ if (source.nodeType === 1) copyControlState(source, copy)
52
+ return copy
53
+ }
54
+
55
+ export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
56
+ if (!context) throw new TypeError('createContentView requires a DOM context')
57
+ const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
58
+ if (!sourceDocument?.implementation?.createHTMLDocument) {
59
+ throw new TypeError('createContentView requires an HTML DOM implementation')
60
+ }
61
+ const inertDocument = sourceDocument.implementation.createHTMLDocument('')
62
+ const cloneToLive = new WeakMap()
63
+ const liveToClone = new WeakMap()
64
+ const sourceRoot = context.nodeType === 9 ? context.documentElement : context
65
+ if (exclude) inertDocument.documentElement.matches(exclude)
66
+ const capabilityMatch = capabilitySelector(capability)
67
+ const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
68
+ if (context.nodeType === 9 && root) {
69
+ inertDocument.replaceChild(root, inertDocument.documentElement)
70
+ liveToClone.set(context, inertDocument)
71
+ cloneToLive.set(inertDocument, context)
72
+ }
73
+
74
+ const assertSelector = selector => {
75
+ if (UNSUPPORTED_SELECTOR.test(selector)) {
76
+ throw new Error(`Filtered content queries do not support stateful selector: ${selector}`)
77
+ }
78
+ }
79
+
80
+ return {
81
+ root,
82
+ document: inertDocument,
83
+ capability,
84
+ selector: capabilitySelector(capability),
85
+ cloneToLive,
86
+ liveToClone,
87
+ original(node) { return cloneToLive.get(node) || null },
88
+ cloneOf(node) { return liveToClone.get(node) || null },
89
+ query(selector, queryRoot = root) {
90
+ assertSelector(selector)
91
+ return Array.from(queryRoot.querySelectorAll(selector), node => cloneToLive.get(node) || node)
92
+ },
93
+ text(node = root) {
94
+ const projected = node === root ? root : liveToClone.get(node)
95
+ return projected?.textContent || ''
96
+ },
97
+ html(node = root) {
98
+ const projected = node === root ? root : liveToClone.get(node)
99
+ return projected?.innerHTML ?? ''
100
+ },
101
+ clone(node = root) {
102
+ const projected = node === root ? root : liveToClone.get(node)
103
+ return projected ? inertDocument.importNode(projected, true) : null
104
+ },
105
+ }
106
+ }
107
+
108
+ export function cleanContentClone(node, options) {
109
+ return createContentView(node, options).root
110
+ }
@@ -0,0 +1,71 @@
1
+ // GENERATED from clayjs/src/lib/region-capabilities.js. Edit ClayJS and run `npm run propagate:foundation`.
2
+
3
+ export const CAPABILITIES = Object.freeze({
4
+ data: Object.freeze({ tokens: ['no-data'], bundles: ['editor-ui'] }),
5
+ save: Object.freeze({ tokens: ['no-save'], bundles: ['editor-ui'] }),
6
+ snapshot: Object.freeze({ tokens: ['no-snapshot'], bundles: ['editor-ui'] }),
7
+ watch: Object.freeze({ tokens: ['no-watch'], bundles: ['editor-ui'] }),
8
+ undo: Object.freeze({ tokens: ['no-undo'], bundles: ['editor-ui'] }),
9
+ history: Object.freeze({ tokens: [], bundles: ['editor-ui'] }),
10
+ })
11
+
12
+ export const BUNDLES = Object.freeze({
13
+ 'editor-ui': Object.freeze(['no-data', 'no-save', 'no-snapshot', 'no-watch', 'no-undo']),
14
+ })
15
+
16
+ export const POLICY_TOKENS = Object.freeze([
17
+ 'no-save',
18
+ 'no-snapshot',
19
+ 'no-trigger-autosave',
20
+ 'no-dirty',
21
+ 'no-watch',
22
+ 'no-undo',
23
+ 'no-data',
24
+ 'freeze',
25
+ 'editor-ui',
26
+ ])
27
+
28
+ export function hasPolicyToken(element, token) {
29
+ if (!element || element.nodeType !== 1) return false
30
+ const clay = element.getAttribute?.('clay')
31
+ return !!(
32
+ (clay && clay.split(/\s+/).includes(token)) ||
33
+ element.hasAttribute?.(token)
34
+ )
35
+ }
36
+
37
+ export function expandsTo(element, token) {
38
+ if (hasPolicyToken(element, token)) return true
39
+ for (const [bundle, members] of Object.entries(BUNDLES)) {
40
+ if (members.includes(token) && hasPolicyToken(element, bundle)) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ export function capabilitySelector(capability) {
46
+ const definition = CAPABILITIES[capability]
47
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
48
+ return [...definition.tokens, ...definition.bundles]
49
+ .flatMap(token => [`[clay~="${token}"]`, `[${token}]`])
50
+ .join(', ')
51
+ }
52
+
53
+ export function closestWithCapability(node, capability) {
54
+ let element = node && node.nodeType === 1 ? node : node?.parentElement
55
+ while (element && element.nodeType === 1) {
56
+ const definition = CAPABILITIES[capability]
57
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
58
+ if (definition.tokens.some(token => expandsTo(element, token)) ||
59
+ definition.bundles.some(bundle => hasPolicyToken(element, bundle))) return element
60
+ element = element.parentElement
61
+ }
62
+ return null
63
+ }
64
+
65
+ export function hasCapability(node, capability) {
66
+ return !!closestWithCapability(node, capability)
67
+ }
68
+
69
+ export function isPolicyAttribute(name) {
70
+ return name === 'clay' || POLICY_TOKENS.includes(name)
71
+ }
package/src/morph.js CHANGED
@@ -6,5 +6,6 @@ export function morphForm(formRoot, newFragment, { ignoreActiveValue = true } =
6
6
  ignoreActiveValue,
7
7
  restoreFocus: true,
8
8
  formStateSync: 'property',
9
+ policy: 'raw',
9
10
  })
10
11
  }
package/src/place.js ADDED
@@ -0,0 +1,131 @@
1
+ // Where to put a floating thing next to a page element.
2
+ //
3
+ // COPIED, deliberately, from richclay/src/toolbar-float.js:1-59. Not imported:
4
+ // hypercms must not gain a hard richclay dependency to lay out a popover on a
5
+ // page that contains no rich text at all. The ladder is the same one richclay's
6
+ // floating toolbar has been using in production, and the copy is verbatim apart
7
+ // from the two notes below, so a fix on either side can be read across.
8
+ //
9
+ // Two differences from the original, both because a popover is not a toolbar:
10
+ //
11
+ // 1. A popover has ONE size. richclay measures a horizontal bar and a vertical
12
+ // rail because its toolbar reflows between them; ours does not. Callers pass
13
+ // the same box as both `bar` and `rail`, which collapses the side-rail rung
14
+ // of the ladder into a plain "put it beside the anchor" without changing a
15
+ // line of the math.
16
+ //
17
+ // 2. The `hidden` rung is a backstop here, not the visibility test. It compares
18
+ // the anchor to the viewport, which cannot see an anchor clipped by an
19
+ // overflow: hidden ancestor while still inside the viewport — the case that
20
+ // floats a control over whatever is visible in its place. The inline view
21
+ // decides visibility from an IntersectionObserver, which accounts for
22
+ // ancestor clipping by construction (measured, plan §3.1.2), and only asks
23
+ // for a placement once it knows the anchor is showing.
24
+
25
+ export const GAP = 16
26
+ export const GAP_LADDER = [16, 8, 0]
27
+ export const VIEWPORT_INSET = 8
28
+ export const PLACEMENT_SLACK = 4
29
+
30
+ // Pure placement ladder: above -> below -> beside -> pinned to viewport top.
31
+ // All coords are viewport-relative (the host is position: fixed). `current` gets
32
+ // PLACEMENT_SLACK of hysteresis so the mode doesn't flip-flop at exact
33
+ // boundaries while the page scrolls.
34
+ export function place({ anchor, bar, rail = bar, viewport, current = null }) {
35
+ const slack = (mode) => (current === mode ? PLACEMENT_SLACK : 0)
36
+
37
+ if (
38
+ anchor.bottom <= 0 ||
39
+ anchor.top >= viewport.height ||
40
+ anchor.right <= 0 ||
41
+ anchor.left >= viewport.width
42
+ ) {
43
+ return { mode: 'hidden', x: 0, y: 0 }
44
+ }
45
+
46
+ const clampX = (width) =>
47
+ Math.max(VIEWPORT_INSET, Math.min(anchor.left, viewport.width - width - VIEWPORT_INSET))
48
+
49
+ const aboveY = anchor.top - GAP - bar.height
50
+ if (aboveY >= VIEWPORT_INSET - slack('above')) {
51
+ return { mode: 'above', x: clampX(bar.width), y: aboveY }
52
+ }
53
+
54
+ const belowY = anchor.bottom + GAP
55
+ if (belowY + bar.height <= viewport.height - VIEWPORT_INSET + slack('below')) {
56
+ return { mode: 'below', x: clampX(bar.width), y: belowY }
57
+ }
58
+
59
+ const rightSpace = viewport.width - anchor.right - VIEWPORT_INSET
60
+ const leftSpace = anchor.left - VIEWPORT_INSET
61
+ const sides =
62
+ rightSpace >= leftSpace
63
+ ? [['rail-right', rightSpace], ['rail-left', leftSpace]]
64
+ : [['rail-left', leftSpace], ['rail-right', rightSpace]]
65
+
66
+ for (const [mode, space] of sides) {
67
+ for (const gap of GAP_LADDER) {
68
+ if (rail.width + gap > space + slack(mode)) continue
69
+ const x =
70
+ mode === 'rail-right'
71
+ ? Math.min(anchor.right + gap, viewport.width - rail.width - VIEWPORT_INSET)
72
+ : Math.max(anchor.left - gap - rail.width, VIEWPORT_INSET)
73
+ const maxY = Math.min(anchor.bottom, viewport.height - VIEWPORT_INSET) - rail.height
74
+ const y = Math.max(VIEWPORT_INSET, Math.min(Math.max(anchor.top, VIEWPORT_INSET), maxY))
75
+ return { mode, x, y, gap }
76
+ }
77
+ }
78
+
79
+ return { mode: 'pinned', x: clampX(bar.width), y: VIEWPORT_INSET }
80
+ }
81
+
82
+ // A handle sits ON its anchor's top-right corner, overlapping it, so it reads as
83
+ // belonging to that element rather than floating near it. No ladder: a handle is
84
+ // small enough that it always fits, and moving it around the anchor would make
85
+ // the page feel like it was shuffling under the pointer.
86
+ //
87
+ // The one thing it does need is the viewport clamp. A card flush against the
88
+ // right edge would otherwise put its handle half off-screen, and a heading at
89
+ // the very top of the document would put it above the fold.
90
+ //
91
+ // `prefer` picks which of the two placements below the caller wants:
92
+ // 'auto' adapts to the anchor's size, 'corner' always takes the corner.
93
+ export function placeHandle({ anchor, handle, viewport, inset = 6, prefer = 'auto' }) {
94
+ // Overlapping only reads as "belongs to this" when there is something left to
95
+ // belong to. On a 108x18 link a 36x31 handle covered 28% of it, including the
96
+ // last word — measured in a real browser. So a small anchor gets its handle
97
+ // set BESIDE it instead: still adjacent, nothing hidden.
98
+ //
99
+ // That rule is about a handle floating near a link. A control that BELONGS to
100
+ // a list row wants the opposite and asks for 'corner': measured in Chrome, a
101
+ // 31px strip beside a 36px card row landed wholly outside the row, and beside
102
+ // a run of 60px-pitch pills the strips covered each other. Such a caller pins
103
+ // the control to the corner and decides for itself whether the row is big
104
+ // enough to carry one at all. The clamp below is shared either way.
105
+ const roomy = anchor.height >= handle.height * 1.5 && anchor.width >= handle.width * 2
106
+ if (prefer === 'corner' || roomy) {
107
+ const x = Math.max(
108
+ VIEWPORT_INSET,
109
+ Math.min(anchor.right - handle.width + inset, viewport.width - handle.width - VIEWPORT_INSET)
110
+ )
111
+ const y = Math.max(
112
+ VIEWPORT_INSET,
113
+ Math.min(anchor.top - inset, viewport.height - handle.height - VIEWPORT_INSET)
114
+ )
115
+ return { x, y, mode: 'over' }
116
+ }
117
+
118
+ // Beside: to the right when it fits, otherwise to the left, vertically
119
+ // centred on the anchor. Both are clamped, so a handle never leaves the
120
+ // viewport even for an anchor flush against an edge.
121
+ const gap = 4
122
+ const fitsRight = anchor.right + gap + handle.width <= viewport.width - VIEWPORT_INSET
123
+ const rawX = fitsRight ? anchor.right + gap : anchor.left - gap - handle.width
124
+ // Only the left edge needs clamping. fitsRight is the test for "the right
125
+ // placement is inside the viewport", so the right branch is already in bounds
126
+ // by construction; the left branch can run off a narrow anchor's left side.
127
+ const x = Math.max(VIEWPORT_INSET, rawX)
128
+ const centred = anchor.top + anchor.height / 2 - handle.height / 2
129
+ const y = Math.max(VIEWPORT_INSET, Math.min(centred, viewport.height - handle.height - VIEWPORT_INSET))
130
+ return { x, y, mode: fitsRight ? 'beside-right' : 'beside-left' }
131
+ }
package/src/platform.js CHANGED
@@ -16,6 +16,11 @@ const CAPABILITIES = {
16
16
  Mutation: (clay, hyperclay) => clay?.Mutation ?? hyperclay?.Mutation,
17
17
  undo: (clay, hyperclay) => clay?.undo ?? hyperclay?.undo,
18
18
  onPrepareForSave: (clay, hyperclay) => clay?.addDocumentTransform ?? hyperclay?.onPrepareForSave,
19
+ // Distinct from onPrepareForSave above, and the difference is the whole point:
20
+ // onPrepareForSave is save-only, so a hook there runs AFTER live sync has
21
+ // already broadcast the document. onSnapshot runs on every snapshot, save and
22
+ // sync alike, which is the only place a cleanup can sit and be seen by both.
23
+ onSnapshot: (clay, hyperclay) => clay?.onSnapshot ?? hyperclay?.onSnapshot,
19
24
  consent: (clay, hyperclay) => clay?.confirm ?? hyperclay?.consent,
20
25
  RichClay: (clay, hyperclay) => clay?.RichClay ?? hyperclay?.RichClay,
21
26
  quickcrop: (clay, hyperclay) => clay?.quickcrop ?? hyperclay?.quickcrop,
@@ -92,6 +97,13 @@ export function platform(name, win) {
92
97
  export const MUTATION_READY = ['clay:mutation-ready', 'hyperclay:mutation-ready']
93
98
  export const LIVESYNC_APPLIED = ['clay:sync-applied', 'hyperclay:livesync-applied']
94
99
 
100
+ // The readiness pair both clients publish once their public surface is
101
+ // assembled. clayjs fires clay:ready from its loader; hyperclayjs fires
102
+ // hyperclay:ready from exportToWindow. This is the handshake richclay uses for
103
+ // the same race, and it is the only reliable retry signal for a hook whose
104
+ // capability arrives later than hypercms does.
105
+ export const PLATFORM_READY = ['clay:ready', 'hyperclay:ready']
106
+
95
107
  // Subscribe to every spelling of one signal, delivering the handler ONCE per
96
108
  // occurrence. No client dispatches the pair today, but one that did would make a
97
109
  // plain listener on each name fire the handler twice for one event: two form
package/src/refresh.js CHANGED
@@ -4,33 +4,47 @@ 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 { findUnresolved, applyUnresolvedState } from './unresolved.js'
8
- import { enhanceFields, upgradeRichTextRules } from './enhance.js'
7
+ import { findUnresolved, applyUnresolvedState, stripReadOnly } from './unresolved.js'
8
+ import { enhanceFields } from './enhance.js'
9
9
  import { platform } from './platform.js'
10
+ import { rowIdentitySeeder } from './row-identity.js'
10
11
 
11
12
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
12
13
 
13
14
  export function refreshForm(ctx, { ignoreActiveValue } = {}) {
15
+ return refreshPageForm(ctx, { ignoreActiveValue })
16
+ }
17
+
18
+ function refreshPageForm(ctx, { ignoreActiveValue }) {
14
19
  // Re-resolve rules every refresh, source-aware: an object source returns the
15
20
  // same literal (tagNode null); a token source re-resolves the tag so a
16
21
  // livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
17
22
  const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
18
23
  if (found) {
19
- // Re-apply the rich-text upgrade open() performed: the tag holds the
20
- // author's bare rules, and using them raw would rebind upgraded fields
21
- // back to textContent mid-session.
22
- ctx.pageRules = ctx.richText ? upgradeRichTextRules(found.rules, ctx.pageRoot) : found.rules
24
+ // Re-apply the rule upgrade open() performed: the tag holds the author's
25
+ // bare rules, and using them raw would rebind upgraded fields back to
26
+ // textContent mid-session. Route it through the SAME seam open() used
27
+ // (session.js hands found.rules to view.prepareRules) rather than
28
+ // hardcoding one view's upgrade here — that undid a wider upgrade on the
29
+ // first refresh, silently flattening rich text back to plain.
30
+ ctx.pageRules = ctx.view.prepareRules(found.rules)
23
31
  ctx.rulesTagNode = found.tagNode
24
32
  }
25
33
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
26
34
  injectDefaults(ctx.doc)
27
35
  injectComponents(ctx.doc, ctx.pageRules)
28
36
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
37
+ ctx.writeRules = stripReadOnly(ctx.pageRules)
29
38
  // Before the extract below, for the same readable-failure reason as open().
30
39
  ctx.unresolved = findUnresolved(ctx.pageRoot, ctx.pageRules)
31
40
 
41
+ // The morph below can rebuild a form row, which drops its binding to the page
42
+ // row it stood for. Read the pairing off this extract and re-establish it once
43
+ // the morph has settled, so the first operation after a refresh is not left
44
+ // guessing.
45
+ const seeder = rowIdentitySeeder()
32
46
  const newData = coerceBooleans(
33
- engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
47
+ engine.extract(ctx.pageRoot, ctx.pageRules, { ...ENGINE_OPTS, ...seeder.hooks }),
34
48
  ctx.pageRules
35
49
  )
36
50
  const fragment = buildForm({
@@ -40,9 +54,10 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
40
54
  doc: ctx.doc,
41
55
  })
42
56
  morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
57
+ seeder.seed(ctx.formRoot)
43
58
  // The morph can rebuild field nodes, dropping autosize heights and richclay
44
59
  // instances (instances are per-element); re-enhance whatever is new.
45
- enhanceFields(ctx.formRoot, ctx.doc)
60
+ enhanceFields(ctx.formRoot, ctx.doc, ctx.view?.enhanceFormRichText !== false)
46
61
  // morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
47
62
  // the last error state so error messages survive across observer-driven refreshes
48
63
  // (e.g., the rollback after a failed add).
@@ -57,14 +72,16 @@ export function installObserver({ debounce = 100, onRefresh }) {
57
72
  if (!M || typeof M.onAnyChange !== 'function') {
58
73
  throw new Error('hypercms: a mutation hub is required (clay.Mutation or hyperclay.Mutation). Load clayjs or hyperclayjs, or just the mutation utility, before initializing hypercms.')
59
74
  }
60
- let paused = 0
61
- const unsub = M.onAnyChange({ debounce }, () => {
62
- if (paused > 0) return
63
- onRefresh()
75
+ // The hub hands every subscriber the batch of changes it collected
76
+ // ({ type: 'add' | 'remove' | 'attribute', element, parent, attribute,
77
+ // oldValue, newValue }, see clayjs/src/lib/mutation.js). Discarding them was
78
+ // fine while the only view was the sidebar, which refreshes wholesale. A view
79
+ // that edits the page in place needs to know whether a batch is its own doing,
80
+ // so pass them on and let the view decide.
81
+ const unsub = M.onAnyChange({ debounce }, (changes) => {
82
+ onRefresh(changes)
64
83
  })
65
84
  return {
66
85
  unsubscribe: typeof unsub === 'function' ? unsub : () => {},
67
- pause() { paused++ },
68
- resume() { paused = Math.max(0, paused - 1) },
69
86
  }
70
87
  }
@@ -0,0 +1,140 @@
1
+ // hypercms's bridge to richclay: how it finds the class, and how it cleans up
2
+ // after the instances it creates.
3
+ //
4
+ // The cleanup exists because hypercms constructs `new RichClay(el, { hyperclay:
5
+ // false })`, and richclay installs its own save bridge only when that option is
6
+ // true (`richclay/src/richclay.js:135`). So on a page whose only richclay usage
7
+ // is hypercms's, NOTHING strips the editor's runtime state out of the document:
8
+ // a contenteditable heading reaches the file and stays editable forever.
9
+ // hypercms created those instances, so hypercms cleans them up.
10
+
11
+ import { platform } from './platform.js'
12
+
13
+ // Written on the live page element at bind time, removed from the snapshot
14
+ // clone. It is the only way to tell an element hypercms made rich from one the
15
+ // author marked up themselves: element identity does not survive cloneNode, so
16
+ // an in-memory Set cannot answer the question on the clone the hook receives.
17
+ //
18
+ // Its VALUE is whether the element held markup before richclay touched it:
19
+ // 'rich' or 'plain'. That has to be frozen here, because Squire wraps the
20
+ // content of a block it binds in a <div>, so from the first click the live DOM
21
+ // can no longer answer whether the author wrote markup in this element.
22
+ export const BOUND_ATTR = 'data-hcms-bound'
23
+
24
+ // The binding's own id, written on the live element and carried into the clone
25
+ // by cloneNode. It is how the save hook, holding a clone, finds the live binding
26
+ // that knows what the author's markup was before richclay rewrote it. Identity
27
+ // cannot cross cloneNode, and the markup is far too big to ride in an attribute.
28
+ export const BOUND_ID_ATTR = 'data-hcms-bound-id'
29
+
30
+ // id -> the live binding. Populated at bind, dropped at unbind, read only by the
31
+ // save hook. Holding the binding rather than a copy of its markup is deliberate:
32
+ // whether the restore should happen at all is a question about the binding's
33
+ // state right now, not about its state when it was created.
34
+ const boundById = new Map()
35
+
36
+ export function registerBinding(id, binding) {
37
+ boundById.set(id, binding)
38
+ }
39
+
40
+ export function releaseBinding(id) {
41
+ boundById.delete(id)
42
+ }
43
+
44
+ // The author's markup for a clone element, or null when the clone must be left
45
+ // exactly as it stands. Null covers all three reasons: no live binding behind
46
+ // it, an editor the author owns, and any content this session has actually
47
+ // changed. See restorable() in inline.js, which asks the same question of the
48
+ // live element at teardown.
49
+ export function pristineMarkupFor(cloneEl) {
50
+ const id = cloneEl.getAttribute(BOUND_ID_ATTR)
51
+ if (!id) return null
52
+ const binding = boundById.get(id)
53
+ if (!binding || !binding.restorable()) return null
54
+ return binding.originalHTML
55
+ }
56
+
57
+ // Written beside the marker when hypercms's own construct call is what put
58
+ // data-richclay on the element. richclay 0.5.0 tracks the same provenance in
59
+ // data-richclay-runtime-marker and strips the attribute for us, but clayjs and
60
+ // hyperclayjs both vendor 0.4.0, which has no marker and leaves it in the file.
61
+ // data-richclay is richclay's mount selector, so left there it turns every
62
+ // heading anyone clicked into a permanent richclay region. hypercms knew the
63
+ // answer before it touched the element; it does not need to ask.
64
+ export const RC_OWNED_ATTR = 'data-hcms-owns-richclay'
65
+
66
+ export function resolveRichClay(win) {
67
+ return (
68
+ (win && win.richclay && win.richclay.RichClay) ||
69
+ platform('RichClay', win) ||
70
+ (win && typeof win.RichClay === 'function' ? win.RichClay : null)
71
+ )
72
+ }
73
+
74
+ export function markBound(el, heldMarkup, richClayIsOurs) {
75
+ if (!el || typeof el.setAttribute !== 'function') return
76
+ el.setAttribute(BOUND_ATTR, heldMarkup ? 'rich' : 'plain')
77
+ if (richClayIsOurs) el.setAttribute(RC_OWNED_ATTR, 'true')
78
+ }
79
+
80
+ // ⚠ THE ORDER IS LOAD-BEARING. richclay's strip iterates
81
+ // "[data-richclay], [richclay], [editable], [clay-editable]". Remove
82
+ // data-richclay first and a bare <h1> stops matching that selector, the strip
83
+ // skips it, and every runtime attribute richclay wrote reaches the file. Strip
84
+ // FIRST, unmark second. Both orders were run and the bytes compared; the other
85
+ // one ships a permanently contenteditable heading.
86
+ export function cleanRichClayFromSnapshot(cloneDocEl, win) {
87
+ if (!cloneDocEl || typeof cloneDocEl.querySelectorAll !== 'function') return
88
+ const bound = cloneDocEl.querySelectorAll(`[${BOUND_ATTR}]`)
89
+ if (!bound.length) return
90
+
91
+ const RichClay = resolveRichClay(win)
92
+ if (RichClay && typeof RichClay.stripFromClone === 'function') {
93
+ try {
94
+ // A second pass over an already-stripped clone is string-identical, so
95
+ // this stays correct on a page that also uses richclay directly and has
96
+ // richclay's own bridge installed.
97
+ RichClay.stripFromClone(cloneDocEl)
98
+ } catch (err) {
99
+ console.warn('[hypercms] richclay strip failed; editor state may reach the save', err)
100
+ }
101
+ }
102
+ for (const el of bound) {
103
+ // Whether data-richclay comes off is decided by who put it there, not by
104
+ // which richclay is loaded. On 0.5.0 stripFromClone has already removed it
105
+ // and this is a no-op; on the 0.4.0 copies clayjs and hyperclayjs vendor,
106
+ // this is the only thing that removes it. An author's own opt-in never
107
+ // carries the marker, so it is never touched.
108
+ if (el.getAttribute(RC_OWNED_ATTR) === 'true') el.removeAttribute('data-richclay')
109
+ el.removeAttribute(RC_OWNED_ATTR)
110
+ el.removeAttribute(BOUND_ATTR)
111
+ }
112
+ }
113
+
114
+ // A page element that carries the CMS marker but has no binding behind it: a
115
+ // clone the engine made of a bound row, or of a bound element it replaced while
116
+ // rolling back a failed apply. destroy() cannot reach it — destroy belongs to an
117
+ // instance and a clone has none — so it is left permanently editable with page
118
+ // undo switched off for it. The snapshot hook still cleans the saved file; this
119
+ // is the live page.
120
+ export function stripOrphanEditorState(el, win) {
121
+ if (!el || typeof el.removeAttribute !== 'function') return
122
+ const RichClay = resolveRichClay(win)
123
+ if (RichClay && typeof RichClay.stripElement === 'function') {
124
+ try {
125
+ RichClay.stripElement(el)
126
+ } catch (err) {
127
+ console.warn('[hypercms] richclay element strip failed; the clone stays editable', err)
128
+ }
129
+ } else {
130
+ // An older richclay with no per-element strip. These two are what actually
131
+ // harm the live page; the rest is cosmetic and the snapshot hook removes it
132
+ // from the file either way.
133
+ el.removeAttribute('contenteditable')
134
+ el.removeAttribute('no-undo')
135
+ }
136
+ if (el.getAttribute(RC_OWNED_ATTR) === 'true') el.removeAttribute('data-richclay')
137
+ el.removeAttribute(RC_OWNED_ATTR)
138
+ el.removeAttribute(BOUND_ATTR)
139
+ el.removeAttribute(BOUND_ID_ATTR)
140
+ }