@panphora/clayjs 1.11.3 → 1.12.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 +3 -1
- package/dist/clay.standalone.js +10417 -7665
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +3 -3
- package/package.json +1 -1
- package/src/core/draft-mode.js +57 -0
- package/src/core/edit-mode.js +7 -2
- package/src/core/host-meta.js +2 -0
- package/src/core/is-edit-mode.js +27 -9
- package/src/core/page-data.js +12 -0
- package/src/core/save-core.js +22 -2
- package/src/core/save.js +2 -2
- package/src/core/unsaved-warning.js +5 -2
- package/src/lib/root-attrs.js +1 -1
- package/src/loader-logic.js +21 -2
- package/src/loader.js +10 -3
- package/src/plugins/visitor-drafts/bottom-ui.js +31 -0
- package/src/plugins/visitor-drafts/intro-style.js +19 -0
- package/src/plugins/visitor-drafts/intro.js +118 -0
- package/src/plugins/visitor-drafts/owner-chip.js +178 -0
- package/src/plugins/visitor-drafts/prompt-style.js +198 -0
- package/src/plugins/visitor-drafts/prompt.js +203 -0
- package/src/plugins/visitor-drafts/source.js +21 -0
- package/src/plugins/visitor-drafts/store.js +342 -0
- package/src/plugins/visitor-drafts/ui-memory.js +14 -0
- package/src/plugins/visitor-drafts.js +111 -0
- package/src/standalone.js +5 -0
- package/src/vendor/hyper-html-api-data.vendor.js +4 -0
package/src/core/edit-mode.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Edit mode system - combines the editmode toggle with page type setting.
|
|
2
2
|
// Loads in BOTH view and edit mode: toggleEditMode must exist in view mode
|
|
3
3
|
// (it's the way IN), and setPageTypeOnPageLoad marks <html> in either mode.
|
|
4
|
-
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
4
|
+
import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
|
|
5
5
|
import onDomReady from "../lib/dom-ready.js";
|
|
6
6
|
import { addDocumentTransform } from "./snapshot.js";
|
|
7
7
|
|
|
8
8
|
export function toggleEditMode() {
|
|
9
9
|
const url = new URL(window.location.href);
|
|
10
|
-
const newMode = isEditMode ? "false" : "true";
|
|
10
|
+
const newMode = isEditMode && !isDraftMode ? "false" : "true";
|
|
11
11
|
url.searchParams.set('editmode', newMode);
|
|
12
12
|
window.location.href = url.toString();
|
|
13
13
|
}
|
|
@@ -23,6 +23,11 @@ export function setPageTypeOnPageLoad () {
|
|
|
23
23
|
onDomReady(() => {
|
|
24
24
|
document.documentElement.setAttribute("editmode", isEditMode ? "true" : "false");
|
|
25
25
|
document.documentElement.setAttribute("pageowner", isOwner ? "true" : "false");
|
|
26
|
+
if (isDraftMode) {
|
|
27
|
+
document.documentElement.setAttribute("draftmode", "true");
|
|
28
|
+
} else {
|
|
29
|
+
document.documentElement.removeAttribute("draftmode");
|
|
30
|
+
}
|
|
26
31
|
});
|
|
27
32
|
}
|
|
28
33
|
|
package/src/core/host-meta.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { saveToken } from "./host-attrs.js";
|
|
17
|
+
import { isDraftMode } from "./is-edit-mode.js";
|
|
17
18
|
|
|
18
19
|
const META_PATH = "/_/meta";
|
|
19
20
|
const META_TIMEOUT_MS = 6000;
|
|
@@ -67,6 +68,7 @@ let refreshing = null;
|
|
|
67
68
|
* @returns {Promise<{spec: ?number, extensions: string[], document: ?Object}>}
|
|
68
69
|
*/
|
|
69
70
|
export function hostMeta({ fresh = false } = {}) {
|
|
71
|
+
if (isDraftMode) return Promise.resolve(bareHost());
|
|
70
72
|
if (!fresh) {
|
|
71
73
|
if (!inFlight) inFlight = fetchMeta().catch(bareHost);
|
|
72
74
|
return inFlight;
|
package/src/core/is-edit-mode.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import cookie from "../lib/cookie.js";
|
|
2
2
|
import query from "../lib/query.js";
|
|
3
3
|
import { hasSaveToken, servedStaleToken, servedSiteVisibility } from "./host-attrs.js";
|
|
4
|
+
import { draftEligible } from "./draft-mode.js";
|
|
4
5
|
|
|
5
6
|
// Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
|
|
6
7
|
// authored `viewonly` attribute on <html> (a tool page that must never save its
|
|
@@ -11,7 +12,9 @@ import { hasSaveToken, servedStaleToken, servedSiteVisibility } from "./host-att
|
|
|
11
12
|
// a save token the host put on the root, then the isAdminOfCurrentResource cookie. The
|
|
12
13
|
// global is for standalone uses (demos, htmlclay, any self-saving file) that are
|
|
13
14
|
// always editable and have no owner cookie; setting it before clayjs loads turns
|
|
14
|
-
// on the edit-only modules.
|
|
15
|
+
// on the edit-only modules. On a page that opts into visitor drafts, draft mode
|
|
16
|
+
// comes before ?editmode=true unless the owner cookie is present, and before the
|
|
17
|
+
// owner cookie otherwise.
|
|
15
18
|
//
|
|
16
19
|
// The token sits above the cookie because it is the stronger claim and often the
|
|
17
20
|
// only one visible: the host minted it for this response and nothing else, while
|
|
@@ -37,19 +40,34 @@ if (typeof window !== "undefined") {
|
|
|
37
40
|
const isViewOnlyDocument =
|
|
38
41
|
typeof document !== "undefined" && document.documentElement.hasAttribute("viewonly");
|
|
39
42
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
: isViewOnlyDocument || servedStaleToken()
|
|
43
|
-
? false
|
|
44
|
-
: forcedEditMode != null
|
|
45
|
-
? forcedEditMode
|
|
46
|
-
: hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
43
|
+
const draftCandidate = typeof document !== "undefined" && draftEligible();
|
|
44
|
+
const ownerCookie = Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
47
45
|
|
|
48
|
-
const
|
|
46
|
+
const isDraftMode =
|
|
47
|
+
draftCandidate &&
|
|
48
|
+
query.editmode !== "false" &&
|
|
49
|
+
!(query.editmode === "true" && ownerCookie) &&
|
|
50
|
+
!isViewOnlyDocument &&
|
|
51
|
+
!servedStaleToken() &&
|
|
52
|
+
forcedEditMode == null &&
|
|
53
|
+
!hasSaveToken();
|
|
54
|
+
|
|
55
|
+
const isEditMode = isDraftMode
|
|
56
|
+
? true
|
|
57
|
+
: query.editmode
|
|
58
|
+
? query.editmode === "true" // takes precedence over viewonly, the global, token and cookie
|
|
59
|
+
: isViewOnlyDocument || servedStaleToken()
|
|
60
|
+
? false
|
|
61
|
+
: forcedEditMode != null
|
|
62
|
+
? forcedEditMode
|
|
63
|
+
: hasSaveToken() || ownerCookie;
|
|
64
|
+
|
|
65
|
+
const isOwner = ownerCookie;
|
|
49
66
|
|
|
50
67
|
const siteVisibility = servedSiteVisibility;
|
|
51
68
|
|
|
52
69
|
export {
|
|
70
|
+
isDraftMode,
|
|
53
71
|
isEditMode,
|
|
54
72
|
isOwner,
|
|
55
73
|
siteVisibility
|
package/src/core/page-data.js
CHANGED
|
@@ -132,6 +132,12 @@ async function defaultWriteModules() {
|
|
|
132
132
|
return { ...save, ...saveCore, ...etag, ...warning, ...editMode };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
async function draftRefusal() {
|
|
136
|
+
const { isDraftMode } = await import("./is-edit-mode.js");
|
|
137
|
+
if (!isDraftMode) return null;
|
|
138
|
+
return refusal("DataUnavailableInDraft", "Page data is kept in this browser for this visitor.");
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
export function createPageDataApi({
|
|
136
142
|
supports = hostSupports,
|
|
137
143
|
token = saveToken,
|
|
@@ -151,6 +157,9 @@ export function createPageDataApi({
|
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
async function readData(rules) {
|
|
160
|
+
const draft = await draftRefusal();
|
|
161
|
+
if (draft) throw draft;
|
|
162
|
+
|
|
154
163
|
if (!(await supports(DATA_READ))) {
|
|
155
164
|
throw refusal("DataReadUnsupported", "This host does not support reading page data.");
|
|
156
165
|
}
|
|
@@ -201,6 +210,9 @@ export function createPageDataApi({
|
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
async function writeData(values, { rules, ifMatch } = {}) {
|
|
213
|
+
const draft = await draftRefusal();
|
|
214
|
+
if (draft) throw draft;
|
|
215
|
+
|
|
204
216
|
if (!(await supports(DATA_WRITE))) {
|
|
205
217
|
throw refusal("DataWriteUnsupported", "This host does not support writing page data.");
|
|
206
218
|
}
|
package/src/core/save-core.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* For full save system with state management, use save.js instead.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { isEditMode } from "./is-edit-mode.js";
|
|
10
|
+
import { isEditMode, isDraftMode } from "./is-edit-mode.js";
|
|
11
|
+
import { draftTransport } from "./draft-mode.js";
|
|
11
12
|
import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
|
|
12
13
|
import { saveToken } from "./host-attrs.js";
|
|
13
14
|
import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
|
|
@@ -379,6 +380,24 @@ function resolveSaveUrl(path) {
|
|
|
379
380
|
return new URL(path, origin).href;
|
|
380
381
|
}
|
|
381
382
|
|
|
383
|
+
function draftResponse(status, body) {
|
|
384
|
+
const text = JSON.stringify(body);
|
|
385
|
+
return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? "OK" : "Insufficient Storage", text: () => Promise.resolve(text) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function sendToDraftStore(content) {
|
|
389
|
+
const send = draftTransport();
|
|
390
|
+
if (!send) return Promise.resolve(draftResponse(503, { msg: "Saving in this browser is not ready yet", msgType: "error", code: "draft-not-ready" }));
|
|
391
|
+
return Promise.resolve().then(() => {
|
|
392
|
+
try {
|
|
393
|
+
send(content);
|
|
394
|
+
return draftResponse(200, { msg: "Saved in this browser", msgType: "success" });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
return draftResponse(507, { msg: "Couldn't save in this browser. Storage is full or turned off.", msgType: "error", code: error.code || "draft-storage" });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
382
401
|
/**
|
|
383
402
|
* Send one save and decode one response.
|
|
384
403
|
*
|
|
@@ -414,7 +433,7 @@ function sendOnce(content, saveId, etag) {
|
|
|
414
433
|
const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
|
|
415
434
|
rememberSentId(saveId);
|
|
416
435
|
|
|
417
|
-
return fetch(url, options)
|
|
436
|
+
return (isDraftMode ? sendToDraftStore(content) : fetch(url, options))
|
|
418
437
|
.then(res => res.text().then(text => {
|
|
419
438
|
let data = {};
|
|
420
439
|
if (text) {
|
|
@@ -689,6 +708,7 @@ export function replacePageWith(url, callback = () => {}) {
|
|
|
689
708
|
|
|
690
709
|
if (!isEditMode) return done(skippedResult('Not in edit mode'));
|
|
691
710
|
if (saveInProgress) return done(skippedResult('Save already in progress'));
|
|
711
|
+
if (isDraftMode) return done(false);
|
|
692
712
|
|
|
693
713
|
// Resolved against the document's real location, not a <base href> the page
|
|
694
714
|
// may have set.
|
package/src/core/save.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import throttle from "../lib/throttle.js";
|
|
13
13
|
import Mutation from "../lib/mutation.js";
|
|
14
|
-
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
14
|
+
import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
|
|
15
15
|
import {
|
|
16
16
|
saveHtml,
|
|
17
17
|
getPageContents,
|
|
@@ -742,7 +742,7 @@ export async function saveOverwritingConflict(callback = () => {}) {
|
|
|
742
742
|
* @param {string} url - URL to fetch from
|
|
743
743
|
*/
|
|
744
744
|
export function replacePageWith(url) {
|
|
745
|
-
if (!isEditMode) {
|
|
745
|
+
if (!isEditMode || isDraftMode) {
|
|
746
746
|
return;
|
|
747
747
|
}
|
|
748
748
|
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* Requires the 'save-system' module (automatically included as dependency).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { isEditMode } from "./is-edit-mode.js";
|
|
18
|
+
import { isEditMode, isDraftMode } from "./is-edit-mode.js";
|
|
19
|
+
import { draftStoreFailed, flushDraft } from "./draft-mode.js";
|
|
19
20
|
import { captureForDirtyCheck } from "./snapshot.js";
|
|
20
21
|
import { getLastSavedDirty } from "./save.js";
|
|
21
22
|
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
@@ -52,6 +53,7 @@ export function hasUnsavedChanges() {
|
|
|
52
53
|
// Work outside the DOM first: it needs no capture, and a capture that throws
|
|
53
54
|
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
54
55
|
if (hasUnsavedState()) return true;
|
|
56
|
+
if (isDraftMode) return draftStoreFailed();
|
|
55
57
|
|
|
56
58
|
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
57
59
|
// leaving loses none of them.
|
|
@@ -84,7 +86,8 @@ window.addEventListener('beforeunload', (event) => {
|
|
|
84
86
|
return;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
const flushed = isDraftMode ? flushDraft() : true;
|
|
90
|
+
if (flushed && !hasUnsavedChanges()) return;
|
|
88
91
|
|
|
89
92
|
event.preventDefault();
|
|
90
93
|
event.returnValue = '';
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -94,7 +94,7 @@ export const HOST_TOKEN_ATTRS = [
|
|
|
94
94
|
];
|
|
95
95
|
|
|
96
96
|
// This library's own root state, and this tab's UI truth.
|
|
97
|
-
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
97
|
+
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner", "draftmode"];
|
|
98
98
|
|
|
99
99
|
// Never copied between tabs: not written onto this root by an incoming morph, not
|
|
100
100
|
// sent out on a sync broadcast.
|
package/src/loader-logic.js
CHANGED
|
@@ -55,6 +55,7 @@ export const PLUGIN_PATHS = {
|
|
|
55
55
|
// verify is sent as the ordinary full serialization, so the floor is the behaviour
|
|
56
56
|
// it replaces. `exclude=source` turns it off.
|
|
57
57
|
source: { path: "plugins/source.js", editOnly: true, default: true },
|
|
58
|
+
"visitor-drafts": { path: "plugins/visitor-drafts.js", editOnly: false, default: false },
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
// One literal import per module the loader can ask for, so a bundler can see the
|
|
@@ -68,6 +69,7 @@ export const PLUGIN_PATHS = {
|
|
|
68
69
|
// PLUGIN_PATHS has an entry here and that each entry imports the file it names.
|
|
69
70
|
export const MODULES = {
|
|
70
71
|
"core/is-edit-mode.js": () => import("./core/is-edit-mode.js"),
|
|
72
|
+
"core/draft-mode.js": () => import("./core/draft-mode.js"),
|
|
71
73
|
"lib/region-policy.js": () => import("./lib/region-policy.js"),
|
|
72
74
|
"lib/mutation.js": () => import("./lib/mutation.js"),
|
|
73
75
|
"core/edit-mode.js": () => import("./core/edit-mode.js"),
|
|
@@ -100,12 +102,13 @@ export const MODULES = {
|
|
|
100
102
|
"plugins/wire.js": () => import("./plugins/wire.js"),
|
|
101
103
|
"plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
|
|
102
104
|
"plugins/demo.js": () => import("./plugins/demo.js"),
|
|
105
|
+
"plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
|
|
103
106
|
"plugins/source.js": () => import("./plugins/source.js"),
|
|
104
107
|
};
|
|
105
108
|
|
|
106
109
|
// `source` is last on purpose: its install captures a save clone, so it wants every
|
|
107
110
|
// plugin that registers a document transform to have registered it first.
|
|
108
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
|
|
111
|
+
const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
|
|
109
112
|
|
|
110
113
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
111
114
|
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
@@ -143,7 +146,15 @@ function parseCsv(params, key, enabled, apply) {
|
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
|
|
149
|
+
// Each of these talks to the host or writes outside the visitor's own record, so a
|
|
150
|
+
// page that keeps its edits in this browser cannot carry them.
|
|
151
|
+
const DRAFT_DROPS = ["sync", "live", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
|
|
152
|
+
|
|
153
|
+
// [refetch-on-save] swaps its element for a fresh GET of the same URL after every
|
|
154
|
+
// save, which on a visitor's own draft is a request to the host.
|
|
155
|
+
const DRAFT_DROPS_CORE = ["attrs/refetch-on-save.js"];
|
|
156
|
+
|
|
157
|
+
export function resolveModules(params, isEditMode, { isDraftMode = false } = {}) {
|
|
147
158
|
const core = [...CORE_WAVES.always];
|
|
148
159
|
if (isEditMode) core.push(...CORE_WAVES.editOnly);
|
|
149
160
|
|
|
@@ -166,6 +177,14 @@ export function resolveModules(params, isEditMode) {
|
|
|
166
177
|
for (const implied of IMPLIES[name] || []) enabled.add(implied);
|
|
167
178
|
}
|
|
168
179
|
for (const name of excluded) enabled.delete(name);
|
|
180
|
+
if (isDraftMode) {
|
|
181
|
+
for (const name of DRAFT_DROPS) enabled.delete(name);
|
|
182
|
+
for (const path of DRAFT_DROPS_CORE) {
|
|
183
|
+
const at = core.indexOf(path);
|
|
184
|
+
if (at > -1) core.splice(at, 1);
|
|
185
|
+
}
|
|
186
|
+
enabled.add("visitor-drafts");
|
|
187
|
+
}
|
|
169
188
|
|
|
170
189
|
const plugins = [];
|
|
171
190
|
for (const name of PLUGIN_ORDER) {
|
package/src/loader.js
CHANGED
|
@@ -14,6 +14,8 @@ function domReady() {
|
|
|
14
14
|
// no clayjs at all.
|
|
15
15
|
export async function boot(base, params, readyResolve) {
|
|
16
16
|
initTheme();
|
|
17
|
+
const draftMode = await MODULES["core/draft-mode.js"]();
|
|
18
|
+
draftMode.noteBootParams(params);
|
|
17
19
|
|
|
18
20
|
await domReady(); // Mutation observes document.body
|
|
19
21
|
// unconditionally, and a <head> placement
|
|
@@ -30,14 +32,16 @@ export async function boot(base, params, readyResolve) {
|
|
|
30
32
|
|
|
31
33
|
const regionPolicy = await MODULES["lib/region-policy.js"]();
|
|
32
34
|
|
|
33
|
-
const
|
|
35
|
+
const { isDraftMode } = editMode;
|
|
36
|
+
|
|
37
|
+
const plan = resolveModules(params, isEditMode, { isDraftMode });
|
|
34
38
|
const loaded = {};
|
|
35
39
|
|
|
36
40
|
for (const path of plan.core) {
|
|
37
41
|
loaded[path] = await MODULES[path](); // sequential: order is load-bearing
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy); // window.clay MUST be assembled
|
|
44
|
+
assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy); // window.clay MUST be assembled
|
|
41
45
|
// before any plugin import
|
|
42
46
|
|
|
43
47
|
for (const path of plan.plugins) {
|
|
@@ -69,7 +73,7 @@ export async function boot(base, params, readyResolve) {
|
|
|
69
73
|
document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy) {
|
|
76
|
+
function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility }, regionPolicy) {
|
|
73
77
|
const clay = window.clay;
|
|
74
78
|
|
|
75
79
|
const mutation = loaded["lib/mutation.js"];
|
|
@@ -77,6 +81,7 @@ function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPol
|
|
|
77
81
|
|
|
78
82
|
Object.assign(clay, {
|
|
79
83
|
toggleEditMode: editModeMod.toggleEditMode,
|
|
84
|
+
isDraftMode,
|
|
80
85
|
isEditMode,
|
|
81
86
|
isOwner,
|
|
82
87
|
siteVisibility,
|
|
@@ -148,6 +153,8 @@ function attachPluginMember(path, mod, loaded) {
|
|
|
148
153
|
clay.quickcrop = mod.quickcrop || mod.default;
|
|
149
154
|
} else if (path === "plugins/source.js") {
|
|
150
155
|
clay.source = mod.source || mod.default;
|
|
156
|
+
} else if (path === "plugins/visitor-drafts.js") {
|
|
157
|
+
clay.visitorDrafts = mod.visitorDrafts;
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const BOTTOM_UI_CHANGED = "clay:bottom-ui-changed";
|
|
2
|
+
|
|
3
|
+
export function announceBottomUi() {
|
|
4
|
+
document.dispatchEvent(new CustomEvent(BOTTOM_UI_CHANGED));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function bottomBarHeight(x, skip) {
|
|
8
|
+
const h = window.innerHeight;
|
|
9
|
+
let raise = 0;
|
|
10
|
+
const hits = document.elementsFromPoint ? document.elementsFromPoint(x, h - 3) : [];
|
|
11
|
+
for (const hit of hits) {
|
|
12
|
+
for (let el = hit; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
|
|
13
|
+
if (skip(el)) break;
|
|
14
|
+
const position = getComputedStyle(el).position;
|
|
15
|
+
if (position === "fixed" || position === "sticky") {
|
|
16
|
+
const height = Math.ceil(h - el.getBoundingClientRect().top);
|
|
17
|
+
if (height < h / 3) raise = Math.max(raise, height);
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return raise;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function promptPanel(box) {
|
|
26
|
+
const panel = document.querySelector("#hcp:not([hidden])");
|
|
27
|
+
if (!panel) return 0;
|
|
28
|
+
const r = panel.getBoundingClientRect();
|
|
29
|
+
if (!r.width || !r.height || r.right <= box.left || r.left >= box.right) return 0;
|
|
30
|
+
return Math.ceil(window.innerHeight - r.top);
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const INTRO_CSS = `html:has(> body > .clay-intro) { height: auto !important; overflow-y: auto !important; }
|
|
2
|
+
body:has(> .clay-intro) { height: auto !important; overflow: visible !important; }
|
|
3
|
+
.clay-intro {
|
|
4
|
+
position: relative;
|
|
5
|
+
display: block;
|
|
6
|
+
}
|
|
7
|
+
.clay-intro__close {
|
|
8
|
+
position: absolute; top: 10px; right: 10px; z-index: 1;
|
|
9
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
10
|
+
width: 28px; height: 28px; min-height: 0; margin: 0; padding: 0;
|
|
11
|
+
border: 0; border-radius: 4px; background: none; box-shadow: none;
|
|
12
|
+
color: inherit; opacity: .55; cursor: pointer; line-height: 1;
|
|
13
|
+
}
|
|
14
|
+
.clay-intro .clay-intro__close:hover { opacity: 1; background: none; color: inherit; }
|
|
15
|
+
.clay-intro__close:focus-visible { opacity: 1; outline: 2px solid currentColor; outline-offset: 2px; }
|
|
16
|
+
.clay-intro__close svg { display: block; width: 12px; height: 12px; fill: currentColor; stroke: none; }
|
|
17
|
+
@media (pointer: coarse) { .clay-intro__close { width: 44px; height: 44px; top: 4px; right: 4px; } }
|
|
18
|
+
@media print { .clay-intro { display: none; } }
|
|
19
|
+
`;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { INTRO_CSS } from "./intro-style.js";
|
|
2
|
+
import { announceBottomUi } from "./bottom-ui.js";
|
|
3
|
+
|
|
4
|
+
export const COPY = {
|
|
5
|
+
close: "Hide this introduction",
|
|
6
|
+
label: (appName) => `About ${appName}`,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
|
10
|
+
const TEXT_ENTRY = "textarea, select";
|
|
11
|
+
const NON_TEXT_INPUT = ["checkbox", "radio", "button", "submit", "reset", "file"];
|
|
12
|
+
|
|
13
|
+
function isTextEntry(el) {
|
|
14
|
+
if (el.isContentEditable || el.closest(TEXT_ENTRY)) return true;
|
|
15
|
+
if (el.tagName !== "INPUT") return false;
|
|
16
|
+
return NON_TEXT_INPUT.indexOf(el.type) < 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function forced() {
|
|
20
|
+
try {
|
|
21
|
+
return new URLSearchParams(window.location.search).get("intro") === "1";
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stylesOnce() {
|
|
28
|
+
if (document.getElementById("clay-intro-style")) return;
|
|
29
|
+
const style = document.createElement("style");
|
|
30
|
+
style.id = "clay-intro-style";
|
|
31
|
+
style.setAttribute("clay", "editor-ui");
|
|
32
|
+
style.textContent = INTRO_CSS;
|
|
33
|
+
document.head.appendChild(style);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function focusApp() {
|
|
37
|
+
const next = [...document.querySelectorAll(FOCUSABLE)].find(
|
|
38
|
+
(el) => !el.disabled && !isTextEntry(el) && !el.closest("[clay~='editor-ui']") && (typeof el.checkVisibility !== "function" || el.checkVisibility())
|
|
39
|
+
);
|
|
40
|
+
if (next) next.focus({ preventScroll: true });
|
|
41
|
+
else document.body.focus({ preventScroll: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mountIntro({ memory, template, appName, hadDraft }) {
|
|
45
|
+
if (!template || !template.content.textContent.trim() && !template.content.querySelector("img, svg, video, canvas")) return null;
|
|
46
|
+
if (!forced()) {
|
|
47
|
+
if (memory.get().intro.done || hadDraft) return null;
|
|
48
|
+
if (window.location.hash) return null;
|
|
49
|
+
if (window.matchMedia && window.matchMedia("print").matches) return null;
|
|
50
|
+
}
|
|
51
|
+
return mount({ memory, template, appName });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mount({ memory, template, appName }) {
|
|
55
|
+
stylesOnce();
|
|
56
|
+
|
|
57
|
+
const section = document.createElement("section");
|
|
58
|
+
section.className = "clay-intro";
|
|
59
|
+
section.setAttribute("clay", "editor-ui");
|
|
60
|
+
section.setAttribute("sap-ignore", "");
|
|
61
|
+
section.setAttribute("aria-label", COPY.label(appName));
|
|
62
|
+
section.appendChild(template.content.cloneNode(true));
|
|
63
|
+
|
|
64
|
+
const close = document.createElement("button");
|
|
65
|
+
close.type = "button";
|
|
66
|
+
close.className = "clay-intro__close";
|
|
67
|
+
close.setAttribute("aria-label", COPY.close);
|
|
68
|
+
close.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M1.4 0 6 4.6 10.6 0 12 1.4 7.4 6 12 10.6 10.6 12 6 7.4 1.4 12 0 10.6 4.6 6 0 1.4z"/></svg>';
|
|
69
|
+
section.appendChild(close);
|
|
70
|
+
|
|
71
|
+
let gone = false;
|
|
72
|
+
let frame = 0;
|
|
73
|
+
|
|
74
|
+
function dismiss(reason) {
|
|
75
|
+
if (gone) return;
|
|
76
|
+
gone = true;
|
|
77
|
+
removeEventListener("scroll", onScroll);
|
|
78
|
+
if (frame) cancelAnimationFrame(frame);
|
|
79
|
+
const height = section.getBoundingClientRect().height;
|
|
80
|
+
const before = window.scrollY;
|
|
81
|
+
memory.get().intro.done = reason;
|
|
82
|
+
memory.save();
|
|
83
|
+
section.remove();
|
|
84
|
+
if (reason === "scrolled") {
|
|
85
|
+
const locked = /hidden|clip/.test(getComputedStyle(document.body).overflowY + getComputedStyle(document.documentElement).overflowY);
|
|
86
|
+
window.scrollTo(0, locked ? 0 : Math.max(0, before - height));
|
|
87
|
+
}
|
|
88
|
+
announceBottomUi();
|
|
89
|
+
if (reason !== "scrolled") focusApp();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onScroll() {
|
|
93
|
+
if (frame) return;
|
|
94
|
+
frame = requestAnimationFrame(() => {
|
|
95
|
+
frame = 0;
|
|
96
|
+
const bottom = section.getBoundingClientRect().bottom;
|
|
97
|
+
const atEnd = scrollY > 0 && scrollY + innerHeight >= document.documentElement.scrollHeight - 1;
|
|
98
|
+
if (bottom < 1 || (atEnd && bottom < innerHeight)) dismiss("scrolled");
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
section.addEventListener("click", (event) => {
|
|
103
|
+
if (event.target.closest(".clay-intro__close, [visitor-intro-dismiss]")) {
|
|
104
|
+
dismiss("closed");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (event.target.closest("[visitor-intro-start]")) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
window.scrollTo({ top: window.scrollY + section.getBoundingClientRect().bottom, behavior: "smooth" });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
document.body.prepend(section);
|
|
114
|
+
announceBottomUi();
|
|
115
|
+
addEventListener("scroll", onScroll, { passive: true });
|
|
116
|
+
|
|
117
|
+
return section;
|
|
118
|
+
}
|