@panphora/clayjs 1.5.3 → 1.6.1
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 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The CMS shell, toggle, inline layer and ghost rows, with their --mirk-* tokens pinned
|
|
2
|
+
// to Bevel and the Bevel system mono face. The CMS keeps its own stylesheet and its
|
|
3
|
+
// own light/dark switching. The "Edit content" toggle also gets Bevel's button face, ink and hover (src/ui/skins/cms-toggle.js).
|
|
4
|
+
import { installSkin } from "../ui/vendor-skin.js";
|
|
5
|
+
import { CSS } from "../ui/skins/cms.js";
|
|
6
|
+
import { CSS as TOGGLE_CSS, ROOTS as TOGGLE_ROOTS } from "../ui/skins/cms-toggle.js";
|
|
7
|
+
|
|
8
|
+
export { cms, default } from "../vendor/hypercms.vendor.js";
|
|
9
|
+
|
|
10
|
+
installSkin("cms", CSS);
|
|
11
|
+
// The toggle takes the page's scheme, so Bevel's face and ink resolve the way the page reads.
|
|
12
|
+
installSkin("cms-toggle", TOGGLE_CSS, { roots: TOGGLE_ROOTS });
|
package/src/plugins/indicator.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
2
2
|
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
+
import { set, style } from "../lib/hostile-css.js";
|
|
4
|
+
import { bevelSurface, pageScheme } from "../ui/bevel-controls.js";
|
|
5
|
+
import { TOKENS, FONT_SANS } from "../ui/bevel.js";
|
|
6
|
+
import { saveFeedback } from "../core/save-feedback.js";
|
|
3
7
|
|
|
4
|
-
// No 'conflict' here on purpose. core/
|
|
8
|
+
// No 'conflict' here on purpose. core/conflict-notice.js owns that state now,
|
|
5
9
|
// and it ships in every document rather than only the ones that turned this chip on.
|
|
6
10
|
// Both listen to clay:save-conflict, so keeping a label here put a chip in the corner
|
|
7
11
|
// saying the same thing as the bar at the same moment. Dropping it from this side
|
|
@@ -19,41 +23,56 @@ const STICKY = new Set(["saving"]);
|
|
|
19
23
|
|
|
20
24
|
const ALARMING = new Set(["error", "offline"]);
|
|
21
25
|
|
|
26
|
+
const REST = [`background:${TOKENS.surface}`, `color:${TOKENS.ink}`, `border-color:${TOKENS["line-2"]}`];
|
|
27
|
+
const ALARM = [
|
|
28
|
+
`background:${TOKENS["ox-soft"]}`,
|
|
29
|
+
`color:${TOKENS.ox}`,
|
|
30
|
+
`border-color:color-mix(in srgb, ${TOKENS.ox} 28%, ${TOKENS["ox-soft"]})`,
|
|
31
|
+
];
|
|
32
|
+
|
|
22
33
|
let el = null;
|
|
23
34
|
let hideTimer = null;
|
|
24
35
|
|
|
25
36
|
function ensure() {
|
|
26
37
|
if (el) return el;
|
|
27
|
-
|
|
28
|
-
el
|
|
38
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
39
|
+
el = bevelSurface("div", [
|
|
40
|
+
"position:fixed", "right:16px", "bottom:16px", "z-index:2147483000",
|
|
41
|
+
"padding:4px 12px", `font:500 13px/1.6 ${FONT_SANS}`,
|
|
42
|
+
"opacity:0", `transition:${reduced ? "none" : "opacity .25s"}`, "pointer-events:none",
|
|
43
|
+
`color-scheme:${pageScheme()}`,
|
|
44
|
+
]);
|
|
29
45
|
el.setAttribute("data-clay-indicator", "");
|
|
30
46
|
el.setAttribute("role", "status");
|
|
31
|
-
el.style.cssText = [
|
|
32
|
-
"position:fixed", "right:16px", "bottom:16px", "z-index:2147483000",
|
|
33
|
-
"padding:4px 12px", "border-radius:999px",
|
|
34
|
-
"font:13px/1.6 system-ui,-apple-system,sans-serif",
|
|
35
|
-
"background:var(--clay-indicator-bg,#2e2b27)",
|
|
36
|
-
"color:var(--clay-indicator-ink,#f2f0eb)",
|
|
37
|
-
"opacity:0", "transition:opacity .25s", "pointer-events:none",
|
|
38
|
-
].join(";");
|
|
39
|
-
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) el.style.transition = "none";
|
|
40
47
|
document.body.appendChild(el);
|
|
41
48
|
return el;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
function show(state) {
|
|
52
|
+
// A failed or offline save is the persistent toast's to report when clay-ui is loaded,
|
|
53
|
+
// and so is saved when the page turned on clay.saveToast.
|
|
54
|
+
if (saveFeedback.toasts && (ALARMING.has(state) || (state === "saved" && saveFeedback.savedToast))) {
|
|
55
|
+
clearTimeout(hideTimer);
|
|
56
|
+
if (el) {
|
|
57
|
+
set(el, "opacity", "0");
|
|
58
|
+
el.textContent = "";
|
|
59
|
+
delete el.dataset.state;
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
45
63
|
const node = ensure();
|
|
46
64
|
node.textContent = LABELS[state];
|
|
47
65
|
node.dataset.state = state;
|
|
48
|
-
node
|
|
49
|
-
|
|
50
|
-
node
|
|
66
|
+
set(node, "color-scheme", pageScheme());
|
|
67
|
+
style(node, ALARMING.has(state) ? ALARM : REST);
|
|
68
|
+
set(node, "opacity", "1");
|
|
51
69
|
clearTimeout(hideTimer);
|
|
52
|
-
if (!STICKY.has(state)) hideTimer = setTimeout(() => { node
|
|
70
|
+
if (!STICKY.has(state)) hideTimer = setTimeout(() => { set(node, "opacity", "0"); }, 2200);
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function init() {
|
|
56
74
|
if (!isEditMode) return;
|
|
75
|
+
saveFeedback.chip = true;
|
|
57
76
|
for (const state of Object.keys(LABELS)) {
|
|
58
77
|
document.addEventListener("clay:save-" + state, () => show(state));
|
|
59
78
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// clay.quickcrop: the vendored cropper, framed by ClayJS's own Bevel dialog.
|
|
2
|
+
//
|
|
3
|
+
// quickcrop draws the crop stage; how it is framed is an adapter's job (its
|
|
4
|
+
// `modal` option). Left to itself it would wrap themodal by reaching for themodal's
|
|
5
|
+
// classes, which ClayJS's modal no longer has, or fall back to a frame of its own
|
|
6
|
+
// that a page stylesheet can repaint. So every request that would have landed on
|
|
7
|
+
// either ('auto', or a themodal-shaped object such as clay.modal, which is what the
|
|
8
|
+
// CMS passes) is framed here instead. 'builtin' and a caller's own adapter pass
|
|
9
|
+
// through untouched. The stage itself is quickcrop's, and so is its geometry; its
|
|
10
|
+
// skin keeps a page stylesheet off it.
|
|
11
|
+
import vendorQuickcrop from "../vendor/quickcrop.vendor.js";
|
|
12
|
+
import { bevelDialog, dismissOf, keepFocusIn } from "../ui/bevel-dialog.js";
|
|
13
|
+
import { bevelButton } from "../ui/bevel-controls.js";
|
|
14
|
+
import { installSkin } from "../ui/vendor-skin.js";
|
|
15
|
+
import { CSS as SKIN, ROOTS as SKIN_ROOTS } from "../ui/skins/quickcrop.js";
|
|
16
|
+
|
|
17
|
+
const WIDTH = 844;
|
|
18
|
+
|
|
19
|
+
installSkin("quickcrop", SKIN, { roots: SKIN_ROOTS });
|
|
20
|
+
|
|
21
|
+
function isThemodal(m) {
|
|
22
|
+
return !!m && typeof m === "object" &&
|
|
23
|
+
typeof m.open === "function" && typeof m.close === "function" &&
|
|
24
|
+
typeof m.onYes === "function" && "html" in m;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const bevelCropAdapter = {
|
|
28
|
+
// The stage has to fit inside the panel: the overlay's 16px side inset, the panel's
|
|
29
|
+
// 1px border and the body's 22px padding a side; below it the header, the footer and
|
|
30
|
+
// the overlay's top inset. Widths come from clientWidth, which leaves out a classic
|
|
31
|
+
// scrollbar as the overlay does.
|
|
32
|
+
fit() {
|
|
33
|
+
const width = document.documentElement.clientWidth;
|
|
34
|
+
return {
|
|
35
|
+
width: Math.max(120, Math.min(WIDTH, width - 32) - 2 - 44),
|
|
36
|
+
height: Math.max(120, window.innerHeight - Math.min(96, window.innerHeight * 0.1) - 200),
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
open({ content, confirmLabel, onConfirm, onCancel }) {
|
|
40
|
+
const { root, overlay, panel, heading, body, footer, close } = bevelDialog({
|
|
41
|
+
zIndex: "2147483001", width: `${WIDTH}px`, closable: true, titled: true,
|
|
42
|
+
});
|
|
43
|
+
heading.textContent = "Crop image";
|
|
44
|
+
panel.setAttribute("aria-label", "Crop image");
|
|
45
|
+
body.append(content);
|
|
46
|
+
const confirm = bevelButton(confirmLabel, { variant: "primary", onClick: () => onConfirm() });
|
|
47
|
+
footer.append(confirm);
|
|
48
|
+
|
|
49
|
+
close.addEventListener("click", () => onCancel());
|
|
50
|
+
panel.addEventListener("submit", (event) => event.preventDefault());
|
|
51
|
+
let pressedOnBackdrop = false;
|
|
52
|
+
overlay.addEventListener("mousedown", (event) => { pressedOnBackdrop = event.target === overlay; });
|
|
53
|
+
overlay.addEventListener("click", (event) => {
|
|
54
|
+
if (pressedOnBackdrop && event.target === overlay) onCancel();
|
|
55
|
+
pressedOnBackdrop = false;
|
|
56
|
+
});
|
|
57
|
+
// Capture, and stopped there, so a dialog underneath (a CMS confirm through
|
|
58
|
+
// clay.modal) does not take the same key as its own: Escape cancels this crop,
|
|
59
|
+
// Tab stays inside it.
|
|
60
|
+
const onKey = (event) => {
|
|
61
|
+
if (event.key === "Tab") {
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
keepFocusIn(panel, event);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (event.key !== "Escape") return;
|
|
67
|
+
event.stopPropagation();
|
|
68
|
+
onCancel();
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("keydown", onKey, true);
|
|
71
|
+
|
|
72
|
+
// The page does not scroll behind the crop, as it did not under themodal. The
|
|
73
|
+
// previous value comes back, so a modal underneath keeps its own lock.
|
|
74
|
+
const overflow = document.body.style.overflow;
|
|
75
|
+
document.body.style.overflow = "hidden";
|
|
76
|
+
|
|
77
|
+
let done = false;
|
|
78
|
+
const finish = () => {
|
|
79
|
+
if (done) return;
|
|
80
|
+
done = true;
|
|
81
|
+
document.removeEventListener("keydown", onKey, true);
|
|
82
|
+
document.body.style.overflow = overflow;
|
|
83
|
+
root.remove();
|
|
84
|
+
};
|
|
85
|
+
// A modal opened on top supersedes this crop the way it supersedes itself: the
|
|
86
|
+
// crop is cancelled, so its promise settles and the next crop can open.
|
|
87
|
+
dismissOf.set(root, () => { finish(); onCancel(); });
|
|
88
|
+
|
|
89
|
+
root.setAttribute("aria-hidden", "false");
|
|
90
|
+
document.body.append(root);
|
|
91
|
+
confirm.focus();
|
|
92
|
+
|
|
93
|
+
return { close: finish };
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function quickcrop(file, options = {}) {
|
|
98
|
+
const modal = options.modal ?? "auto";
|
|
99
|
+
const framed = modal === "auto" || isThemodal(modal);
|
|
100
|
+
return vendorQuickcrop(file, framed ? { ...options, modal: bevelCropAdapter } : options);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default quickcrop;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// richclay's toolbar, menus, floating toolbar and link dialog, raised above the host
|
|
2
|
+
// page by its skin. The vendor bundle is unchanged; the skin goes in first, so the
|
|
3
|
+
// editor never draws a frame of chrome the page can repaint.
|
|
4
|
+
import { installSkin } from "../ui/vendor-skin.js";
|
|
5
|
+
import { CSS, ROOTS } from "../ui/skins/richclay.js";
|
|
6
|
+
import { RichClay } from "../vendor/richclay.vendor.js";
|
|
7
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
8
|
+
|
|
9
|
+
installSkin("richclay", CSS, { roots: ROOTS });
|
|
10
|
+
|
|
11
|
+
// A frame's snapshot carries none of RichClay's runtime state, and the merge removes
|
|
12
|
+
// live attributes the merged page lacks, so an applied frame leaves editors that can
|
|
13
|
+
// no longer be typed into. RichClay's reattach() restores them. View mode mounts none.
|
|
14
|
+
if (isEditMode) {
|
|
15
|
+
document.addEventListener("clay:sync-applied", () => {
|
|
16
|
+
for (const editor of RichClay.init()) editor.reattach();
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { RichClay };
|
|
21
|
+
export default RichClay;
|
package/src/plugins/source.js
CHANGED
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
* sync-applied re-pair, because a morph replaces live nodes and the map is keyed
|
|
20
20
|
* by node identity.
|
|
21
21
|
*
|
|
22
|
-
* The
|
|
23
|
-
* landing and the page hearing about it
|
|
22
|
+
* The accepted-save re-model is deferred and coalesced, so it never sits between a save
|
|
23
|
+
* landing and the page hearing about it. A sync frame's re-pair shares that idle refresh,
|
|
24
|
+
* so a burst of frames costs one walk, and a save, `text()` or `locate()` that comes
|
|
25
|
+
* before it runs it first, so none of them work from a map the morph made stale.
|
|
24
26
|
*
|
|
25
27
|
* NOTHING HERE BLOCKS BOOT. The install is async and the loader does not wait for
|
|
26
28
|
* it, because a save before it finishes is a save exactly as it is today. Waiting
|
|
@@ -173,6 +175,10 @@ function install(sourceUrl) {
|
|
|
173
175
|
*/
|
|
174
176
|
export function renderSave(clone, today, opts = {}) {
|
|
175
177
|
if (!state.installed) return today;
|
|
178
|
+
// A refresh still waiting for idle time runs now, so this save never renders from a
|
|
179
|
+
// map a morph made stale. It pairs against the page, not this clone: onbeforesave may
|
|
180
|
+
// have changed the clone, and the map must describe the live page.
|
|
181
|
+
refreshNow();
|
|
176
182
|
state.lastOutcome = null;
|
|
177
183
|
const print = new Set();
|
|
178
184
|
let firstDiff = null;
|
|
@@ -288,7 +294,8 @@ function report(bytes) {
|
|
|
288
294
|
* the page, and it has to be re-paired either way. A DISK frame also carries the bytes
|
|
289
295
|
* now on disk, written by somebody else: those become the model, the same as bytes this
|
|
290
296
|
* tab saved, or the next save would copy the old formatting back over theirs. A peer
|
|
291
|
-
* frame changed nothing on disk, so the model stays.
|
|
297
|
+
* frame changed nothing on disk, so the model stays. The re-pair runs in idle time, or
|
|
298
|
+
* earlier if a save, `text()` or `locate()` needs it first.
|
|
292
299
|
*/
|
|
293
300
|
function queueRepair(event) {
|
|
294
301
|
if (!state.installed) return;
|
|
@@ -380,7 +387,7 @@ function summary() {
|
|
|
380
387
|
|
|
381
388
|
export const source = {
|
|
382
389
|
ready: null,
|
|
383
|
-
stats: summary,
|
|
390
|
+
stats: () => { refreshNow(); return summary(); },
|
|
384
391
|
/** The bytes this module believes are on disk right now. A refresh still queued from a save or a disk frame runs first, so this is never the file before that. */
|
|
385
392
|
text: () => { refreshNow(); return state.model ? state.model.src : null; },
|
|
386
393
|
/**
|
|
@@ -398,9 +405,9 @@ export const source = {
|
|
|
398
405
|
*/
|
|
399
406
|
locate: (node) => { refreshNow(); return state.installed && node ? locate(node, state.map, state.model) : null; },
|
|
400
407
|
/** What did not pair, for working out why a document reprints more than it should. */
|
|
401
|
-
unpaired: () => (state.stats
|
|
408
|
+
unpaired: () => { refreshNow(); return (state.stats
|
|
402
409
|
? { live: state.stats.unmatchedLive.slice(0, 50), source: state.stats.unmatchedSource.slice(0, 50) }
|
|
403
|
-
: null),
|
|
410
|
+
: null); },
|
|
404
411
|
};
|
|
405
412
|
|
|
406
413
|
onSaveAccepted(adopt);
|
package/src/plugins/wire.js
CHANGED
|
@@ -506,6 +506,10 @@ async function flushSave() {
|
|
|
506
506
|
// surface is an opt-in satellite script, absent on every page the loader
|
|
507
507
|
// builds, so reading it here made "is a save on the wire" permanently false.
|
|
508
508
|
const { isSaveInProgress } = await import("../core/save-core.js");
|
|
509
|
+
// A held save is an AI edit preview waiting for Keep or Revert, not a page with
|
|
510
|
+
// nothing to save: posting now would hand the agent a file that is about to change.
|
|
511
|
+
const { savesHeld } = await import("../core/save.js");
|
|
512
|
+
if (savesHeld()) throw new Error("keep or revert the AI edit first");
|
|
509
513
|
const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
|
|
510
514
|
|
|
511
515
|
for (let attempt = 0; attempt < 3; attempt++) {
|