@panphora/clayjs 1.0.0 → 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 +23 -0
- package/dist/clay.standalone.js +22030 -0
- package/entries/clay-data.js +1 -1
- package/package.json +9 -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/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/root-attrs.js +23 -7
- package/src/loader-logic.js +39 -0
- package/src/loader.js +11 -5
- 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/lib/load-vendor-script.js +0 -57
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
|
/**
|
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
|
package/src/loader.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { resolveModules } from "./loader-logic.js";
|
|
1
|
+
import { resolveModules, MODULES } from "./loader-logic.js";
|
|
2
2
|
import onDomReady from "./lib/dom-ready.js";
|
|
3
3
|
|
|
4
4
|
function domReady() {
|
|
5
5
|
return new Promise((resolve) => onDomReady(resolve));
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// `base` has been unused since 1.1.0 (the loader imports through MODULES) and
|
|
9
|
+
// stays anyway: /v1/clay.js and /v1/src/loader.js are cached independently, so
|
|
10
|
+
// for a while after a deploy a browser can pair either file with the other's
|
|
11
|
+
// previous release. A call shape that differs between them leaves the page with
|
|
12
|
+
// no clayjs at all.
|
|
8
13
|
export async function boot(base, params, readyResolve) {
|
|
9
14
|
await domReady(); // Mutation observes document.body
|
|
10
15
|
// unconditionally, and a <head> placement
|
|
11
16
|
// would otherwise observe null
|
|
12
17
|
|
|
13
|
-
const editMode = await
|
|
18
|
+
const editMode = await MODULES["core/is-edit-mode.js"]();
|
|
14
19
|
const { isEditMode, isOwner } = editMode;
|
|
15
20
|
|
|
16
21
|
// richclay's vendor build detects edit mode via this legacy global; set it
|
|
@@ -19,13 +24,13 @@ export async function boot(base, params, readyResolve) {
|
|
|
19
24
|
// and a conflicting leftover would make richclay disable itself.
|
|
20
25
|
window.__hyperclayEditMode = isEditMode;
|
|
21
26
|
|
|
22
|
-
const regionPolicy = await
|
|
27
|
+
const regionPolicy = await MODULES["lib/region-policy.js"]();
|
|
23
28
|
|
|
24
29
|
const plan = resolveModules(params, isEditMode);
|
|
25
30
|
const loaded = {};
|
|
26
31
|
|
|
27
32
|
for (const path of plan.core) {
|
|
28
|
-
loaded[path] = await
|
|
33
|
+
loaded[path] = await MODULES[path](); // sequential: order is load-bearing
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay MUST be assembled
|
|
@@ -39,7 +44,7 @@ export async function boot(base, params, readyResolve) {
|
|
|
39
44
|
// `ready` exports, never their evaluation.)
|
|
40
45
|
let mod;
|
|
41
46
|
try {
|
|
42
|
-
mod = await
|
|
47
|
+
mod = await MODULES[path]();
|
|
43
48
|
} catch (err) {
|
|
44
49
|
console.error(`clayjs: plugin "${path}" failed to load, continuing without it:`, err);
|
|
45
50
|
continue;
|
|
@@ -81,6 +86,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
81
86
|
if (save) {
|
|
82
87
|
const saveFn = save.savePage || save.default;
|
|
83
88
|
saveFn.force = save.savePageForce;
|
|
89
|
+
saveFn.overwrite = save.saveOverwritingConflict;
|
|
84
90
|
clay.save = saveFn;
|
|
85
91
|
}
|
|
86
92
|
if (snapshot) {
|
package/src/plugins/indicator.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
3
|
|
|
4
|
+
// No 'conflict' here on purpose. core/save-conflict-notice.js owns that state now,
|
|
5
|
+
// and it ships in every document rather than only the ones that turned this chip on.
|
|
6
|
+
// Both listen to clay:save-conflict, so keeping a label here put a chip in the corner
|
|
7
|
+
// saying the same thing as the bar at the same moment. Dropping it from this side
|
|
8
|
+
// leaves core unaware that this plugin exists, which is the direction that
|
|
9
|
+
// dependency has to point.
|
|
4
10
|
const LABELS = {
|
|
5
11
|
saving: "Saving…",
|
|
6
12
|
saved: "Saved",
|
|
@@ -8,6 +14,11 @@ const LABELS = {
|
|
|
8
14
|
offline: "Offline, not saved",
|
|
9
15
|
};
|
|
10
16
|
|
|
17
|
+
// States that stay on screen instead of fading, because 'saving' is still in flight.
|
|
18
|
+
const STICKY = new Set(["saving"]);
|
|
19
|
+
|
|
20
|
+
const ALARMING = new Set(["error", "offline"]);
|
|
21
|
+
|
|
11
22
|
let el = null;
|
|
12
23
|
let hideTimer = null;
|
|
13
24
|
|
|
@@ -34,11 +45,11 @@ function show(state) {
|
|
|
34
45
|
const node = ensure();
|
|
35
46
|
node.textContent = LABELS[state];
|
|
36
47
|
node.dataset.state = state;
|
|
37
|
-
node.style.background = state
|
|
48
|
+
node.style.background = ALARMING.has(state)
|
|
38
49
|
? "var(--clay-indicator-error-bg,#7a3b28)" : "var(--clay-indicator-bg,#2e2b27)";
|
|
39
50
|
node.style.opacity = "1";
|
|
40
51
|
clearTimeout(hideTimer);
|
|
41
|
-
if (state
|
|
52
|
+
if (!STICKY.has(state)) hideTimer = setTimeout(() => { node.style.opacity = "0"; }, 2200);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
function init() {
|
package/src/plugins/sortable.js
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
18
18
|
import Mutation from "../lib/mutation.js";
|
|
19
|
-
import { getVendorUrl } from "../lib/load-vendor-script.js";
|
|
20
19
|
|
|
21
20
|
function makeSortable(sortableElem, Sortable) {
|
|
22
21
|
let options = {};
|
|
@@ -89,10 +88,11 @@ function makeSortable(sortableElem, Sortable) {
|
|
|
89
88
|
async function init() {
|
|
90
89
|
if (!isEditMode) return;
|
|
91
90
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
91
|
+
// Sortable's UMD header picks its target at run time: window.Sortable when no
|
|
92
|
+
// module system is present (a module load, and the single-file build), a
|
|
93
|
+
// default export when a bundler hands it one. Cover both.
|
|
94
|
+
const mod = await import('../vendor/Sortable.vendor.js');
|
|
95
|
+
const Sortable = window.Sortable || mod.default;
|
|
96
96
|
|
|
97
97
|
// Set up sortable on page load
|
|
98
98
|
document.querySelectorAll('[sortable]').forEach(el => makeSortable(el, Sortable));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* clayjs standalone: the entry of the single-file build. https://clayjs.com/offline
|
|
2
|
+
|
|
3
|
+
Twin of entries/clay.js. That bootstrap derives a base URL from its own script
|
|
4
|
+
tag and imports the loader at runtime; this one is bundled together with the
|
|
5
|
+
loader and every module it can ask for (see MODULES in loader-logic.js), so
|
|
6
|
+
`plugins=` decides what runs, never what downloads. The bootstrap logic below
|
|
7
|
+
(window.clay, `ready`, `__booted`, the retry path) is the same code as clay.js
|
|
8
|
+
and must stay in step with it; tests/unit/bootstrap-twins.test.js compares the
|
|
9
|
+
shared pieces. */
|
|
10
|
+
import { boot } from "./loader.js";
|
|
11
|
+
import onDomReady from "./lib/dom-ready.js";
|
|
12
|
+
|
|
13
|
+
(function () {
|
|
14
|
+
// Suppress every vendored bundle's window auto-export; the loader assembles
|
|
15
|
+
// window.clay explicitly.
|
|
16
|
+
window.__hyperclayNoAutoExport = true;
|
|
17
|
+
|
|
18
|
+
// Merge into any window.clay a satellite already created; never replace it.
|
|
19
|
+
var clay = window.clay = window.clay || {};
|
|
20
|
+
// A second tag is a no-op: the original boot's `ready` promise survives.
|
|
21
|
+
// A FAILED boot resets the sentinel so a corrected tag can retry; the retry
|
|
22
|
+
// resolves the ORIGINAL `ready` promise via the stashed resolver.
|
|
23
|
+
if (clay.__booted) return;
|
|
24
|
+
|
|
25
|
+
function mintReady() {
|
|
26
|
+
clay.ready = new Promise(function (res, rej) {
|
|
27
|
+
clay.__readyResolve = res;
|
|
28
|
+
clay.__readyReject = rej;
|
|
29
|
+
});
|
|
30
|
+
// Nobody may be awaiting a failed boot's promise, and an unhandled rejection
|
|
31
|
+
// in the console is noise on top of the error we already logged.
|
|
32
|
+
clay.ready.catch(function () {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!clay.ready) mintReady();
|
|
36
|
+
|
|
37
|
+
// clay.js needs its own URL to find the loader; this file needs it only for the
|
|
38
|
+
// query string, so pasted inline into the page it boots with the defaults.
|
|
39
|
+
var script = document.currentScript;
|
|
40
|
+
var params = script && script.src ? new URL(script.src, location.href).searchParams : new URLSearchParams();
|
|
41
|
+
clay.__booted = true;
|
|
42
|
+
|
|
43
|
+
function domReady() {
|
|
44
|
+
return new Promise(function (r) { onDomReady(r); });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The module satellites, on the terms of their own entry scripts: each is a
|
|
48
|
+
// promise on clay.loaded, the ones that touch document.body wait for DOM
|
|
49
|
+
// ready, and events waits for dom because [onrender] handlers reach for its
|
|
50
|
+
// element helpers the moment events lands.
|
|
51
|
+
function satellite(name, promise) {
|
|
52
|
+
var p = promise.catch(function (err) {
|
|
53
|
+
console.error("clayjs: " + name + " failed to load:", err);
|
|
54
|
+
throw err;
|
|
55
|
+
});
|
|
56
|
+
// Mark handled: a failed satellite must not emit an unhandled rejection.
|
|
57
|
+
// Consumers who await clay.loaded[name] still get the error.
|
|
58
|
+
p.catch(function () {});
|
|
59
|
+
clay.loaded[name] = p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clay.loaded = clay.loaded || {};
|
|
63
|
+
// The two generated satellites are classic scripts that assemble themselves
|
|
64
|
+
// onto window.clay when they run, and they carry no guard of their own, so
|
|
65
|
+
// they run here, behind the sentinel, and not as hoisted static imports: a
|
|
66
|
+
// duplicate tag would otherwise mount a second Sap runtime on the page. For
|
|
67
|
+
// the same reason a tag that already registered one (a leftover
|
|
68
|
+
// <script src="sap.js"> above this one) wins, and the copy in here stays idle.
|
|
69
|
+
if (!clay.loaded.data) satellite("data", import("../entries/clay-data.js"));
|
|
70
|
+
if (!clay.loaded.sap) satellite("sap", import("../entries/sap.js"));
|
|
71
|
+
satellite("dom", import("./dom/dom-helpers.js"));
|
|
72
|
+
satellite("utils", import("./utils/index.js"));
|
|
73
|
+
satellite("internals", import("./internals/index.js"));
|
|
74
|
+
satellite("all", import("./dom/all.js").then(function (m) {
|
|
75
|
+
window.All = m.default; // interop carve-out, as in entries/all.js
|
|
76
|
+
clay.All = m.default;
|
|
77
|
+
return m.default;
|
|
78
|
+
}));
|
|
79
|
+
satellite("ui", domReady().then(function () { return import("./ui/index.js"); }));
|
|
80
|
+
satellite("options", domReady().then(function () { return import("./options/options.js"); }));
|
|
81
|
+
satellite("events", domReady()
|
|
82
|
+
.then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
|
|
83
|
+
.then(function () { return import("./events/index.js"); }));
|
|
84
|
+
|
|
85
|
+
// The first argument is the loader's unused `base`; see boot() in loader.js.
|
|
86
|
+
boot(null, params, clay.__readyResolve).catch(function (err) {
|
|
87
|
+
clay.__booted = false;
|
|
88
|
+
console.error("clayjs failed to load:", err);
|
|
89
|
+
// Settle the promise so `await clay.ready` fails loudly instead of hanging,
|
|
90
|
+
// then mint a fresh one for a corrected retry tag. See entries/clay.js. A
|
|
91
|
+
// retry re-evaluates this whole file, satellites included; the path is
|
|
92
|
+
// reachable only when a bundled module throws on evaluation, which is a
|
|
93
|
+
// clayjs bug, not a page condition.
|
|
94
|
+
var reject = clay.__readyReject;
|
|
95
|
+
mintReady();
|
|
96
|
+
if (reject) reject(err);
|
|
97
|
+
});
|
|
98
|
+
})();
|
package/src/sync/live-sync.js
CHANGED
|
@@ -44,6 +44,7 @@ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from
|
|
|
44
44
|
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
45
|
import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
|
|
46
46
|
import { hostMeta } from '../core/host-meta.js';
|
|
47
|
+
import { recordEtag, seedEtag } from '../core/etag.js';
|
|
47
48
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
48
49
|
|
|
49
50
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
@@ -517,11 +518,50 @@ class LiveSync {
|
|
|
517
518
|
const key = lane === 'live' ? '_heldLive' : '_heldExt';
|
|
518
519
|
if (this[key] === isHeld) return;
|
|
519
520
|
this[key] = isHeld;
|
|
521
|
+
// A hold that clears leaves this tab back in step, but every stamp frame that
|
|
522
|
+
// arrived while it was held was refused and nothing re-sends them: stamps are
|
|
523
|
+
// broadcast on a save, not on a merge. So the tab would go on holding a stamp
|
|
524
|
+
// from before the hold and have its next save refused over a conflict that has
|
|
525
|
+
// already resolved itself, which is this notice crying wolf. Ask the host for
|
|
526
|
+
// the current one instead. Only once BOTH lanes are clear, because one lane
|
|
527
|
+
// resuming while the other still holds means the tab is still behind.
|
|
528
|
+
if (!isHeld && !this._heldLive && !this._heldExt) {
|
|
529
|
+
seedEtag({ fresh: true, clearIfMissing: false });
|
|
530
|
+
}
|
|
520
531
|
document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
|
|
521
532
|
detail: { lane, el: el || null },
|
|
522
533
|
}));
|
|
523
534
|
}
|
|
524
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Take a stamp that arrived with no document (spec §6).
|
|
538
|
+
*
|
|
539
|
+
* The host sends one whenever the file on disk changes, because an editing tab
|
|
540
|
+
* never receives the saved document itself: that lane is for viewers, and
|
|
541
|
+
* pushing a saved document onto an editor would replace work in progress.
|
|
542
|
+
*
|
|
543
|
+
* Taking it is what makes live sync and the conflict check agree. An editor
|
|
544
|
+
* whose peer frames are merging is already looking at the other tab's content,
|
|
545
|
+
* so refusing its next save would be a conflict about nothing.
|
|
546
|
+
*
|
|
547
|
+
* But only while this tab is in step. A held lane means live sync saw an
|
|
548
|
+
* incoming change, could not merge it into an unsaved local edit, and kept this
|
|
549
|
+
* tab's version instead, so the DOM here is knowingly missing what disk holds.
|
|
550
|
+
* Taking the new stamp there would let this tab's next save replace that change
|
|
551
|
+
* with nobody seeing it. That is the one loss live sync cannot prevent on its
|
|
552
|
+
* own: a held tab "converges through its own next save", and that convergence
|
|
553
|
+
* IS the overwrite. Refusing the stamp turns it into a conflict somebody is
|
|
554
|
+
* told about.
|
|
555
|
+
*
|
|
556
|
+
* @param {Object} data - The decoded frame
|
|
557
|
+
* @returns {boolean} True when this was a stamp frame and nothing should morph
|
|
558
|
+
*/
|
|
559
|
+
_applyEtagFrame(data) {
|
|
560
|
+
if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
|
|
561
|
+
if (!this._heldLive && !this._heldExt) recordEtag(data.etag);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
525
565
|
/**
|
|
526
566
|
* Connect to the SSE endpoint
|
|
527
567
|
* Uses native EventSource reconnection behavior
|
|
@@ -609,6 +649,8 @@ class LiveSync {
|
|
|
609
649
|
return;
|
|
610
650
|
}
|
|
611
651
|
|
|
652
|
+
if (this._applyEtagFrame(data)) return;
|
|
653
|
+
|
|
612
654
|
const { html, sender, identityMap } = data;
|
|
613
655
|
|
|
614
656
|
// Ignore own changes — already reflected in the DOM, nothing to morph
|
package/src/ui/index.js
CHANGED
|
@@ -23,6 +23,13 @@ if (typeof window.toastPersistent === "undefined") window.toastPersistent = toas
|
|
|
23
23
|
document.addEventListener("clay:save-saved", (e) =>
|
|
24
24
|
toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
|
|
25
25
|
document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
|
|
26
|
+
// Spec §6. Persistent, and carrying the host's own words, because this is the one
|
|
27
|
+
// save outcome the person has to act on: their edits are safe and unsaved, and
|
|
28
|
+
// autosave has stopped until they choose. Deliberately a toast and not a dialog:
|
|
29
|
+
// most conflicts surface on an autosave nobody asked for, and a modal thrown over
|
|
30
|
+
// the page a person is typing into is worse than the problem it reports.
|
|
31
|
+
document.addEventListener("clay:save-conflict", (e) =>
|
|
32
|
+
toastPersistent(e.detail?.msg || "This document changed since you opened it", "warning"));
|
|
26
33
|
document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
|
|
27
34
|
|
|
28
35
|
export { toast, toastPersistent, ask, consent, tell, snippet, themodal };
|