@panphora/clayjs 0.2.0 → 0.4.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 +99 -7
- package/clay-internals.js +19 -0
- package/clay.js +18 -2
- package/package.json +19 -3
- package/sap.js +5 -5
- package/src/attrs/onaftersave.js +12 -1
- package/src/attrs/refetch-on-save.js +4 -4
- package/src/attrs/save-freeze.js +47 -19
- package/src/core/admin-contenteditable.js +2 -2
- package/src/core/admin-inputs.js +2 -2
- package/src/core/admin-onclick.js +2 -2
- package/src/core/admin-resources.js +23 -14
- package/src/core/autosave.js +11 -1
- package/src/core/edit-mode.js +2 -2
- package/src/core/host-attrs.js +46 -0
- package/src/core/is-edit-mode.js +14 -6
- package/src/core/persist.js +8 -1
- package/src/core/save-core.js +199 -290
- package/src/core/save.js +124 -84
- package/src/core/snapshot.js +99 -43
- package/src/core/unsaved-warning.js +8 -2
- package/src/dom/all.js +4 -29
- package/src/dom/form-data.js +1 -1
- package/src/dom/nearest.js +11 -9
- package/src/internals/index.js +64 -0
- package/src/lib/cache-bust.js +10 -0
- package/src/lib/cookie.js +50 -15
- package/src/lib/extension-noise.js +5 -1
- package/src/lib/mutation.js +65 -18
- package/src/lib/region-policy.js +17 -2
- package/src/lib/root-attrs.js +45 -0
- package/src/lib/throttle.js +25 -13
- package/src/loader.js +18 -24
- package/src/options/options.js +1 -3
- package/src/plugins/demo.js +7 -1
- package/src/sync/live-sync.js +121 -89
- package/src/ui/dialogs.js +10 -1
- package/src/ui/index.js +2 -11
- package/src/ui/toast.js +8 -2
- package/src/utils/debounce.js +24 -7
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +29 -16
- package/src/vendor/richclay.vendor.js +29 -22
package/src/attrs/save-freeze.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Changes inside freeze elements do not trigger autosave dirty checks.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { onSnapshot,
|
|
19
|
+
import { onSnapshot, addDocumentTransform } from "../core/snapshot.js";
|
|
20
20
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
21
21
|
import { FREEZE_SELECTOR } from "../lib/region-policy.js";
|
|
22
22
|
import Mutation from "../lib/mutation.js";
|
|
@@ -44,22 +44,51 @@ function captureAll() {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Which cloned element holds which authored innerHTML, for the current snapshot.
|
|
48
|
+
// Keyed by the cloned element itself: the prepare phase runs on the same clone
|
|
49
|
+
// object the snapshot phase saw, so identity is the pairing. An attribute marker
|
|
50
|
+
// would work too, right up until clay:snapshot-ready — which fires BETWEEN the two
|
|
51
|
+
// phases — hands the still-marked clone to live-sync and demo mode, both of which
|
|
52
|
+
// serialize it on the spot.
|
|
53
|
+
let pending = new WeakMap();
|
|
54
|
+
|
|
55
|
+
// Phase 2. The clone is a verbatim cloneNode of the live root and nothing has been
|
|
56
|
+
// stripped from it yet, so pairing by position is sound HERE and nowhere else.
|
|
57
|
+
function freezeSnapshot(clone) {
|
|
58
|
+
pending = new WeakMap();
|
|
59
|
+
|
|
48
60
|
const liveElements = document.querySelectorAll(FREEZE_SELECTOR);
|
|
49
61
|
const cloneElements = clone.querySelectorAll(FREEZE_SELECTOR);
|
|
50
62
|
|
|
51
63
|
log('freezing clone — live:', liveElements.length, 'clone:', cloneElements.length);
|
|
52
64
|
|
|
65
|
+
// Counts can only differ if an earlier onSnapshot hook added or removed
|
|
66
|
+
// elements, which means alignment is already gone. Restoring nothing beats
|
|
67
|
+
// restoring one element's authored content into a different element.
|
|
68
|
+
if (liveElements.length !== cloneElements.length) {
|
|
69
|
+
console.warn('[save-freeze] live/clone freeze counts differ, skipping freeze restore');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
for (let i = 0; i < cloneElements.length; i++) {
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
if (!originals.has(liveElements[i])) continue;
|
|
75
|
+
const original = originals.get(liveElements[i]);
|
|
76
|
+
pending.set(cloneElements[i], original);
|
|
77
|
+
if (cloneElements[i].innerHTML !== original) {
|
|
78
|
+
log('element', i, '— restoring original');
|
|
79
|
+
cloneElements[i].innerHTML = original;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Phase 3a. Re-freeze anything an [onbeforesave] handler changed between the two
|
|
85
|
+
// phases. Nothing to clean up: the pairing never entered the document.
|
|
86
|
+
function freezePrepare(clone) {
|
|
87
|
+
for (const el of clone.querySelectorAll(FREEZE_SELECTOR)) {
|
|
88
|
+
const original = pending.get(el);
|
|
89
|
+
if (original !== undefined && el.innerHTML !== original) {
|
|
90
|
+
log('re-restoring original after prepare-phase change');
|
|
91
|
+
el.innerHTML = original;
|
|
63
92
|
}
|
|
64
93
|
}
|
|
65
94
|
}
|
|
@@ -75,10 +104,8 @@ function init() {
|
|
|
75
104
|
// debounce:0 so we snapshot the authored content before any runtime edit
|
|
76
105
|
// mutates it; the callback is a pure read into a WeakMap (no DOM writes).
|
|
77
106
|
// require:'observed' (skip only no-watch); pausable:false so a morphed-in
|
|
78
|
-
// freeze region is still captured.
|
|
79
|
-
//
|
|
80
|
-
// directly to <head> at runtime is no longer captured (authored <head> freeze
|
|
81
|
-
// regions are still handled by captureAll() on load) — an accepted edge.
|
|
107
|
+
// freeze region is still captured. Mutation observes document.documentElement,
|
|
108
|
+
// so a [freeze] added directly to <head> at runtime is captured like any other.
|
|
82
109
|
Mutation.onAddElement({
|
|
83
110
|
selectorFilter: FREEZE_SELECTOR,
|
|
84
111
|
require: 'observed',
|
|
@@ -90,11 +117,12 @@ function init() {
|
|
|
90
117
|
|
|
91
118
|
// Phase 2: Freeze in snapshot — before snapshot-ready fires.
|
|
92
119
|
// This prevents live-sync from writing unfrozen content to disk.
|
|
93
|
-
onSnapshot(
|
|
120
|
+
onSnapshot(freezeSnapshot);
|
|
94
121
|
|
|
95
|
-
// Phase 3a: Freeze again in prepare
|
|
96
|
-
//
|
|
97
|
-
|
|
122
|
+
// Phase 3a: Freeze again in prepare, for anything changed between the phases
|
|
123
|
+
// (e.g. onbeforesave handlers). Pairs by the WeakMap phase 2 filled, because by
|
|
124
|
+
// now the clone has lost its [no-snapshot] subtrees and position means nothing.
|
|
125
|
+
addDocumentTransform(freezePrepare);
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
init();
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
-
import {
|
|
3
|
+
import { addDocumentTransform } from "./snapshot.js";
|
|
4
4
|
|
|
5
5
|
export const SELECTOR = '[editmode\\:contenteditable]';
|
|
6
6
|
|
|
7
7
|
export function disableContentEditableBeforeSave () {
|
|
8
|
-
|
|
8
|
+
addDocumentTransform(docElem => {
|
|
9
9
|
docElem.querySelectorAll(SELECTOR).forEach(resource => {
|
|
10
10
|
const originalValue = resource.getAttribute("contenteditable");
|
|
11
11
|
resource.setAttribute("inert-contenteditable", originalValue);
|
package/src/core/admin-inputs.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
-
import {
|
|
3
|
+
import { addDocumentTransform } from "./snapshot.js";
|
|
4
4
|
|
|
5
5
|
export const SELECTOR_DISABLED = '[viewmode\\:disabled]';
|
|
6
6
|
export const SELECTOR_READONLY = '[viewmode\\:readonly]';
|
|
7
7
|
|
|
8
8
|
export function disableAdminInputsBeforeSave() {
|
|
9
|
-
|
|
9
|
+
addDocumentTransform(docElem => {
|
|
10
10
|
docElem.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
|
|
11
11
|
input.setAttribute('disabled', '');
|
|
12
12
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
-
import {
|
|
3
|
+
import { addDocumentTransform } from "./snapshot.js";
|
|
4
4
|
|
|
5
5
|
export const SELECTOR = '[editmode\\:onclick]';
|
|
6
6
|
|
|
7
7
|
export function disableOnClickBeforeSave () {
|
|
8
|
-
|
|
8
|
+
addDocumentTransform(docElem => {
|
|
9
9
|
docElem.querySelectorAll(SELECTOR).forEach(resource => {
|
|
10
10
|
const originalValue = resource.getAttribute("onclick");
|
|
11
11
|
resource.setAttribute("inert-onclick", originalValue);
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
-
import {
|
|
3
|
+
import { addDocumentTransform } from "./snapshot.js";
|
|
4
4
|
|
|
5
5
|
export const SELECTOR = '[editmode\\:resource]:is(style, link, script)';
|
|
6
6
|
export const SELECTOR_INERT = '[editmode\\:resource]:is(style, link, script)[type^="inert/"]';
|
|
7
7
|
|
|
8
|
+
const INERT_PREFIX = 'inert/';
|
|
9
|
+
|
|
10
|
+
// An absent type is recorded as an empty remainder, so enabling restores absence
|
|
11
|
+
// rather than inventing text/javascript. A <style> or a stylesheet <link> handed a
|
|
12
|
+
// JavaScript MIME on the way back never applies again.
|
|
13
|
+
function makeInert(resource) {
|
|
14
|
+
const current = resource.getAttribute('type');
|
|
15
|
+
if (current && current.startsWith(INERT_PREFIX)) return false;
|
|
16
|
+
resource.setAttribute('type', INERT_PREFIX + (current || ''));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeActive(resource) {
|
|
21
|
+
const original = resource.getAttribute('type').slice(INERT_PREFIX.length);
|
|
22
|
+
if (original) resource.setAttribute('type', original);
|
|
23
|
+
else resource.removeAttribute('type');
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
export function disableAdminResourcesBeforeSave () {
|
|
9
|
-
|
|
10
|
-
docElem.querySelectorAll(SELECTOR).forEach(
|
|
11
|
-
const currentType = resource.getAttribute('type') || 'text/javascript';
|
|
12
|
-
if (!currentType.startsWith('inert/')) {
|
|
13
|
-
resource.setAttribute('type', `inert/${currentType}`);
|
|
14
|
-
}
|
|
15
|
-
});
|
|
27
|
+
addDocumentTransform(docElem => {
|
|
28
|
+
docElem.querySelectorAll(SELECTOR).forEach(makeInert);
|
|
16
29
|
});
|
|
17
30
|
}
|
|
18
31
|
|
|
@@ -27,18 +40,14 @@ export function enableAdminResourcesOnPageLoad () {
|
|
|
27
40
|
// Runtime toggle functions
|
|
28
41
|
export function enableAdminResources() {
|
|
29
42
|
document.querySelectorAll(SELECTOR_INERT).forEach(resource => {
|
|
30
|
-
resource
|
|
43
|
+
makeActive(resource);
|
|
31
44
|
resource.replaceWith(resource.cloneNode(true));
|
|
32
45
|
});
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export function disableAdminResources() {
|
|
36
49
|
document.querySelectorAll(SELECTOR).forEach(resource => {
|
|
37
|
-
|
|
38
|
-
if (!currentType.startsWith('inert/')) {
|
|
39
|
-
resource.setAttribute('type', `inert/${currentType}`);
|
|
40
|
-
resource.replaceWith(resource.cloneNode(true));
|
|
41
|
-
}
|
|
50
|
+
if (makeInert(resource)) resource.replaceWith(resource.cloneNode(true));
|
|
42
51
|
});
|
|
43
52
|
}
|
|
44
53
|
|
package/src/core/autosave.js
CHANGED
|
@@ -19,9 +19,19 @@ import { initUserGesture, markUserDriven } from "../lib/user-gesture.js";
|
|
|
19
19
|
* Initialize auto-save on DOM changes
|
|
20
20
|
* Uses debounced mutation observer
|
|
21
21
|
*/
|
|
22
|
+
// A bare debounce resets on every change, so a page with anything that mutates
|
|
23
|
+
// faster than the delay — a clock, a countdown, a polling counter — pushes autosave
|
|
24
|
+
// into the future forever and it never fires, with nothing in the UI saying so.
|
|
25
|
+
// maxWait bounds that: however long the churn lasts, a save happens within
|
|
26
|
+
// AUTOSAVE_MAX_WAIT_MS of the first change it was waiting on. (The settled baseline
|
|
27
|
+
// capture in save.js already solved this for itself with MAX_SETTLE_MS.)
|
|
28
|
+
const AUTOSAVE_DEBOUNCE_MS = 1500;
|
|
29
|
+
const AUTOSAVE_MAX_WAIT_MS = 10000;
|
|
30
|
+
|
|
22
31
|
function initSavePageOnChange() {
|
|
23
32
|
Mutation.onAnyChange({
|
|
24
|
-
debounce:
|
|
33
|
+
debounce: AUTOSAVE_DEBOUNCE_MS,
|
|
34
|
+
maxWait: AUTOSAVE_MAX_WAIT_MS,
|
|
25
35
|
omitChangeDetails: true,
|
|
26
36
|
require: 'autosave'
|
|
27
37
|
}, () => {
|
package/src/core/edit-mode.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (it's the way IN), and setPageTypeOnPageLoad marks <html> in either mode.
|
|
4
4
|
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
5
5
|
import onDomReady from "../lib/dom-ready.js";
|
|
6
|
-
import {
|
|
6
|
+
import { addDocumentTransform } from "./snapshot.js";
|
|
7
7
|
|
|
8
8
|
export function toggleEditMode() {
|
|
9
9
|
const url = new URL(window.location.href);
|
|
@@ -13,7 +13,7 @@ export function toggleEditMode() {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function setViewerPageTypeBeforeSave () {
|
|
16
|
-
|
|
16
|
+
addDocumentTransform(docElem => {
|
|
17
17
|
docElem.setAttribute("editmode", "false");
|
|
18
18
|
docElem.setAttribute("pageowner", "false");
|
|
19
19
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host-attrs.js — the attributes a host puts on <html>, and what they mean.
|
|
3
|
+
*
|
|
4
|
+
* These are ephemeral: the host injects them into the response and strips them
|
|
5
|
+
* back out of whatever the client saves, so they never reach disk. clayjs only
|
|
6
|
+
* ever reads them, and reads them here so the two spellings of the save token
|
|
7
|
+
* cannot drift between the edit-mode ladder and the save lane.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { HOST_TOKEN_ATTRS, SAVE_TRANSPORT_ATTR } from "../lib/root-attrs.js";
|
|
11
|
+
|
|
12
|
+
// A host that wants the desktop JSON envelope on its save lane declares it on
|
|
13
|
+
// the root. This replaced a `location.hostname === 'localhost'` sniff, which sent
|
|
14
|
+
// the envelope to every host that happened to be local, including ones whose
|
|
15
|
+
// save lane takes text and answers 415.
|
|
16
|
+
export const DESKTOP_JSON = "desktop-json-v1";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The per-document save token this response carries, or null.
|
|
20
|
+
* @returns {?string}
|
|
21
|
+
*/
|
|
22
|
+
export function saveToken() {
|
|
23
|
+
if (typeof document === "undefined") return null;
|
|
24
|
+
for (const attr of HOST_TOKEN_ATTRS) {
|
|
25
|
+
const value = document.documentElement.getAttribute(attr);
|
|
26
|
+
if (value) return value;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when the host handed this response a save token of either spelling.
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function hasSaveToken() {
|
|
36
|
+
return saveToken() !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The save transport the served document declares, or null.
|
|
41
|
+
* @returns {?string}
|
|
42
|
+
*/
|
|
43
|
+
export function saveTransport() {
|
|
44
|
+
if (typeof document === "undefined") return null;
|
|
45
|
+
return document.documentElement.getAttribute(SAVE_TRANSPORT_ATTR);
|
|
46
|
+
}
|
package/src/core/is-edit-mode.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import cookie from "../lib/cookie.js";
|
|
2
2
|
import query from "../lib/query.js";
|
|
3
|
+
import { hasSaveToken } from "./host-attrs.js";
|
|
3
4
|
|
|
4
5
|
// Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
|
|
5
6
|
// opt-in window.clayEditMode global (with the legacy window.__hyperclayEditMode
|
|
6
|
-
// still honored as a fallback — htmlclay injects it today), then the
|
|
7
|
-
//
|
|
8
|
-
// htmlclay, any self-saving file) that are
|
|
9
|
-
// cookie; setting it before clayjs loads turns
|
|
7
|
+
// still honored as a fallback — htmlclay injects it today), then a save token the
|
|
8
|
+
// host put on the root, then the platform's isAdminOfCurrentResource cookie. The
|
|
9
|
+
// global is for standalone uses (demos, htmlclay, any self-saving file) that are
|
|
10
|
+
// always editable and have no owner cookie; setting it before clayjs loads turns
|
|
11
|
+
// on the edit-only modules.
|
|
12
|
+
//
|
|
13
|
+
// The token sits above the cookie because it is the stronger claim and often the
|
|
14
|
+
// only one visible: the host minted it for this response and nothing else, while
|
|
15
|
+
// the cookie is ambient. A sandboxed document cannot read the cookie at all, so
|
|
16
|
+
// without this rung a host that sandboxes its own documents can hand one a save
|
|
17
|
+
// token and still watch it render read-only.
|
|
10
18
|
let forcedEditMode = null;
|
|
11
19
|
if (typeof window !== "undefined") {
|
|
12
20
|
if (window.clayEditMode != null) {
|
|
@@ -17,10 +25,10 @@ if (typeof window !== "undefined") {
|
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
const isEditMode = query.editmode
|
|
20
|
-
? query.editmode === "true" // takes precedence over the global and cookie
|
|
28
|
+
? query.editmode === "true" // takes precedence over the global, token and cookie
|
|
21
29
|
: forcedEditMode != null
|
|
22
30
|
? forcedEditMode
|
|
23
|
-
: Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
31
|
+
: hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
24
32
|
|
|
25
33
|
const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
26
34
|
|
package/src/core/persist.js
CHANGED
|
@@ -86,7 +86,14 @@ export default function enablePersistentFormInputValues(filterBySelector = "[per
|
|
|
86
86
|
const finalize = (selector) => {
|
|
87
87
|
const live = document.querySelectorAll(selector);
|
|
88
88
|
const cloned = doc.querySelectorAll(selector);
|
|
89
|
-
|
|
89
|
+
// Index pairing only holds while the two lists describe the same tree. If a
|
|
90
|
+
// hook ever diverges them, writing one control's value into another's is
|
|
91
|
+
// worse than writing none, so fail loudly instead of silently.
|
|
92
|
+
if (live.length !== cloned.length) {
|
|
93
|
+
console.warn('[persist] live/clone counts differ for', selector, '— skipping');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
cloned.forEach((c, i) => finalizeControlForSave(c, live[i]));
|
|
90
97
|
};
|
|
91
98
|
finalize(inputSelector);
|
|
92
99
|
finalize(textareaSelector);
|