@panphora/clayjs 1.6.1 → 1.8.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 +5 -4
- package/dist/clay.standalone.js +9578 -6063
- package/entries/clay-data.js +8 -6
- package/package.json +2 -1
- package/src/core/conflict-notice.js +2 -4
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/page-data.js +321 -0
- package/src/core/save.js +32 -16
- package/src/lib/content-dom.js +3 -3
- package/src/lib/root-attrs.js +14 -0
- package/src/loader-logic.js +2 -0
- package/src/loader.js +7 -2
- package/src/plugins/ai-edit.js +307 -89
- package/src/plugins/wire.js +4 -4
- package/src/sync/conflict-footprints.js +278 -0
- package/src/sync/conflict-revert.js +18 -5
- package/src/sync/conflicts.js +30 -1
- package/src/sync/live-sync.js +331 -38
- package/src/sync/stream.js +17 -4
- package/src/ui/bevel-controls.js +8 -2
- package/src/ui/bevel-dialog.js +2 -4
- package/src/ui/bevel.js +22 -2
- package/src/ui/dialogs.js +1 -1
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/skins/quickcrop.js +1 -1
- package/src/ui/skins/richclay.js +2 -2
- package/src/ui/toast.js +7 -15
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/hypercms.vendor.js +12 -12
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +27 -19
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { hostSupports } from "./host-meta.js";
|
|
2
|
+
import { saveToken } from "./host-attrs.js";
|
|
3
|
+
|
|
4
|
+
const DATA_READ = "data-read";
|
|
5
|
+
const DATA_WRITE = "data-write";
|
|
6
|
+
const WRITE_HOLD_REASON = "Waiting for the page data write to finish";
|
|
7
|
+
const SYNC_WAIT_MS = 12000;
|
|
8
|
+
const HTTP_WAIT_MS = 30000;
|
|
9
|
+
|
|
10
|
+
function refusal(code, message, details) {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
error.details = details;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function responseBody(response) {
|
|
18
|
+
if (typeof response.json === "function") return response.json();
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
return text ? JSON.parse(text) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function responseError(response) {
|
|
24
|
+
let body = {};
|
|
25
|
+
try {
|
|
26
|
+
body = (await responseBody(response)) || {};
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
const message = body.message || body.error || response.statusText || `Request failed (${response.status})`;
|
|
30
|
+
const error = new Error(message);
|
|
31
|
+
error.status = response.status;
|
|
32
|
+
error.error = body.error;
|
|
33
|
+
error.details = body.details;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function responseEtag(response) {
|
|
38
|
+
const value = response.headers?.get?.("ETag");
|
|
39
|
+
return typeof value === "string" && value ? value : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dataUrl(rules, location) {
|
|
43
|
+
const base = `${location.origin}${location.pathname}`;
|
|
44
|
+
if (rules !== undefined) return `${base}?data=${encodeURIComponent(JSON.stringify(rules))}`;
|
|
45
|
+
return `${location.origin}/_/api${location.pathname}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function connected(sync) {
|
|
49
|
+
return !!sync && !sync.isDestroyed && sync.sse?.readyState === 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function syncWaiter(eventTarget, timeoutMs) {
|
|
53
|
+
const seen = [];
|
|
54
|
+
let expected = null;
|
|
55
|
+
let resolvePending = null;
|
|
56
|
+
let timer = null;
|
|
57
|
+
|
|
58
|
+
const matchesExact = (detail) =>
|
|
59
|
+
detail?.source === "disk" && (expected === null || detail.etag === expected);
|
|
60
|
+
|
|
61
|
+
const matchesPending = (detail) =>
|
|
62
|
+
detail?.source === "disk" && (detail.etag == null || expected === null || detail.etag === expected);
|
|
63
|
+
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
eventTarget.removeEventListener("clay:sync-applied", onApplied);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const onApplied = (event) => {
|
|
70
|
+
seen.push(event.detail);
|
|
71
|
+
if (!resolvePending || !matchesPending(event.detail)) return;
|
|
72
|
+
const resolve = resolvePending;
|
|
73
|
+
resolvePending = null;
|
|
74
|
+
cleanup();
|
|
75
|
+
resolve(true);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
eventTarget.addEventListener("clay:sync-applied", onApplied);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
waitFor(etag) {
|
|
82
|
+
expected = etag;
|
|
83
|
+
if (seen.some(matchesExact)) {
|
|
84
|
+
cleanup();
|
|
85
|
+
return Promise.resolve(true);
|
|
86
|
+
}
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
resolvePending = resolve;
|
|
89
|
+
timer = setTimeout(() => {
|
|
90
|
+
resolvePending = null;
|
|
91
|
+
cleanup();
|
|
92
|
+
resolve(false);
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
cancel: cleanup,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function httpDeadline(timeoutMs) {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
let rejectDeadline;
|
|
103
|
+
const expired = new Promise((_, reject) => {
|
|
104
|
+
rejectDeadline = reject;
|
|
105
|
+
});
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
const error = new Error("The page data write timed out.");
|
|
108
|
+
error.name = "TimeoutError";
|
|
109
|
+
controller.abort(error);
|
|
110
|
+
rejectDeadline(error);
|
|
111
|
+
}, timeoutMs);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
waitFor(promise) {
|
|
116
|
+
return Promise.race([promise, expired]);
|
|
117
|
+
},
|
|
118
|
+
cancel() {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function defaultWriteModules() {
|
|
125
|
+
const [save, saveCore, etag, warning, editMode] = await Promise.all([
|
|
126
|
+
import("./save.js"),
|
|
127
|
+
import("./save-core.js"),
|
|
128
|
+
import("./etag.js"),
|
|
129
|
+
import("./unsaved-warning.js"),
|
|
130
|
+
import("./is-edit-mode.js"),
|
|
131
|
+
]);
|
|
132
|
+
return { ...save, ...saveCore, ...etag, ...warning, ...editMode };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createPageDataApi({
|
|
136
|
+
supports = hostSupports,
|
|
137
|
+
token = saveToken,
|
|
138
|
+
fetchImpl = (...args) => fetch(...args),
|
|
139
|
+
getLocation = () => window.location,
|
|
140
|
+
eventTarget = document,
|
|
141
|
+
loadWriteModules = defaultWriteModules,
|
|
142
|
+
syncTimeoutMs = SYNC_WAIT_MS,
|
|
143
|
+
httpTimeoutMs = HTTP_WAIT_MS,
|
|
144
|
+
} = {}) {
|
|
145
|
+
let liveSync = null;
|
|
146
|
+
let writeRunning = false;
|
|
147
|
+
let writeOutcomeUnknown = false;
|
|
148
|
+
|
|
149
|
+
function setLiveSync(sync) {
|
|
150
|
+
liveSync = sync;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readData(rules) {
|
|
154
|
+
if (!(await supports(DATA_READ))) {
|
|
155
|
+
throw refusal("DataReadUnsupported", "This host does not support reading page data.");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(dataUrl(rules, getLocation()), {
|
|
161
|
+
method: "GET",
|
|
162
|
+
credentials: "same-origin",
|
|
163
|
+
cache: "no-store",
|
|
164
|
+
redirect: "error",
|
|
165
|
+
});
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
throw Object.assign(refusal("DataReadFailed", cause?.message || "Could not read page data."), { cause });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!response.ok) throw await responseError(response);
|
|
171
|
+
return { data: await responseBody(response), etag: responseEtag(response) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertWriteState(modules, { checkHold = true } = {}) {
|
|
175
|
+
if (writeOutcomeUnknown) {
|
|
176
|
+
throw refusal("DataWriteOutcomeUnknown", "A previous page data write has an unknown outcome. Reload before writing again.");
|
|
177
|
+
}
|
|
178
|
+
if (writeRunning) {
|
|
179
|
+
throw refusal("DataWriteInProgress", "Another page data write is already running.");
|
|
180
|
+
}
|
|
181
|
+
if (modules.isSaveInProgress()) {
|
|
182
|
+
throw refusal("SaveInProgress", "The page is already being saved.");
|
|
183
|
+
}
|
|
184
|
+
if (modules.saveFateIsUnknown()) {
|
|
185
|
+
throw refusal("SaveOutcomeUnknown", "The page's last save has an unknown outcome.");
|
|
186
|
+
}
|
|
187
|
+
if (checkHold && (modules.savesHeld() || modules.isSaveConflicted())) {
|
|
188
|
+
throw refusal("SavesHeld", "The page cannot write data while another save hold is active.");
|
|
189
|
+
}
|
|
190
|
+
if (modules.hasUnsavedChanges()) {
|
|
191
|
+
throw refusal("UnsavedChanges", "Save or discard the page's unsaved changes before writing data.");
|
|
192
|
+
}
|
|
193
|
+
if (!connected(liveSync)) {
|
|
194
|
+
throw refusal("SyncRequired", "Page data writes require the sync plugin to be connected.");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function waitForBaseline(modules) {
|
|
199
|
+
if (modules.baselineSettled()) return;
|
|
200
|
+
await new Promise((resolve) => eventTarget.addEventListener("clay:baseline-settled", resolve, { once: true }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function writeData(values, { rules, ifMatch } = {}) {
|
|
204
|
+
if (!(await supports(DATA_WRITE))) {
|
|
205
|
+
throw refusal("DataWriteUnsupported", "This host does not support writing page data.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const modules = await loadWriteModules();
|
|
209
|
+
if (!modules.isEditMode) {
|
|
210
|
+
throw refusal("DataWriteUnavailable", "Page data writes require an editable page.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await waitForBaseline(modules);
|
|
214
|
+
assertWriteState(modules);
|
|
215
|
+
|
|
216
|
+
let match = ifMatch;
|
|
217
|
+
if (match == null) match = modules.lastSeenEtag() || await modules.seedEtag();
|
|
218
|
+
if (typeof match !== "string" || match === "") {
|
|
219
|
+
throw refusal("DataWriteVersionRequired", "Page data writes require a trustworthy document ETag.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
assertWriteState(modules);
|
|
223
|
+
writeRunning = true;
|
|
224
|
+
modules.holdAllSaves(WRITE_HOLD_REASON);
|
|
225
|
+
|
|
226
|
+
let waiter = null;
|
|
227
|
+
try {
|
|
228
|
+
if (modules.isSaveInProgress()) {
|
|
229
|
+
throw refusal("SaveInProgress", "The page is already being saved.");
|
|
230
|
+
}
|
|
231
|
+
if (modules.saveFateIsUnknown()) {
|
|
232
|
+
throw refusal("SaveOutcomeUnknown", "The page's last save has an unknown outcome.");
|
|
233
|
+
}
|
|
234
|
+
if (modules.isSaveConflicted()) {
|
|
235
|
+
throw refusal("SavesHeld", "The page cannot write data while another save hold is active.");
|
|
236
|
+
}
|
|
237
|
+
if (modules.hasUnsavedChanges()) {
|
|
238
|
+
throw refusal("UnsavedChanges", "Save or discard the page's unsaved changes before writing data.");
|
|
239
|
+
}
|
|
240
|
+
if (!connected(liveSync)) {
|
|
241
|
+
throw refusal("SyncRequired", "Page data writes require the sync plugin to be connected.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const headers = {
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
"If-Match": match,
|
|
247
|
+
};
|
|
248
|
+
const currentToken = token();
|
|
249
|
+
if (currentToken) headers["Save-Token"] = currentToken;
|
|
250
|
+
|
|
251
|
+
const body = JSON.stringify(values);
|
|
252
|
+
waiter = syncWaiter(eventTarget, syncTimeoutMs);
|
|
253
|
+
const deadline = httpDeadline(httpTimeoutMs);
|
|
254
|
+
|
|
255
|
+
let response;
|
|
256
|
+
try {
|
|
257
|
+
response = await deadline.waitFor(fetchImpl(dataUrl(rules, getLocation()), {
|
|
258
|
+
method: "POST",
|
|
259
|
+
credentials: "same-origin",
|
|
260
|
+
redirect: "error",
|
|
261
|
+
headers,
|
|
262
|
+
body,
|
|
263
|
+
signal: deadline.signal,
|
|
264
|
+
}));
|
|
265
|
+
} catch (cause) {
|
|
266
|
+
deadline.cancel();
|
|
267
|
+
writeOutcomeUnknown = true;
|
|
268
|
+
throw Object.assign(
|
|
269
|
+
refusal("DataWriteOutcomeUnknown", "The page data write has an unknown outcome. Do not retry automatically."),
|
|
270
|
+
{ cause, unknownOutcome: true }
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let data;
|
|
275
|
+
try {
|
|
276
|
+
if (!response.ok) throw await deadline.waitFor(responseError(response));
|
|
277
|
+
data = await deadline.waitFor(responseBody(response));
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
if (cause?.status) throw cause;
|
|
280
|
+
writeOutcomeUnknown = true;
|
|
281
|
+
throw Object.assign(
|
|
282
|
+
refusal("DataWriteOutcomeUnknown", "The page data was written, but its response could not be read."),
|
|
283
|
+
{ cause, unknownOutcome: true }
|
|
284
|
+
);
|
|
285
|
+
} finally {
|
|
286
|
+
deadline.cancel();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const etag = responseEtag(response);
|
|
290
|
+
if (etag && etag === modules.lastSeenEtag()) {
|
|
291
|
+
waiter.cancel();
|
|
292
|
+
waiter = null;
|
|
293
|
+
return { data, etag };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const applied = await waiter.waitFor(etag);
|
|
297
|
+
waiter = null;
|
|
298
|
+
if (!applied) {
|
|
299
|
+
return {
|
|
300
|
+
data,
|
|
301
|
+
etag,
|
|
302
|
+
pageUpdatePending: true,
|
|
303
|
+
message: "Written, page update pending.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { data, etag };
|
|
307
|
+
} finally {
|
|
308
|
+
waiter?.cancel();
|
|
309
|
+
writeRunning = false;
|
|
310
|
+
modules.releaseAllSaves({ reason: WRITE_HOLD_REASON });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { readData, writeData, setLiveSync };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const pageData = createPageDataApi();
|
|
318
|
+
|
|
319
|
+
export const readData = pageData.readData;
|
|
320
|
+
export const writeData = pageData.writeData;
|
|
321
|
+
export const setPageDataLiveSync = pageData.setLiveSync;
|
package/src/core/save.js
CHANGED
|
@@ -26,7 +26,7 @@ import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
|
26
26
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
27
27
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
28
28
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
29
|
-
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
29
|
+
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
|
|
30
30
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
31
31
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
32
32
|
// A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
|
|
@@ -51,16 +51,24 @@ addDocumentTransform(clone => {
|
|
|
51
51
|
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
// Keep the host's save token out of the saved bytes, both spellings
|
|
54
|
+
// Keep the host's save token out of the saved bytes, both spellings, and the response
|
|
55
|
+
// metadata with it.
|
|
55
56
|
//
|
|
56
|
-
//
|
|
57
|
-
// every save body on arrival, so it never reached disk anyway. Sending it made the
|
|
57
|
+
// A save token is a credential for this response, never file content: htmlclay strips it
|
|
58
|
+
// from every save body on arrival, so it never reached disk anyway. Sending it made the
|
|
58
59
|
// source map, which models the bytes a save sent, describe a root tag one attribute
|
|
59
60
|
// longer than the file, and every offset after it was off by that much. The save
|
|
60
61
|
// itself is authorized by the URL, which reads the token from the live page. The
|
|
61
62
|
// document id is NOT stripped: htmlclay keeps it on disk on purpose.
|
|
63
|
+
//
|
|
64
|
+
// `documentetag` is the same kind of thing for a different reason. It names the version
|
|
65
|
+
// of the response this tab loaded, and it is replaced on every serve, so writing it to
|
|
66
|
+
// disk would freeze one response's stamp into the file and give the next reader a
|
|
67
|
+
// provenance claim about bytes nobody built a response from. Root only, like the rest of
|
|
68
|
+
// this transform: the same name on a child is the author's, and stripping those would
|
|
69
|
+
// delete page content.
|
|
62
70
|
addDocumentTransform(clone => {
|
|
63
|
-
for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS]) clone.removeAttribute(name);
|
|
71
|
+
for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS, ...HOST_RESPONSE_ATTRS]) clone.removeAttribute(name);
|
|
64
72
|
});
|
|
65
73
|
|
|
66
74
|
// ============================================
|
|
@@ -214,23 +222,26 @@ export function resumeAutosave() {
|
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
// ============================================
|
|
217
|
-
// THE
|
|
225
|
+
// THE ALL-SAVE HOLD
|
|
218
226
|
// ============================================
|
|
219
227
|
//
|
|
220
|
-
// While an
|
|
221
|
-
//
|
|
222
|
-
// so for that window no save goes out at all. A save asked for meanwhile is
|
|
223
|
-
// remembered and runs on release; Keep releases without the replay because it saves
|
|
224
|
-
// right after.
|
|
228
|
+
// While an operation owns the page's persisted state, no save goes out at all. A
|
|
229
|
+
// save asked for meanwhile is remembered and runs on release.
|
|
225
230
|
let previewHold = 0;
|
|
226
231
|
let previewMissed = false;
|
|
232
|
+
const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
|
|
233
|
+
const holdReasons = new Map();
|
|
227
234
|
|
|
228
|
-
export function holdAllSaves() {
|
|
235
|
+
export function holdAllSaves(reason = PREVIEW_HOLD_REASON) {
|
|
229
236
|
previewHold++;
|
|
237
|
+
holdReasons.set(reason, (holdReasons.get(reason) || 0) + 1);
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
export function releaseAllSaves({ replay = true } = {}) {
|
|
233
|
-
|
|
240
|
+
export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON } = {}) {
|
|
241
|
+
const count = holdReasons.get(reason) || 0;
|
|
242
|
+
if (count === 0) return;
|
|
243
|
+
if (count === 1) holdReasons.delete(reason);
|
|
244
|
+
else holdReasons.set(reason, count - 1);
|
|
234
245
|
previewHold--;
|
|
235
246
|
if (previewHold > 0) return;
|
|
236
247
|
const missed = previewMissed;
|
|
@@ -242,11 +253,16 @@ export function savesHeld() {
|
|
|
242
253
|
return previewHold > 0;
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
export function saveHoldReason() {
|
|
257
|
+
if (previewHold === 0) return null;
|
|
258
|
+
return holdReasons.keys().next().value || PREVIEW_HOLD_REASON;
|
|
259
|
+
}
|
|
260
|
+
|
|
245
261
|
function heldForPreview(callback, resolve) {
|
|
246
262
|
if (previewHold === 0) return false;
|
|
247
263
|
previewMissed = true;
|
|
248
264
|
clearExplicitSave();
|
|
249
|
-
const skipped = skipped_(
|
|
265
|
+
const skipped = skipped_(saveHoldReason());
|
|
250
266
|
callback(skipped);
|
|
251
267
|
resolve(skipped);
|
|
252
268
|
return true;
|
|
@@ -854,7 +870,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
854
870
|
|
|
855
871
|
if (previewHold > 0) {
|
|
856
872
|
previewMissed = true;
|
|
857
|
-
const skipped = skipped_(
|
|
873
|
+
const skipped = skipped_(saveHoldReason());
|
|
858
874
|
callback(skipped);
|
|
859
875
|
return Promise.resolve(skipped);
|
|
860
876
|
}
|
package/src/lib/content-dom.js
CHANGED
|
@@ -5,9 +5,9 @@ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visite
|
|
|
5
5
|
function copyControlState(source, copy) {
|
|
6
6
|
if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
|
|
7
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 ('
|
|
10
|
-
if (
|
|
8
|
+
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
|
|
9
|
+
if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
|
|
10
|
+
if (source.tagName === 'OPTION') copy.selected = source.selected
|
|
11
11
|
if (source.tagName === 'SELECT') {
|
|
12
12
|
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
13
13
|
}
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -63,6 +63,19 @@ export const LEGACY_SAVE_TOKEN_ATTRS = ["htmlclaytoken"];
|
|
|
63
63
|
// what fixed that, and the split holds whatever token spellings are read above.
|
|
64
64
|
export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
|
|
65
65
|
|
|
66
|
+
// Response metadata, and neither of the other two. `documentetag` names the version
|
|
67
|
+
// of the bytes the host built THIS response from, which is the one thing neither the
|
|
68
|
+
// token nor the identity can say: a token grants a capability and an identity names a
|
|
69
|
+
// file, while the stamp names a revision. It rides on the response only, so an
|
|
70
|
+
// incoming morph must never apply a peer's copy and an outgoing sync must never carry
|
|
71
|
+
// this tab's. Read once, at module evaluation, by host-attrs.js — never re-read from
|
|
72
|
+
// the live root, which a morph may since have rewritten.
|
|
73
|
+
//
|
|
74
|
+
// Kept out of SAVE_TOKEN_ATTRS on purpose: host-attrs.js returns the first name it
|
|
75
|
+
// finds in that list straight into the save URL, and a version stamp is not a
|
|
76
|
+
// credential.
|
|
77
|
+
export const HOST_RESPONSE_ATTRS = ["documentetag"];
|
|
78
|
+
|
|
66
79
|
// What a host may have injected, and therefore what has to be stripped before a save
|
|
67
80
|
// and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
|
|
68
81
|
// spelling is still injected by every htmlclay, so it still has to be stripped, whether
|
|
@@ -87,6 +100,7 @@ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
|
87
100
|
// can no longer save at all.
|
|
88
101
|
export const TAB_LOCAL_ROOT_ATTRS = new Set([
|
|
89
102
|
...HOST_TOKEN_ATTRS,
|
|
103
|
+
...HOST_RESPONSE_ATTRS,
|
|
90
104
|
...ROOT_LIBRARY_ATTRS,
|
|
91
105
|
]);
|
|
92
106
|
|
package/src/loader-logic.js
CHANGED
|
@@ -8,6 +8,7 @@ export const CORE_WAVES = {
|
|
|
8
8
|
// is exactly the case where edit mode is off, so the editOnly wave would never
|
|
9
9
|
// reach it. It draws nothing on any other page.
|
|
10
10
|
"core/stale-host-notice.js",
|
|
11
|
+
"core/page-data.js",
|
|
11
12
|
],
|
|
12
13
|
editOnly: [
|
|
13
14
|
"vendor/hyper-morph.vendor.js",
|
|
@@ -66,6 +67,7 @@ export const MODULES = {
|
|
|
66
67
|
"lib/mutation.js": () => import("./lib/mutation.js"),
|
|
67
68
|
"core/edit-mode.js": () => import("./core/edit-mode.js"),
|
|
68
69
|
"core/stale-host-notice.js": () => import("./core/stale-host-notice.js"),
|
|
70
|
+
"core/page-data.js": () => import("./core/page-data.js"),
|
|
69
71
|
"core/snapshot.js": () => import("./core/snapshot.js"),
|
|
70
72
|
"core/save-core.js": () => import("./core/save-core.js"),
|
|
71
73
|
"core/save.js": () => import("./core/save.js"),
|
package/src/loader.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function boot(base, params, readyResolve) {
|
|
|
50
50
|
continue;
|
|
51
51
|
}
|
|
52
52
|
loaded[path] = mod;
|
|
53
|
-
attachPluginMember(path, mod);
|
|
53
|
+
attachPluginMember(path, mod, loaded); // immediately, not after the loop: hypercms's ?cms=true
|
|
54
54
|
// auto-open runs as a microtask queued during ITS evaluation
|
|
55
55
|
// (before boot resumes) and reads clay.RichClay and clay.undo,
|
|
56
56
|
// which earlier plugins must have attached by then
|
|
@@ -83,6 +83,10 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
83
83
|
const save = loaded["core/save.js"];
|
|
84
84
|
const cacheBustMod = loaded["lib/cache-bust.js"];
|
|
85
85
|
const hyperMorph = loaded["vendor/hyper-morph.vendor.js"];
|
|
86
|
+
const pageData = loaded["core/page-data.js"];
|
|
87
|
+
|
|
88
|
+
clay.readData = pageData.readData;
|
|
89
|
+
clay.writeData = pageData.writeData;
|
|
86
90
|
|
|
87
91
|
if (save) {
|
|
88
92
|
const saveFn = save.savePage || save.default;
|
|
@@ -107,7 +111,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
107
111
|
if (hyperMorph) clay.morph = hyperMorph.morph;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
function attachPluginMember(path, mod) {
|
|
114
|
+
function attachPluginMember(path, mod, loaded) {
|
|
111
115
|
const clay = window.clay;
|
|
112
116
|
|
|
113
117
|
if (path === "plugins/undo.js") {
|
|
@@ -115,6 +119,7 @@ function attachPluginMember(path, mod) {
|
|
|
115
119
|
} else if (path === "sync/live-sync.js") {
|
|
116
120
|
clay.morph = mod.morph;
|
|
117
121
|
clay.conflicts = mod.conflicts;
|
|
122
|
+
loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
|
|
118
123
|
} else if (path === "plugins/cms.js") {
|
|
119
124
|
clay.cms = mod.cms || mod.default;
|
|
120
125
|
} else if (path === "plugins/upload.js") {
|