@panphora/clayjs 1.2.0 → 1.3.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.
@@ -31,6 +31,9 @@ preloadIfEnabled();
31
31
  // the person editing it deserves the warning.
32
32
  window.addEventListener('beforeunload', (event) => {
33
33
  if (!isEditMode) return;
34
+ // The demo plugin saves into this browser's own storage, so leaving the page
35
+ // loses nothing a prompt could protect.
36
+ if (window.clay?.demo) return;
34
37
 
35
38
  // The DIRTY domain, not the autosave domain. An edit inside a
36
39
  // no-trigger-autosave region never starts a save by itself, which is exactly
@@ -1,8 +1,12 @@
1
1
  import nearest from "./nearest.js";
2
2
  import pipe from "./pipe.js";
3
+ import { createContentView, cleanContentClone } from '../lib/content-dom.js';
3
4
 
4
5
  function init () {
5
6
 
7
+ window.clay = window.clay || {};
8
+ window.clay.dom = Object.assign(window.clay.dom || {}, { createContentView, cleanContentClone });
9
+
6
10
  // Bail if already initialized
7
11
  if (HTMLElement.prototype.hasOwnProperty('nearest')) {
8
12
  return;
@@ -214,5 +218,5 @@ function init () {
214
218
  // Auto-init when module is imported
215
219
  init();
216
220
 
217
- export { init };
221
+ export { init, createContentView, cleanContentClone };
218
222
  export default init;
@@ -0,0 +1,108 @@
1
+ import { capabilitySelector, hasCapability } from './region-capabilities.js'
2
+
3
+ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
4
+
5
+ function copyControlState(source, copy) {
6
+ if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
7
+ const type = (source.getAttribute?.('type') || '').toLowerCase()
8
+ if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
9
+ if ('checked' in source && 'checked' in copy) copy.checked = source.checked
10
+ if ('selected' in source && 'selected' in copy) copy.selected = source.selected
11
+ if (source.tagName === 'SELECT') {
12
+ for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
13
+ }
14
+ if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
15
+ }
16
+
17
+ function matchesWithin(node, selector, boundary, inherit) {
18
+ if (inherit) return !!node.closest?.(selector)
19
+ let current = node
20
+ while (current?.nodeType === 1) {
21
+ if (current.matches(selector)) return true
22
+ if (current === boundary) break
23
+ current = current.parentElement
24
+ }
25
+ return false
26
+ }
27
+
28
+ function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
29
+ if (source.nodeType === 1 && (
30
+ (inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
31
+ (exclude && matchesWithin(source, exclude, boundary, inherit))
32
+ )) return null
33
+ const copy = targetDocument.importNode(source, false)
34
+ maps.cloneToLive.set(copy, source)
35
+ maps.liveToClone.set(source, copy)
36
+ const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
37
+ const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
38
+ if (sourceChildren !== source) {
39
+ maps.cloneToLive.set(copyChildren, sourceChildren)
40
+ maps.liveToClone.set(sourceChildren, copyChildren)
41
+ }
42
+ for (const child of sourceChildren.childNodes || []) {
43
+ const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
44
+ if (childCopy) {
45
+ copyChildren.appendChild(childCopy)
46
+ if (child.nodeType === 1) copyControlState(child, childCopy)
47
+ }
48
+ }
49
+ if (source.nodeType === 1) copyControlState(source, copy)
50
+ return copy
51
+ }
52
+
53
+ export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
54
+ if (!context) throw new TypeError('createContentView requires a DOM context')
55
+ const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
56
+ if (!sourceDocument?.implementation?.createHTMLDocument) {
57
+ throw new TypeError('createContentView requires an HTML DOM implementation')
58
+ }
59
+ const inertDocument = sourceDocument.implementation.createHTMLDocument('')
60
+ const cloneToLive = new WeakMap()
61
+ const liveToClone = new WeakMap()
62
+ const sourceRoot = context.nodeType === 9 ? context.documentElement : context
63
+ if (exclude) inertDocument.documentElement.matches(exclude)
64
+ const capabilityMatch = capabilitySelector(capability)
65
+ const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
66
+ if (context.nodeType === 9 && root) {
67
+ inertDocument.replaceChild(root, inertDocument.documentElement)
68
+ liveToClone.set(context, inertDocument)
69
+ cloneToLive.set(inertDocument, context)
70
+ }
71
+
72
+ const assertSelector = selector => {
73
+ if (UNSUPPORTED_SELECTOR.test(selector)) {
74
+ throw new Error(`Filtered content queries do not support stateful selector: ${selector}`)
75
+ }
76
+ }
77
+
78
+ return {
79
+ root,
80
+ document: inertDocument,
81
+ capability,
82
+ selector: capabilitySelector(capability),
83
+ cloneToLive,
84
+ liveToClone,
85
+ original(node) { return cloneToLive.get(node) || null },
86
+ cloneOf(node) { return liveToClone.get(node) || null },
87
+ query(selector, queryRoot = root) {
88
+ assertSelector(selector)
89
+ return Array.from(queryRoot.querySelectorAll(selector), node => cloneToLive.get(node) || node)
90
+ },
91
+ text(node = root) {
92
+ const projected = node === root ? root : liveToClone.get(node)
93
+ return projected?.textContent || ''
94
+ },
95
+ html(node = root) {
96
+ const projected = node === root ? root : liveToClone.get(node)
97
+ return projected?.innerHTML ?? ''
98
+ },
99
+ clone(node = root) {
100
+ const projected = node === root ? root : liveToClone.get(node)
101
+ return projected ? inertDocument.importNode(projected, true) : null
102
+ },
103
+ }
104
+ }
105
+
106
+ export function cleanContentClone(node, options) {
107
+ return createContentView(node, options).root
108
+ }
@@ -68,6 +68,7 @@
68
68
 
69
69
  import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
70
  import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
+ import { isPolicyAttribute } from './region-capabilities.js';
71
72
  import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
72
73
 
73
74
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
@@ -186,7 +187,12 @@ const localMutation = {
186
187
  // doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
187
188
  // and their legacy equivalents), resolved against the callback's `require`.
188
189
  let filteredChanges = changes.filter(
189
- change => !skipForPolicy(this._policyForChange(change), require, skip)
190
+ change => {
191
+ const currentSkipped = skipForPolicy(this._policyForChange(change), require, skip);
192
+ if (!change.policyTransition) return !currentSkipped;
193
+ const previousSkipped = skipForPolicy(change.previousPolicy, require, skip);
194
+ return !(currentSkipped && previousSkipped);
195
+ }
190
196
  );
191
197
  if (!filteredChanges.length) {
192
198
  this._log('No changes passed the region policy, skipping callback');
@@ -478,7 +484,8 @@ const localMutation = {
478
484
  // Intake drop: a no-watch / mutations-ignore subtree (or extension noise)
479
485
  // is invisible to every consumer, so skip it without walking. All other
480
486
  // region attributes are resolved per-consumer in _notify.
481
- if (isInert(mutation.target)) {
487
+ const policyTransition = mutation.type === 'attributes' && isPolicyAttribute(mutation.attributeName);
488
+ if (!policyTransition && isInert(mutation.target)) {
482
489
  continue;
483
490
  }
484
491
 
@@ -586,7 +593,9 @@ const localMutation = {
586
593
  element: mutation.target,
587
594
  attribute: mutation.attributeName,
588
595
  oldValue: mutation.oldValue,
589
- newValue: mutation.target.getAttribute(mutation.attributeName)
596
+ newValue: mutation.target.getAttribute(mutation.attributeName),
597
+ policyTransition,
598
+ previousPolicy: policyTransition ? policyBeforeMutation(mutation) : null,
590
599
  };
591
600
  changes.push(change);
592
601
  changesByType.attribute.push(change);
@@ -783,3 +792,17 @@ if (typeof window !== 'undefined' && !existingHub) {
783
792
  }
784
793
 
785
794
  export default Mutation;
795
+ function policyBeforeMutation(record) {
796
+ let source = record.target;
797
+ let clone = source.cloneNode(false);
798
+ const leaf = clone;
799
+ while (source.parentElement) {
800
+ source = source.parentElement;
801
+ const parent = source.cloneNode(false);
802
+ parent.appendChild(clone);
803
+ clone = parent;
804
+ }
805
+ if (record.oldValue == null) leaf.removeAttribute(record.attributeName);
806
+ else leaf.setAttribute(record.attributeName, record.oldValue);
807
+ return resolveRegionPolicy(leaf);
808
+ }
@@ -0,0 +1,69 @@
1
+ export const CAPABILITIES = Object.freeze({
2
+ data: Object.freeze({ tokens: ['no-data'], bundles: ['editor-ui'] }),
3
+ save: Object.freeze({ tokens: ['no-save'], bundles: ['editor-ui'] }),
4
+ snapshot: Object.freeze({ tokens: ['no-snapshot'], bundles: ['editor-ui'] }),
5
+ watch: Object.freeze({ tokens: ['no-watch'], bundles: ['editor-ui'] }),
6
+ undo: Object.freeze({ tokens: ['no-undo'], bundles: ['editor-ui'] }),
7
+ history: Object.freeze({ tokens: [], bundles: ['editor-ui'] }),
8
+ })
9
+
10
+ export const BUNDLES = Object.freeze({
11
+ 'editor-ui': Object.freeze(['no-data', 'no-save', 'no-snapshot', 'no-watch', 'no-undo']),
12
+ })
13
+
14
+ export const POLICY_TOKENS = Object.freeze([
15
+ 'no-save',
16
+ 'no-snapshot',
17
+ 'no-trigger-autosave',
18
+ 'no-dirty',
19
+ 'no-watch',
20
+ 'no-undo',
21
+ 'no-data',
22
+ 'freeze',
23
+ 'editor-ui',
24
+ ])
25
+
26
+ export function hasPolicyToken(element, token) {
27
+ if (!element || element.nodeType !== 1) return false
28
+ const clay = element.getAttribute?.('clay')
29
+ return !!(
30
+ (clay && clay.split(/\s+/).includes(token)) ||
31
+ element.hasAttribute?.(token)
32
+ )
33
+ }
34
+
35
+ export function expandsTo(element, token) {
36
+ if (hasPolicyToken(element, token)) return true
37
+ for (const [bundle, members] of Object.entries(BUNDLES)) {
38
+ if (members.includes(token) && hasPolicyToken(element, bundle)) return true
39
+ }
40
+ return false
41
+ }
42
+
43
+ export function capabilitySelector(capability) {
44
+ const definition = CAPABILITIES[capability]
45
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
46
+ return [...definition.tokens, ...definition.bundles]
47
+ .flatMap(token => [`[clay~="${token}"]`, `[${token}]`])
48
+ .join(', ')
49
+ }
50
+
51
+ export function closestWithCapability(node, capability) {
52
+ let element = node && node.nodeType === 1 ? node : node?.parentElement
53
+ while (element && element.nodeType === 1) {
54
+ const definition = CAPABILITIES[capability]
55
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
56
+ if (definition.tokens.some(token => expandsTo(element, token)) ||
57
+ definition.bundles.some(bundle => hasPolicyToken(element, bundle))) return element
58
+ element = element.parentElement
59
+ }
60
+ return null
61
+ }
62
+
63
+ export function hasCapability(node, capability) {
64
+ return !!closestWithCapability(node, capability)
65
+ }
66
+
67
+ export function isPolicyAttribute(name) {
68
+ return name === 'clay' || POLICY_TOKENS.includes(name)
69
+ }
@@ -44,24 +44,21 @@
44
44
  */
45
45
 
46
46
  import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
47
+ import { capabilitySelector, expandsTo, hasCapability, hasPolicyToken, POLICY_TOKENS } from './region-capabilities.js';
47
48
 
48
49
  export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
49
50
 
50
51
  // The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
51
- export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'freeze'];
52
+ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui'];
52
53
 
53
54
  // Every canonical token spellable inside the space-separated `clay` attribute.
54
55
  // Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
55
56
  // while callers hardcoded their own copies.
56
- export const TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-dirty", "no-watch", "no-undo", "freeze"];
57
+ export const TOKENS = [...POLICY_TOKENS];
57
58
 
58
59
  // True when a region marker is present, whether spelled as a `clay` token
59
60
  // (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
60
- function hasRegionToken(el, token) {
61
- const clay = el.getAttribute?.("clay");
62
- if (clay && clay.split(/\s+/).includes(token)) return true; // whitespace-token semantics, matches [clay~=…]
63
- return !!el.hasAttribute?.(token); // legacy bare attribute
64
- }
61
+ const hasRegionToken = hasPolicyToken;
65
62
 
66
63
  /**
67
64
  * Add a canonical region token to an element's `clay` attribute, preserving any
@@ -79,7 +76,7 @@ export function addRegionToken(el, token) {
79
76
  }
80
77
 
81
78
  // Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
82
- export const STRIP_FROM_SAVE = '[clay~="no-save"], [no-save], [save-remove]';
79
+ export const STRIP_FROM_SAVE = `${capabilitySelector('save')}, [save-remove]`;
83
80
  export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
84
81
  // forComparison additionally strips every region whose autosave-trigger is off,
85
82
  // so their churn never marks the page dirty — including the no-watch /
@@ -117,7 +114,8 @@ export const STRIP_FROM_DIRTY_CHECK =
117
114
  // dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
118
115
  // original `snapshot-remove`; hyper-morph treats both as sync-ignored so a
119
116
  // live-sync receiver keeps its own local copy instead of deleting it.
120
- export const SNAPSHOT_REMOVE_SELECTOR = '[clay~="no-snapshot"], [snapshot-remove], [no-snapshot]';
117
+ export const SNAPSHOT_REMOVE_SELECTOR = `${capabilitySelector('snapshot')}, [snapshot-remove]`;
118
+ export const NO_DATA_SELECTOR = capabilitySelector('data');
121
119
 
122
120
  // Ancestor-aware, because the strip removes a marked element together with its
123
121
  // whole subtree: a child of a no-snapshot region is just as absent from every
@@ -128,6 +126,10 @@ export function isSnapshotRemoved(node) {
128
126
  return !!(element && element.closest && element.closest(SNAPSHOT_REMOVE_SELECTOR));
129
127
  }
130
128
 
129
+ export function isDataExcluded(node) {
130
+ return hasCapability(node, 'data');
131
+ }
132
+
131
133
  const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
132
134
  const RANK_PERSIST = ['full', 'frozen', 'none'];
133
135
 
@@ -158,11 +160,11 @@ export function resolveRegionPolicy(node) {
158
160
  while (element && element.nodeType === 1) {
159
161
  if (element.hasAttribute) {
160
162
  // new naked attributes
161
- if (hasRegionToken(element, 'no-watch')) watched = false;
163
+ if (expandsTo(element, 'no-watch')) watched = false;
162
164
  if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
163
165
  if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
164
- if (hasRegionToken(element, 'no-undo')) undoable = false;
165
- if (hasRegionToken(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
166
+ if (expandsTo(element, 'no-undo')) undoable = false;
167
+ if (expandsTo(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
166
168
  if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
167
169
  // legacy markers -> bundles
168
170
  if (hasRegionToken(element, 'mutations-ignore')) watched = false;
@@ -209,7 +211,7 @@ export function isInert(node) {
209
211
  if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) return true;
210
212
  while (element && element.nodeType === 1) {
211
213
  if (element.hasAttribute &&
212
- (hasRegionToken(element, 'no-watch') || hasRegionToken(element, 'mutations-ignore'))) {
214
+ (expandsTo(element, 'no-watch') || hasRegionToken(element, 'mutations-ignore'))) {
213
215
  return true;
214
216
  }
215
217
  element = element.parentElement;
@@ -283,6 +285,7 @@ export const regionShape = {
283
285
  resolveRegionPolicy,
284
286
  isInert,
285
287
  isSnapshotRemoved,
288
+ isDataExcluded,
286
289
  skipForPolicy,
287
290
  strictestPolicy,
288
291
  addRegionToken,
@@ -297,6 +300,7 @@ export const regionShape = {
297
300
  NO_TRIGGER_AUTOSAVE_SELECTOR,
298
301
  NO_DIRTY_SELECTOR,
299
302
  SNAPSHOT_REMOVE_SELECTOR,
303
+ NO_DATA_SELECTOR,
300
304
  FREEZE_SELECTOR,
301
305
 
302
306
  // Nested camelCase: what clay.internals.region has always published.
@@ -307,6 +311,7 @@ export const regionShape = {
307
311
  noTriggerAutosave: NO_TRIGGER_AUTOSAVE_SELECTOR,
308
312
  noDirty: NO_DIRTY_SELECTOR,
309
313
  snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
314
+ noData: NO_DATA_SELECTOR,
310
315
  freeze: FREEZE_SELECTOR,
311
316
  },
312
317
  };
@@ -10,10 +10,14 @@ export const CORE_WAVES = {
10
10
  "core/stale-host-notice.js",
11
11
  ],
12
12
  editOnly: [
13
- "core/snapshot.js", "core/save-core.js", "core/save.js",
13
+ "vendor/hyper-morph.vendor.js",
14
+ "core/snapshot.js",
15
+ // Before save.js: it takes the boot baseline as it evaluates, and a snapshot
16
+ // transform registered after that made an untouched page read as unsaved.
17
+ "core/persist.js", "core/admin-attrs.js",
18
+ "core/save-core.js", "core/save.js",
14
19
  "core/save-conflict-notice.js",
15
- "core/unsaved-warning.js", "core/persist.js",
16
- "core/admin-attrs.js", "core/autosave.js",
20
+ "core/unsaved-warning.js", "core/autosave.js",
17
21
  "attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
18
22
  "lib/cache-bust.js",
19
23
  ],
@@ -33,6 +37,14 @@ export const PLUGIN_PATHS = {
33
37
  upload: { path: "plugins/upload.js", editOnly: true, default: false },
34
38
  wire: { path: "plugins/wire.js", editOnly: false, default: false },
35
39
  demo: { path: "plugins/demo.js", editOnly: false, default: false },
40
+ // Saves the file's own bytes back instead of a fresh serialization of the DOM.
41
+ // editOnly because a page that cannot save has nothing to preserve. On by default
42
+ // because the fallback rate it is judged on can only be collected from real pages,
43
+ // and a plugin nobody enables produces no number to judge. It costs a 48 KB parser
44
+ // and one extra request at boot, both in edit mode only, and every save it cannot
45
+ // verify is sent as the ordinary full serialization, so the floor is the behaviour
46
+ // it replaces. `exclude=source` turns it off.
47
+ source: { path: "plugins/source.js", editOnly: true, default: true },
36
48
  };
37
49
 
38
50
  // One literal import per module the loader can ask for, so a bundler can see the
@@ -62,6 +74,7 @@ export const MODULES = {
62
74
  "attrs/onaftersave.js": () => import("./attrs/onaftersave.js"),
63
75
  "attrs/refetch-on-save.js": () => import("./attrs/refetch-on-save.js"),
64
76
  "lib/cache-bust.js": () => import("./lib/cache-bust.js"),
77
+ "vendor/hyper-morph.vendor.js": () => import("./vendor/hyper-morph.vendor.js"),
65
78
  "vendor/richclay.vendor.js": () => import("./vendor/richclay.vendor.js"),
66
79
  "plugins/indicator.js": () => import("./plugins/indicator.js"),
67
80
  "sync/live-sync.js": () => import("./sync/live-sync.js"),
@@ -72,9 +85,12 @@ export const MODULES = {
72
85
  "plugins/upload.js": () => import("./plugins/upload.js"),
73
86
  "plugins/wire.js": () => import("./plugins/wire.js"),
74
87
  "plugins/demo.js": () => import("./plugins/demo.js"),
88
+ "plugins/source.js": () => import("./plugins/source.js"),
75
89
  };
76
90
 
77
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo"];
91
+ // `source` is last on purpose: its install captures a save clone, so it wants every
92
+ // plugin that registers a document transform to have registered it first.
93
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo", "source"];
78
94
 
79
95
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
80
96
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
package/src/loader.js CHANGED
@@ -82,6 +82,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
82
82
  const snapshot = loaded["core/snapshot.js"];
83
83
  const save = loaded["core/save.js"];
84
84
  const cacheBustMod = loaded["lib/cache-bust.js"];
85
+ const hyperMorph = loaded["vendor/hyper-morph.vendor.js"];
85
86
 
86
87
  if (save) {
87
88
  const saveFn = save.savePage || save.default;
@@ -97,6 +98,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
97
98
  if (cacheBustMod) {
98
99
  clay.cacheBust = cacheBustMod.default;
99
100
  }
101
+ if (hyperMorph) clay.morph = hyperMorph.morph;
100
102
  }
101
103
 
102
104
  function attachPluginMember(path, mod) {
@@ -118,6 +120,8 @@ function attachPluginMember(path, mod) {
118
120
  clay.RichClay = mod.RichClay || mod.default;
119
121
  } else if (path === "vendor/quickcrop.vendor.js") {
120
122
  clay.quickcrop = mod.quickcrop || mod.default;
123
+ } else if (path === "plugins/source.js") {
124
+ clay.source = mod.source || mod.default;
121
125
  }
122
126
  }
123
127
 
@@ -15,6 +15,9 @@
15
15
  // localStorage is instant, and the saving state only shows past save.js's
16
16
  // 500ms debounce, so an instant answer would keep it invisible.
17
17
  //
18
+ // Demo mode never shows the leave-page warning: nothing it saves is worth a
19
+ // prompt, so an unsaved demo edit never blocks a click away.
20
+ //
18
21
  // The storage key includes <html demo-key> (fallback: the page's pathname).
19
22
  // Bump the attribute whenever the page's authored content changes, or
20
23
  // returning visitors' old saved bodies morph over the new page.
@@ -16,9 +16,14 @@
16
16
  */
17
17
  import { isEditMode } from "../core/is-edit-mode.js";
18
18
  import Mutation from "../lib/mutation.js";
19
+ import { capabilitySelector } from "../lib/region-capabilities.js";
20
+
21
+ const EDITOR_UI_SELECTOR = capabilitySelector('history');
19
22
 
20
23
  function makeSortable(sortableElem, Sortable) {
21
24
  let options = {};
25
+ const childSelector = /^(UL|OL)$/.test(sortableElem.tagName) ? 'li' : '*';
26
+ options.draggable = `> ${childSelector}:not(${EDITOR_UI_SELECTOR})`;
22
27
 
23
28
  // Check if Sortable instance already exists
24
29
  if (Sortable.get(sortableElem)) return;
@@ -115,5 +120,5 @@ async function init() {
115
120
  // Auto-init when module is imported; the loader awaits `ready` before resolving clay.ready
116
121
  const ready = init();
117
122
 
118
- export { init, ready };
123
+ export { init, ready, makeSortable };
119
124
  export default init;