@panphora/clayjs 0.7.4 → 1.1.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 -85
- package/THIRD-PARTY-NOTICES.md +2 -4
- package/dist/clay.standalone.js +22030 -0
- package/{all.js → entries/all.js} +7 -1
- package/entries/clay-data.js +16 -0
- package/{clay-dom.js → entries/clay-dom.js} +7 -1
- package/{clay-events.js → entries/clay-events.js} +7 -1
- package/{clay-internals.js → entries/clay-internals.js} +7 -1
- package/{clay-options.js → entries/clay-options.js} +7 -1
- package/{clay-ui.js → entries/clay-ui.js} +7 -1
- package/{clay-utils.js → entries/clay-utils.js} +7 -1
- package/{clay.js → entries/clay.js} +15 -1
- package/package.json +46 -15
- package/src/core/autosave.js +3 -2
- package/src/core/etag.js +114 -0
- package/src/core/host-attrs.js +7 -2
- package/src/core/host-meta.js +20 -3
- package/src/core/persist.js +3 -2
- package/src/core/save-conflict-notice.js +196 -0
- package/src/core/save-core.js +60 -3
- package/src/core/save.js +80 -1
- package/src/core/snapshot.js +34 -17
- package/src/lib/attr-aliases.js +13 -0
- package/src/lib/dirty-gate.js +2 -1
- package/src/lib/root-attrs.js +23 -7
- package/src/loader-logic.js +39 -0
- package/src/loader.js +11 -5
- package/src/plugins/demo.js +2 -2
- package/src/plugins/indicator.js +13 -2
- package/src/plugins/sortable.js +5 -5
- package/src/standalone.js +98 -0
- package/src/sync/live-sync.js +42 -0
- package/src/ui/index.js +7 -0
- package/src/vendor/hypercms.vendor.js +13 -13
- package/src/vendor/richclay.vendor.js +15 -15
- package/_headers +0 -14
- package/clay-data.js +0 -16
- package/src/lib/load-vendor-script.js +0 -57
- /package/{sap.js → entries/sap.js} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { isEditMode } from "./is-edit-mode.js";
|
|
2
|
+
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
+
|
|
4
|
+
// The page's only word on a refused save. Without it a document whose host said no
|
|
5
|
+
// looks exactly like one that is saving fine, while autosave sits suspended: the
|
|
6
|
+
// status chip that would have said so is a plugin, off by default, so most
|
|
7
|
+
// documents show nothing at all.
|
|
8
|
+
//
|
|
9
|
+
// It sits at the bottom because that is where a hand already is on a phone, and
|
|
10
|
+
// one line because the decision is small: keep this version, or take the other.
|
|
11
|
+
// Discarding arms before it fires, since it is the only control here that destroys
|
|
12
|
+
// work with no undo, and the page's own CSS is treated as hostile throughout.
|
|
13
|
+
|
|
14
|
+
const BG = "var(--clay-conflict-bg,#222)";
|
|
15
|
+
const INK = "var(--clay-conflict-ink,#fff)";
|
|
16
|
+
const EDGE = "var(--clay-conflict-edge,rgba(255,255,255,.28))";
|
|
17
|
+
const WARN = "var(--clay-conflict-warn,#e3a33f)";
|
|
18
|
+
const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
19
|
+
|
|
20
|
+
// The host names what moved the file when it knows. It often cannot: a plain
|
|
21
|
+
// filesystem write has no author. Anything unrecognised falls back to the phrase
|
|
22
|
+
// that is true in every case.
|
|
23
|
+
const SOURCES = {
|
|
24
|
+
"another-tab": "in another tab",
|
|
25
|
+
"another-person": "by someone else",
|
|
26
|
+
"an-agent": "by an agent",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ARM_MS = 5000;
|
|
30
|
+
|
|
31
|
+
let root = null, line = null, keep = null, drop = null;
|
|
32
|
+
let armTimer = null, armed = false, busy = false;
|
|
33
|
+
|
|
34
|
+
const still = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
35
|
+
|
|
36
|
+
// Every declaration goes on as !important, and this is the whole reason the notice
|
|
37
|
+
// survives a stranger's page. A plain inline style loses to an author rule that
|
|
38
|
+
// carries !important, and `button { ... !important }` is a thing real pages do: the
|
|
39
|
+
// first browser run of this module had both controls repainted in the host's colours
|
|
40
|
+
// and font. Only an inline !important outranks an author !important.
|
|
41
|
+
function style(el, rules) {
|
|
42
|
+
for (const rule of rules) {
|
|
43
|
+
const at = rule.indexOf(":");
|
|
44
|
+
el.style.setProperty(rule.slice(0, at).trim(), rule.slice(at + 1).trim(), "important");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Same reason: a later assignment must be able to beat the !important one already
|
|
49
|
+
// sitting on the element, and a plain style.foo = x silently cannot.
|
|
50
|
+
function set(el, prop, value) {
|
|
51
|
+
el.style.setProperty(prop, value, "important");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function make(tag, rules, text) {
|
|
55
|
+
const el = document.createElement(tag);
|
|
56
|
+
style(el, rules);
|
|
57
|
+
if (text) el.textContent = text;
|
|
58
|
+
return el;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// all:initial first, because a page restyling every button is the normal case, not
|
|
62
|
+
// the adversarial one. Everything the control needs is restated after it.
|
|
63
|
+
function button(label, rules) {
|
|
64
|
+
const b = make("button", [
|
|
65
|
+
"all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
|
|
66
|
+
"font-weight:500", "border-radius:6px", "padding:6px 12px", "white-space:nowrap",
|
|
67
|
+
"flex:none", ...rules,
|
|
68
|
+
], label);
|
|
69
|
+
b.type = "button";
|
|
70
|
+
b.addEventListener("focus", () => {
|
|
71
|
+
set(b, "outline", `2px solid ${WARN}`);
|
|
72
|
+
set(b, "outline-offset", "2px");
|
|
73
|
+
});
|
|
74
|
+
b.addEventListener("blur", () => set(b, "outline", "none"));
|
|
75
|
+
return b;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to
|
|
79
|
+
// the layout viewport, which parks a bottom-anchored bar behind the keyboard at the
|
|
80
|
+
// exact moment somebody is typing. Lift it by the difference instead.
|
|
81
|
+
function place() {
|
|
82
|
+
if (!root) return;
|
|
83
|
+
const vv = window.visualViewport;
|
|
84
|
+
const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
|
|
85
|
+
set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function build() {
|
|
89
|
+
root = make("div", [
|
|
90
|
+
"position:fixed", "left:50%", "transform:translateX(-50%)",
|
|
91
|
+
"z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
|
|
92
|
+
"max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
|
|
93
|
+
"padding:9px 12px", "border-radius:10px",
|
|
94
|
+
`background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
|
|
95
|
+
"box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
|
|
96
|
+
`font:${FONT}`, "text-align:left",
|
|
97
|
+
]);
|
|
98
|
+
root.setAttribute("clay", "no-save no-watch no-snapshot");
|
|
99
|
+
root.setAttribute("data-clay-conflict", "");
|
|
100
|
+
root.setAttribute("role", "alert");
|
|
101
|
+
|
|
102
|
+
line = make("span", ["margin-right:2px"]);
|
|
103
|
+
drop = button("Load theirs", [`color:${INK}`, "opacity:.72", "padding:6px 8px"]);
|
|
104
|
+
keep = button("Keep mine", [`background:${INK}`, `color:${BG}`, "font-weight:600"]);
|
|
105
|
+
|
|
106
|
+
drop.addEventListener("click", onDrop);
|
|
107
|
+
keep.addEventListener("click", onKeep);
|
|
108
|
+
root.append(line, drop, keep);
|
|
109
|
+
|
|
110
|
+
if (!still()) {
|
|
111
|
+
set(root, "transition", "opacity .18s");
|
|
112
|
+
set(root, "opacity", "0");
|
|
113
|
+
}
|
|
114
|
+
document.body.appendChild(root);
|
|
115
|
+
place();
|
|
116
|
+
window.visualViewport?.addEventListener("resize", place);
|
|
117
|
+
window.visualViewport?.addEventListener("scroll", place);
|
|
118
|
+
requestAnimationFrame(() => set(root, "opacity", "1"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One arming click before the reload. A first click is a choice, not a confirmation
|
|
122
|
+
// of anything, and this is the only control here that throws away work nothing can
|
|
123
|
+
// bring back. It disarms itself so a bar left open does not stay one stray tap from
|
|
124
|
+
// discarding an afternoon.
|
|
125
|
+
function onDrop() {
|
|
126
|
+
if (armed) { window.location.reload(); return; }
|
|
127
|
+
armed = true;
|
|
128
|
+
drop.textContent = "Yes, drop my edits";
|
|
129
|
+
set(drop, "opacity", "1");
|
|
130
|
+
set(drop, "color", WARN);
|
|
131
|
+
set(drop, "box-shadow", `inset 0 0 0 1px ${WARN}`);
|
|
132
|
+
clearTimeout(armTimer);
|
|
133
|
+
armTimer = setTimeout(disarm, ARM_MS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function disarm() {
|
|
137
|
+
clearTimeout(armTimer);
|
|
138
|
+
if (!armed) return;
|
|
139
|
+
armed = false;
|
|
140
|
+
drop.textContent = "Load theirs";
|
|
141
|
+
set(drop, "opacity", ".72");
|
|
142
|
+
set(drop, "color", INK);
|
|
143
|
+
set(drop, "box-shadow", "none");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function onKeep() {
|
|
147
|
+
if (busy) return;
|
|
148
|
+
busy = true;
|
|
149
|
+
disarm();
|
|
150
|
+
keep.textContent = "Saving…";
|
|
151
|
+
set(keep, "opacity", ".65");
|
|
152
|
+
try {
|
|
153
|
+
const result = await window.clay?.save?.overwrite?.();
|
|
154
|
+
if (result && result.ok === false) throw new Error(result.msg || "");
|
|
155
|
+
} catch {
|
|
156
|
+
// Left showing: the save still has not happened, and clearing the bar here
|
|
157
|
+
// would put the page back to looking like one that is saving normally.
|
|
158
|
+
line.textContent = "That did not save either. Your edits are still here.";
|
|
159
|
+
keep.textContent = "Try again";
|
|
160
|
+
set(keep, "opacity", "1");
|
|
161
|
+
busy = false;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
busy = false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function onKeydown(e) {
|
|
168
|
+
if (e.key === "Escape" && armed) { e.preventDefault(); drop.focus(); disarm(); }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function show(e) {
|
|
172
|
+
if (!root) build();
|
|
173
|
+
set(root, "display", "flex");
|
|
174
|
+
const where = SOURCES[e?.detail?.changedBy] || "elsewhere";
|
|
175
|
+
line.textContent = `This page was updated ${where}. Saving is paused.`;
|
|
176
|
+
keep.textContent = "Keep mine";
|
|
177
|
+
set(keep, "opacity", "1");
|
|
178
|
+
busy = false;
|
|
179
|
+
disarm();
|
|
180
|
+
place();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hide() {
|
|
184
|
+
if (!root) return;
|
|
185
|
+
disarm();
|
|
186
|
+
set(root, "display", "none");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function init() {
|
|
190
|
+
if (!isEditMode) return;
|
|
191
|
+
document.addEventListener("clay:save-conflict", show);
|
|
192
|
+
document.addEventListener("clay:save-saved", hide);
|
|
193
|
+
document.addEventListener("keydown", onKeydown);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
onDomReady(init);
|
package/src/core/save-core.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { isEditMode } from "./is-edit-mode.js";
|
|
11
11
|
import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
|
|
12
12
|
import { saveToken } from "./host-attrs.js";
|
|
13
|
+
import { lastSeenEtag, conditionalSaves, recordEtag, seedEtag } from "./etag.js";
|
|
13
14
|
import {
|
|
14
15
|
getPageContents,
|
|
15
16
|
onSnapshot,
|
|
@@ -62,13 +63,21 @@ function successResult(data) {
|
|
|
62
63
|
|
|
63
64
|
function errorResult(err) {
|
|
64
65
|
const timedOut = err.name === 'AbortError';
|
|
66
|
+
// Spec §6: a 412 is a REFUSED save, not a failed one. The host wrote nothing and
|
|
67
|
+
// its document is byte-identical to what it was, and the bytes we tried to write
|
|
68
|
+
// are still on this page. Reporting that as 'error' would give the one outcome
|
|
69
|
+
// where nothing went wrong the one severity that means something did, and would
|
|
70
|
+
// put it through the same retry-and-toast path as a dead server.
|
|
71
|
+
// The status is authoritative (§3), and `code` is honoured too because a proxy
|
|
72
|
+
// can answer 412 on the host's behalf with no body at all.
|
|
73
|
+
const conflicted = !timedOut && (err.status === 412 || err.code === 'conflict');
|
|
65
74
|
return {
|
|
66
75
|
ok: false,
|
|
67
76
|
msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
|
|
68
77
|
// A timeout is not evidence the write failed. The request may well have landed,
|
|
69
78
|
// so this reports "we do not know" rather than asserting something false.
|
|
70
|
-
msgType: timedOut ? 'unknown' : 'error',
|
|
71
|
-
code: timedOut ? 'timeout' : (err.code ?? null),
|
|
79
|
+
msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
|
|
80
|
+
code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
|
|
72
81
|
etag: null
|
|
73
82
|
};
|
|
74
83
|
}
|
|
@@ -134,7 +143,43 @@ function buildSaveRequest(content, userDriven, signal) {
|
|
|
134
143
|
body: content
|
|
135
144
|
};
|
|
136
145
|
|
|
137
|
-
|
|
146
|
+
// Spec §6: the stamp this tab last saw, so a host that advertises `conditional`
|
|
147
|
+
// can refuse rather than overwrite a version nobody here has read.
|
|
148
|
+
//
|
|
149
|
+
// Two gates, and both are the point. The capability must have been announced by
|
|
150
|
+
// name, because §5 forbids inferring one any other way, and a host that never
|
|
151
|
+
// promised to honour If-Match may do anything at all with it. And a stamp must
|
|
152
|
+
// actually be held: a host reads this header's PRESENCE, so an empty value is
|
|
153
|
+
// not a softer version of the request, it is a save asking to be refused.
|
|
154
|
+
const etag = lastSeenEtag();
|
|
155
|
+
if (conditionalSaves() && etag) options.headers['If-Match'] = etag;
|
|
156
|
+
|
|
157
|
+
return { url: resolveSaveUrl(path), options };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The absolute URL a save goes to.
|
|
162
|
+
*
|
|
163
|
+
* A relative path is resolved by fetch against the DOCUMENT's base URL, which
|
|
164
|
+
* `<base href>` sets and the author of a malleable document controls. Left
|
|
165
|
+
* relative, a `<base href="https://elsewhere.example/">` sends the document and
|
|
166
|
+
* the per-document token in the path to an origin the document picked. Pinning to
|
|
167
|
+
* the real origin is the whole fix.
|
|
168
|
+
*
|
|
169
|
+
* The guard is not defensive noise. `window.location.origin` is the STRING "null"
|
|
170
|
+
* on a file:// document, and `new URL(path, "null")` throws a TypeError, which
|
|
171
|
+
* would escape synchronously here rather than becoming a failed save. Documents
|
|
172
|
+
* opened from disk are a first-class case (an exported app is just a file), and
|
|
173
|
+
* there is no origin to pin to and no host to save to there anyway, so the
|
|
174
|
+
* relative path is both the honest answer and the one that cannot throw.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} path - Root-relative save path
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function resolveSaveUrl(path) {
|
|
180
|
+
const origin = window.location.origin;
|
|
181
|
+
if (!origin || origin === "null") return path;
|
|
182
|
+
return new URL(path, origin).href;
|
|
138
183
|
}
|
|
139
184
|
|
|
140
185
|
/**
|
|
@@ -183,10 +228,22 @@ function sendSave(content) {
|
|
|
183
228
|
error.status = res.status;
|
|
184
229
|
throw error;
|
|
185
230
|
}
|
|
231
|
+
// The one place a stamp is ever learned from a save (§6). A response with no
|
|
232
|
+
// etag clears it rather than keeping the old one, because the write we just
|
|
233
|
+
// made means any stamp held here describes bytes the host no longer stores.
|
|
234
|
+
recordEtag(data.etag ?? null);
|
|
186
235
|
return data;
|
|
187
236
|
}))
|
|
188
237
|
.catch(err => {
|
|
189
238
|
console.error('Failed to save page:', err);
|
|
239
|
+
// Spec §7: a timeout is INDETERMINATE, so this write may well have landed,
|
|
240
|
+
// and the stamp held here may already describe bytes the host has replaced.
|
|
241
|
+
// Reconcile against the host before anything retries, or a save that times
|
|
242
|
+
// out and lands is followed by a 412 the person cannot explain and did not
|
|
243
|
+
// cause. The only save that can have moved the document inside that window
|
|
244
|
+
// is almost always this one, so taking the host's current stamp is taking
|
|
245
|
+
// back our own.
|
|
246
|
+
if (err.name === 'AbortError') seedEtag({ fresh: true });
|
|
190
247
|
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
191
248
|
// save still reports the human gesture instead of reading as background.
|
|
192
249
|
if (userDriven) markUserDriven();
|
package/src/core/save.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isSaveInProgress
|
|
21
21
|
} from "./save-core.js";
|
|
22
22
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
|
+
import { seedEtag } from "./etag.js";
|
|
23
24
|
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
24
25
|
import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
|
|
25
26
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
@@ -50,7 +51,7 @@ let savingTimeout = null;
|
|
|
50
51
|
/**
|
|
51
52
|
* Sets the save status on <html> and dispatches an event.
|
|
52
53
|
*
|
|
53
|
-
* @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
|
|
54
|
+
* @param {string} state - One of: 'saving', 'saved', 'offline', 'error', 'conflict'
|
|
54
55
|
* @param {string} msg - Optional message (e.g., error details)
|
|
55
56
|
* @param {string} msgType - Optional severity from the server (e.g., 'warning')
|
|
56
57
|
*/
|
|
@@ -190,6 +191,46 @@ export function resumeAutosave() {
|
|
|
190
191
|
savePageThrottled();
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
// ============================================
|
|
195
|
+
// THE CONFLICT HOLD
|
|
196
|
+
// ============================================
|
|
197
|
+
//
|
|
198
|
+
// A 412 refuses this tab's bytes because the document changed since this tab last
|
|
199
|
+
// saw it (spec §6). Two things have to follow, and the second is the one that is
|
|
200
|
+
// easy to leave out.
|
|
201
|
+
//
|
|
202
|
+
// Nothing may be thrown away. The baselines do not advance on a refused save, so
|
|
203
|
+
// the edits stay dirty, the close warning still fires, and the person keeps what
|
|
204
|
+
// they typed. That falls out of applySaveResult and needs no special case.
|
|
205
|
+
//
|
|
206
|
+
// And autosave has to stop. Every autosave from here sends the same stamp and is
|
|
207
|
+
// refused for the same reason, so leaving it running means a save attempt every
|
|
208
|
+
// throttle window, forever, each one toasting a failure the person can do nothing
|
|
209
|
+
// about. The suspension clay.wire already owns is exactly the right lever: it
|
|
210
|
+
// stops AUTOsave only, so an explicit Cmd+S still goes out, and it replays one
|
|
211
|
+
// missed save on release so nothing typed during the hold is stranded.
|
|
212
|
+
//
|
|
213
|
+
// The hold is released when a save lands, whatever produced it: clay.save.overwrite,
|
|
214
|
+
// a live-sync frame that brought the page back in step, or the other tab going away.
|
|
215
|
+
let conflictHold = false;
|
|
216
|
+
|
|
217
|
+
function holdForConflict() {
|
|
218
|
+
if (conflictHold) return;
|
|
219
|
+
conflictHold = true;
|
|
220
|
+
suspendAutosave();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function releaseConflictHold() {
|
|
224
|
+
if (!conflictHold) return;
|
|
225
|
+
conflictHold = false;
|
|
226
|
+
resumeAutosave();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** True while this tab is refusing to autosave over a version it has not seen. */
|
|
230
|
+
export function isSaveConflicted() {
|
|
231
|
+
return conflictHold;
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
function skipped_(msg) {
|
|
194
235
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
195
236
|
}
|
|
@@ -217,6 +258,10 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
|
|
|
217
258
|
// warning, and the UI module is what decides how to render that.
|
|
218
259
|
setSaveState('saved', result.msg || 'Saved', result.msgType);
|
|
219
260
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
261
|
+
releaseConflictHold();
|
|
262
|
+
} else if (result.msgType === 'conflict') {
|
|
263
|
+
holdForConflict();
|
|
264
|
+
setSaveState('conflict', result.msg, result.msgType);
|
|
220
265
|
} else if (result.msgType !== 'skipped') {
|
|
221
266
|
if (!navigator.onLine) {
|
|
222
267
|
setSaveState('offline', result.msg);
|
|
@@ -405,6 +450,30 @@ export function savePageForce(callback = () => {}) {
|
|
|
405
450
|
});
|
|
406
451
|
}
|
|
407
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Keep this tab's version, over the one the host is holding.
|
|
455
|
+
*
|
|
456
|
+
* The only exit from a conflict that keeps what is on screen. It asks the host for
|
|
457
|
+
* the document's current stamp and force-saves with it, so the save that follows
|
|
458
|
+
* carries a value the host will accept. If the host answers with no stamp at all,
|
|
459
|
+
* the save goes out unconditional, which is last write wins, which is what the
|
|
460
|
+
* person just asked for by name.
|
|
461
|
+
*
|
|
462
|
+
* Deliberately not automatic, and deliberately not what a second Cmd+S does. A
|
|
463
|
+
* person pressing Save again has not been shown the other version, and reading
|
|
464
|
+
* that as consent to replace it destroys exactly the copy this capability exists
|
|
465
|
+
* to protect. The other two answers to a conflict need nothing from this library:
|
|
466
|
+
* `location.reload()` takes the host's version, and a page that wants to merge
|
|
467
|
+
* merges into its own DOM and then calls this.
|
|
468
|
+
*
|
|
469
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
470
|
+
* @returns {Promise<{ok: boolean, msg: string, msgType: string}>}
|
|
471
|
+
*/
|
|
472
|
+
export async function saveOverwritingConflict(callback = () => {}) {
|
|
473
|
+
await seedEtag({ fresh: true });
|
|
474
|
+
return savePageForce(callback);
|
|
475
|
+
}
|
|
476
|
+
|
|
408
477
|
/**
|
|
409
478
|
* Fetch HTML from a URL and save it, then reload
|
|
410
479
|
* Emits error event if save fails
|
|
@@ -646,6 +715,16 @@ export function initHyperclaySaveButton() {
|
|
|
646
715
|
export function init() {
|
|
647
716
|
if (!isEditMode) return;
|
|
648
717
|
|
|
718
|
+
// §6's stamp for a page that has never saved. Fired here and never awaited: the
|
|
719
|
+
// request a person feels is the save, and a host with no /_/meta would make every
|
|
720
|
+
// first save wait out a discovery timeout for an answer that was never coming.
|
|
721
|
+
// Until the seed lands the first save goes out unconditional, which is last write
|
|
722
|
+
// wins, which is what every save did before this existed. This is also the only
|
|
723
|
+
// moment the seed can be useful at all: ask for it lazily at the first save and
|
|
724
|
+
// it can never arrive in time for that save, and from the second save onward the
|
|
725
|
+
// first save's own response has already supplied one.
|
|
726
|
+
seedEtag();
|
|
727
|
+
|
|
649
728
|
// Every editable page, not just autosave pages. A manual-save page makes
|
|
650
729
|
// exactly the saves a person asked for, and used to report all of them as
|
|
651
730
|
// background writes because this was installed behind the autosave gate.
|
package/src/core/snapshot.js
CHANGED
|
@@ -109,10 +109,10 @@ export function addDocumentTransform(callback) {
|
|
|
109
109
|
*
|
|
110
110
|
* @returns {HTMLElement} Cloned document element with snapshot hooks applied
|
|
111
111
|
*/
|
|
112
|
-
function clonePreventingOnclone(node) {
|
|
112
|
+
function clonePreventingOnclone(node, deep = true) {
|
|
113
113
|
const prev = window.__preventOnclone;
|
|
114
114
|
window.__preventOnclone = true;
|
|
115
|
-
try { return node.cloneNode(
|
|
115
|
+
try { return node.cloneNode(deep); }
|
|
116
116
|
finally { window.__preventOnclone = prev; }
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -293,7 +293,13 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
293
293
|
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
294
294
|
const clone = captureSnapshot();
|
|
295
295
|
|
|
296
|
-
// Emit for live-sync before any stripping
|
|
296
|
+
// Emit for live-sync before any stripping.
|
|
297
|
+
//
|
|
298
|
+
// A listener gets the very clone this function goes on to serialize into
|
|
299
|
+
// forSave, forComparison and forDirty — that sharing is the point, it saves a
|
|
300
|
+
// second full DOM clone per save. So a listener may READ it and must not write
|
|
301
|
+
// to it: anything it changes lands in the saved bytes and in both baselines,
|
|
302
|
+
// and a baseline the dirty check cannot reproduce warns on close forever.
|
|
297
303
|
if (emitForSync) {
|
|
298
304
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
299
305
|
detail: { documentElement: clone }
|
|
@@ -445,22 +451,33 @@ export function captureForSave({ emitForSync = true } = {}) {
|
|
|
445
451
|
* (Not to be confused with captureBodyForSync below, which is the older
|
|
446
452
|
* body-innerHTML helper and unrelated to the live-sync lane.)
|
|
447
453
|
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
454
|
+
* It must not write to the clone. The caller derives forSave and both comparison
|
|
455
|
+
* baselines from this same object once every listener has returned, so anything
|
|
456
|
+
* left behind lands in the saved bytes and in both baselines.
|
|
457
|
+
*
|
|
458
|
+
* This used to strip the tab-local attributes in place and set them again in a
|
|
459
|
+
* finally, which looked exact and was not: an attribute list is ordered by
|
|
460
|
+
* insertion, so putting a name back appended it. On htmlclay, whose injectAttr
|
|
461
|
+
* splices the token and file id in right after `<html`, every save then installed
|
|
462
|
+
* a baseline whose root tag was ordered differently from the one any later dirty
|
|
463
|
+
* check builds off the live DOM — same names, same values, same length, never
|
|
464
|
+
* equal again. Closing an already-saved document warned every time, and
|
|
465
|
+
* savePageThrottled's "no changes to save" short-circuit never fired.
|
|
466
|
+
*
|
|
467
|
+
* The open tag is serialized from a childless copy instead, so the shared clone is
|
|
468
|
+
* never touched and there is no restore to get wrong. The copy is one element, not
|
|
469
|
+
* the tree.
|
|
451
470
|
*/
|
|
452
471
|
export function serializeForSync(clone) {
|
|
453
|
-
const
|
|
454
|
-
for (const name of TAB_LOCAL_ROOT_ATTRS)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
for (const [name, value] of removed) clone.setAttribute(name, value);
|
|
463
|
-
}
|
|
472
|
+
const bareRoot = clonePreventingOnclone(clone, false);
|
|
473
|
+
for (const name of TAB_LOCAL_ROOT_ATTRS) bareRoot.removeAttribute(name);
|
|
474
|
+
|
|
475
|
+
// Split the end tag off by LENGTH rather than searching for one: an authored
|
|
476
|
+
// attribute value holding "</html>" would fool any indexOf-based split and
|
|
477
|
+
// truncate the broadcast.
|
|
478
|
+
const shell = bareRoot.outerHTML;
|
|
479
|
+
const endTag = `</${bareRoot.localName}>`;
|
|
480
|
+
return shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
|
|
464
481
|
}
|
|
465
482
|
|
|
466
483
|
/**
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// clayjs spells its attributes without a prefix, which is the whole pitch:
|
|
2
|
+
// <h1 editable> reads like HTML rather than like a framework. No attribute in the
|
|
3
|
+
// HTML standard uses these names and no proposal does either, so the risk of the
|
|
4
|
+
// spelling being taken is small. It is not zero, and a saved document hardcodes
|
|
5
|
+
// the attribute and can never be reached to migrate it.
|
|
6
|
+
//
|
|
7
|
+
// These clay- spellings are read everywhere the bare name is read, and are left
|
|
8
|
+
// out of the documentation on purpose. Their only job is to already exist in every
|
|
9
|
+
// 1.x build: if a bare name ever stops being ours, a file written today can be
|
|
10
|
+
// repaired by adding one attribute instead of needing a migration that cannot
|
|
11
|
+
// reach it. An escape hatch added after the collision would be worthless.
|
|
12
|
+
export const PERSIST = ":is([persist], [clay-persist])";
|
|
13
|
+
export const AUTOSAVE = ":is([autosave], [clay-autosave])";
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
import Mutation from './mutation.js';
|
|
29
29
|
import { isEditMode } from '../core/is-edit-mode.js';
|
|
30
30
|
import { STRIP_FROM_DIRTY_CHECK, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
|
|
31
|
+
import { PERSIST } from './attr-aliases.js';
|
|
31
32
|
|
|
32
33
|
let changes = 0;
|
|
33
34
|
let clearedAt = 0;
|
|
34
35
|
let paused = false;
|
|
35
36
|
let started = false;
|
|
36
37
|
|
|
37
|
-
const PERSIST_CONTROLS =
|
|
38
|
+
const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
|
|
38
39
|
|
|
39
40
|
// Regions the loss oracle never sees. The hub feed already skips them, through
|
|
40
41
|
// `require: 'dirty'`, and the input feed has to skip them for the same reason:
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -8,15 +8,31 @@
|
|
|
8
8
|
* peer's copy of them must never be applied.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// The two spellings of the save token. Spec §9 bounds one to per-file and per-tab
|
|
12
|
+
// and makes the host strip it before writing, so it never reaches disk. But §9
|
|
13
|
+
// bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
14
14
|
// browsers, which is the hole this module closes.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
export const
|
|
16
|
+
// Save tokens ONLY. host-attrs.js returns the first name it finds here and puts it
|
|
17
|
+
// straight into the save URL, so anything added to this list becomes a credential
|
|
18
|
+
// in a path. Durable identities go in the list below, never in this one.
|
|
19
|
+
export const SAVE_TOKEN_ATTRS = ["savetoken", "htmlclaytoken"];
|
|
20
|
+
|
|
21
|
+
// Host-injected, but NOT credentials. htmlclayid is htmlclay's durable file
|
|
22
|
+
// identity, stamped on every serve and absent from disk bytes, so it rides in the
|
|
23
|
+
// morph protection below for the same reason a token does: a morph of raw disk
|
|
24
|
+
// content would otherwise strip this tab's copy, and a peer's copy must never be
|
|
25
|
+
// applied.
|
|
26
|
+
//
|
|
27
|
+
// It was previously in the token list, where saveToken() returned it whenever a
|
|
28
|
+
// host minted no token of its own. That is reachable: htmlclay stamps the id on
|
|
29
|
+
// every serve but injects a token only for top-level document loads, and its save
|
|
30
|
+
// route strips only the token, so the id reaches disk. Any such file hosted
|
|
31
|
+
// somewhere tokenless made this library POST to /_/save/{id} with no cookie and
|
|
32
|
+
// hand edit mode to every visitor.
|
|
33
|
+
export const HOST_IDENTITY_ATTRS = ["htmlclayid"];
|
|
34
|
+
|
|
35
|
+
export const HOST_TOKEN_ATTRS = [...SAVE_TOKEN_ATTRS, ...HOST_IDENTITY_ATTRS];
|
|
20
36
|
|
|
21
37
|
// This library's own root state, and this tab's UI truth.
|
|
22
38
|
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
package/src/loader-logic.js
CHANGED
|
@@ -7,6 +7,7 @@ export const CORE_WAVES = {
|
|
|
7
7
|
],
|
|
8
8
|
editOnly: [
|
|
9
9
|
"core/snapshot.js", "core/save-core.js", "core/save.js",
|
|
10
|
+
"core/save-conflict-notice.js",
|
|
10
11
|
"core/unsaved-warning.js", "core/persist.js",
|
|
11
12
|
"core/admin-attrs.js", "core/autosave.js",
|
|
12
13
|
"attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
|
|
@@ -30,6 +31,44 @@ export const PLUGIN_PATHS = {
|
|
|
30
31
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
// One literal import per module the loader can ask for, so a bundler can see the
|
|
35
|
+
// whole graph. The loader used to build each specifier at runtime
|
|
36
|
+
// (`import(base + "/src/" + path)`), which no bundler can follow, and that alone
|
|
37
|
+
// stood between clayjs and the single-file build. A relative specifier resolves
|
|
38
|
+
// against this file, so on clayjs.com the URLs fetched are exactly the ones the
|
|
39
|
+
// computed form produced. The thunks keep every import lazy: nothing evaluates
|
|
40
|
+
// until the loader asks, in the loader's order, which is load-bearing.
|
|
41
|
+
// tests/unit/loader-modules.test.js proves every path in CORE_WAVES and
|
|
42
|
+
// PLUGIN_PATHS has an entry here and that each entry imports the file it names.
|
|
43
|
+
export const MODULES = {
|
|
44
|
+
"core/is-edit-mode.js": () => import("./core/is-edit-mode.js"),
|
|
45
|
+
"lib/region-policy.js": () => import("./lib/region-policy.js"),
|
|
46
|
+
"lib/mutation.js": () => import("./lib/mutation.js"),
|
|
47
|
+
"core/edit-mode.js": () => import("./core/edit-mode.js"),
|
|
48
|
+
"core/snapshot.js": () => import("./core/snapshot.js"),
|
|
49
|
+
"core/save-core.js": () => import("./core/save-core.js"),
|
|
50
|
+
"core/save.js": () => import("./core/save.js"),
|
|
51
|
+
"core/save-conflict-notice.js": () => import("./core/save-conflict-notice.js"),
|
|
52
|
+
"core/unsaved-warning.js": () => import("./core/unsaved-warning.js"),
|
|
53
|
+
"core/persist.js": () => import("./core/persist.js"),
|
|
54
|
+
"core/admin-attrs.js": () => import("./core/admin-attrs.js"),
|
|
55
|
+
"core/autosave.js": () => import("./core/autosave.js"),
|
|
56
|
+
"attrs/save-freeze.js": () => import("./attrs/save-freeze.js"),
|
|
57
|
+
"attrs/onaftersave.js": () => import("./attrs/onaftersave.js"),
|
|
58
|
+
"attrs/refetch-on-save.js": () => import("./attrs/refetch-on-save.js"),
|
|
59
|
+
"lib/cache-bust.js": () => import("./lib/cache-bust.js"),
|
|
60
|
+
"vendor/richclay.vendor.js": () => import("./vendor/richclay.vendor.js"),
|
|
61
|
+
"plugins/indicator.js": () => import("./plugins/indicator.js"),
|
|
62
|
+
"sync/live-sync.js": () => import("./sync/live-sync.js"),
|
|
63
|
+
"plugins/sortable.js": () => import("./plugins/sortable.js"),
|
|
64
|
+
"plugins/undo.js": () => import("./plugins/undo.js"),
|
|
65
|
+
"vendor/hypercms.vendor.js": () => import("./vendor/hypercms.vendor.js"),
|
|
66
|
+
"vendor/quickcrop.vendor.js": () => import("./vendor/quickcrop.vendor.js"),
|
|
67
|
+
"plugins/upload.js": () => import("./plugins/upload.js"),
|
|
68
|
+
"plugins/wire.js": () => import("./plugins/wire.js"),
|
|
69
|
+
"plugins/demo.js": () => import("./plugins/demo.js"),
|
|
70
|
+
};
|
|
71
|
+
|
|
33
72
|
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo"];
|
|
34
73
|
|
|
35
74
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|