@panphora/clayjs 0.6.0 → 0.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 +13 -5
- package/_headers +12 -0
- package/clay-events.js +9 -0
- package/package.json +1 -1
- package/src/core/host-meta.js +107 -0
- package/src/core/save.js +14 -2
- package/src/loader-logic.js +22 -1
- package/src/loader.js +4 -0
- package/src/plugins/upload.js +185 -0
- package/src/vendor/hypercms.vendor.js +53 -11
- package/src/vendor/quickcrop.vendor.js +401 -0
package/README.md
CHANGED
|
@@ -19,8 +19,10 @@ query params on the script URL:
|
|
|
19
19
|
<script src="/clay.js?plugins=sync,cms&exclude=indicator"></script>
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
- `?plugins=` — add optional plugins: `sync`, `cms`
|
|
23
|
-
|
|
22
|
+
- `?plugins=` — add optional plugins: `sync`, `cms`, `undo`, `sortable`, `indicator`, `quickcrop`, `wire`.
|
|
23
|
+
Only `richclay` loads by default, and only in edit mode. `cms` brings `quickcrop` with it, because
|
|
24
|
+
the CMS uses it for `data-hcms-crop` image fields.
|
|
25
|
+
- `?exclude=` — drop a plugin that would otherwise load (a default, or one another plugin pulled in).
|
|
24
26
|
- `?editmode=false` — force view mode (URL param wins over everything below).
|
|
25
27
|
|
|
26
28
|
## Host attributes
|
|
@@ -52,8 +54,8 @@ clay.save();
|
|
|
52
54
|
|
|
53
55
|
Edit mode exposes `clay.save()` (+ `clay.save.force()`), `clay.getHTML()`, `clay.addDocumentTransform(fn)`,
|
|
54
56
|
`clay.onSnapshot(fn)`, `clay.toggleEditMode()`, `clay.isEditMode`, `clay.isOwner`, `clay.Mutation`,
|
|
55
|
-
`clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay`
|
|
56
|
-
when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
|
|
57
|
+
`clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay` /
|
|
58
|
+
`clay.quickcrop` when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
|
|
57
59
|
`Mutation`, `region`, `ready`); edit-only members are simply absent.
|
|
58
60
|
|
|
59
61
|
## API
|
|
@@ -70,12 +72,18 @@ The contract starts at **0.3.0**: no name below changes without a major version.
|
|
|
70
72
|
|
|
71
73
|
**`clay.js`** — `ready`, `save()`, `save.force()`, `getHTML()`, `addDocumentTransform(fn)`, `onSnapshot(fn)`,
|
|
72
74
|
`toggleEditMode()`, `isEditMode`, `isOwner`, `Mutation`, `region`, `cacheBust(el)`, plus `undo` / `cms` / `morph` /
|
|
73
|
-
`RichClay` when those plugins load. View mode keeps only the always-available members, as above.
|
|
75
|
+
`RichClay` / `quickcrop` when those plugins load. View mode keeps only the always-available members, as above.
|
|
74
76
|
|
|
75
77
|
`addDocumentTransform(fn)` runs your callback over a detached clone whenever the page
|
|
76
78
|
prepares to save AND whenever it checks whether anything changed. Keep it pure and
|
|
77
79
|
repeatable: it is a transform, not a "a save is happening" event.
|
|
78
80
|
|
|
81
|
+
`quickcrop(file, options)` opens a crop modal over a File or Blob and resolves
|
|
82
|
+
`{ blob, dataURL, width, height }`, or `null` if the person cancels. `aspect` (a number,
|
|
83
|
+
or null for freeform), `type`, `quality`, `maxWidth`/`maxHeight` and `labels.confirm` are
|
|
84
|
+
the options worth knowing. Its modal and injected stylesheet carry `save-remove`, so they
|
|
85
|
+
never reach the saved file.
|
|
86
|
+
|
|
79
87
|
`region` is the region-policy model: `resolveRegionPolicy`, `isInert`, `skipForPolicy`,
|
|
80
88
|
`strictestPolicy`, the `PERSIST` and `REGION_ATTRS` token constants, and the
|
|
81
89
|
`STRIP_FROM_SAVE`, `FREEZE_SELECTOR` and `STRIP_FROM_COMPARISON` selectors.
|
package/_headers
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
+
# Cache-Control is declared once, on /*. Two matching blocks do not override,
|
|
2
|
+
# they join: repeating this line under /src/* served every module a doubled
|
|
3
|
+
# max-age, which a cache is free to treat as stale. /src/* inherits it and adds
|
|
4
|
+
# only CORS.
|
|
5
|
+
#
|
|
6
|
+
# No stale-while-revalidate: clay.js imports ~49 unversioned module URLs, each
|
|
7
|
+
# with its own freshness clock, and a view-mode load fetches a different subset
|
|
8
|
+
# than an edit-mode load. A stale window lets one browser hold half the graph
|
|
9
|
+
# from before a deploy and half from after, which fails silently.
|
|
10
|
+
/*
|
|
11
|
+
Cache-Control: public, max-age=600
|
|
12
|
+
|
|
1
13
|
/src/*
|
|
2
14
|
Access-Control-Allow-Origin: *
|
package/clay-events.js
CHANGED
|
@@ -20,7 +20,16 @@
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// [onrender] sweeps the whole document the moment that import resolves, and those
|
|
24
|
+
// handlers routinely reach for clay-dom's element helpers (this.val, this.exec,
|
|
25
|
+
// this.nearest). The two satellites are independent dynamic imports with nothing
|
|
26
|
+
// ordering them, so on a cold load events can win and every handler throws
|
|
27
|
+
// "Cannot read properties of undefined". By DOM ready every satellite tag has run,
|
|
28
|
+
// so clay.loaded.dom is present exactly when clay-dom.js is on the page: wait for it
|
|
29
|
+
// then, proceed when it is absent. A clay-dom that failed to load must not take
|
|
30
|
+
// events down with it, so its rejection is swallowed here rather than chained.
|
|
23
31
|
clay.loaded.events = domReady()
|
|
32
|
+
.then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
|
|
24
33
|
.then(function () { return import(base + "/src/events/index.js"); })
|
|
25
34
|
.catch(function (err) { console.error("clay-events failed to load:", err); throw err; });
|
|
26
35
|
// Mark handled: see clay-ui.js.
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host-meta.js — what this host can do (spec §5).
|
|
3
|
+
*
|
|
4
|
+
* The first discovery client clayjs has had. Not fetched at boot: the first
|
|
5
|
+
* caller is the first file pick, so a page that never uploads never asks.
|
|
6
|
+
*
|
|
7
|
+
* The spec's own rule about what counts as an answer is deliberately strict on
|
|
8
|
+
* the way in and forgiving on the way out. Only a 2xx carrying a JSON object with
|
|
9
|
+
* a numeric `spec` is a capability document. A 404, an HTML error page from a
|
|
10
|
+
* proxy, a redirect, a body that will not parse: all of them mean "bare core
|
|
11
|
+
* host", and a bare core host is fully conforming. Discovery failing must never
|
|
12
|
+
* cost a person their save, so this never throws and never rejects.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { saveToken } from "./host-attrs.js";
|
|
16
|
+
|
|
17
|
+
const META_PATH = "/_/meta";
|
|
18
|
+
const META_TIMEOUT_MS = 6000;
|
|
19
|
+
|
|
20
|
+
// The bare-core-host answer, which is also every failure's answer.
|
|
21
|
+
function bareHost() {
|
|
22
|
+
return { spec: null, extensions: [], document: null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ask the host what it supports, once per page.
|
|
29
|
+
*
|
|
30
|
+
* Memoizes the PROMISE rather than the value, so two pickers opened together
|
|
31
|
+
* make one request instead of two and the second waits on the first.
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<{spec: ?number, extensions: string[], document: ?Object}>}
|
|
34
|
+
*/
|
|
35
|
+
export function hostMeta() {
|
|
36
|
+
if (!inFlight) inFlight = fetchMeta().catch(bareHost);
|
|
37
|
+
return inFlight;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True when the host announced this capability by name.
|
|
42
|
+
*
|
|
43
|
+
* §5: a client must not infer a host's capabilities any other way. Not from the
|
|
44
|
+
* hostname, not from the port, not by probing a route to see whether it answers.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} name
|
|
47
|
+
* @returns {Promise<boolean>}
|
|
48
|
+
*/
|
|
49
|
+
export async function hostSupports(name) {
|
|
50
|
+
const meta = await hostMeta();
|
|
51
|
+
return meta.extensions.includes(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop the memoized answer. Tests only. */
|
|
55
|
+
export function resetHostMeta() {
|
|
56
|
+
inFlight = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchMeta() {
|
|
60
|
+
const token = saveToken();
|
|
61
|
+
// A host that mints tokens answers discovery per token, because on a sandboxed
|
|
62
|
+
// document the token is the only identity there is: the browser gives it an
|
|
63
|
+
// opaque origin, so it holds no cookie and gets the answer any stranger would.
|
|
64
|
+
// Without this the `document` block, which carries whether this person may
|
|
65
|
+
// upload and how large a file the host takes, is unreachable on exactly the
|
|
66
|
+
// hosts that mint tokens.
|
|
67
|
+
const path = token ? `${META_PATH}/${token}` : META_PATH;
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => controller.abort(), META_TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(new URL(path, window.location.origin).href, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
// Same rule as the save lane. The token IS the credential, and a host that
|
|
75
|
+
// mints per-document tokens must never send Access-Control-Allow-Credentials
|
|
76
|
+
// back, so asking for cookies gets the answer blocked before it is read.
|
|
77
|
+
credentials: token ? "omit" : "same-origin",
|
|
78
|
+
headers: { "Document-URL": window.location.href },
|
|
79
|
+
cache: "no-store",
|
|
80
|
+
// A redirect is not an answer (§5), and following one would send the
|
|
81
|
+
// Document-URL header somewhere this host did not choose.
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: controller.signal
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return bareHost();
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (!text) return bareHost();
|
|
88
|
+
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
body = JSON.parse(text);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
return bareHost();
|
|
94
|
+
}
|
|
95
|
+
if (!body || typeof body !== "object" || typeof body.spec !== "number") return bareHost();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
spec: body.spec,
|
|
99
|
+
extensions: Array.isArray(body.extensions) ? body.extensions : [],
|
|
100
|
+
document: body.document && typeof body.document === "object" ? body.document : null
|
|
101
|
+
};
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return bareHost();
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/core/save.js
CHANGED
|
@@ -21,11 +21,23 @@ import {
|
|
|
21
21
|
} from "./save-core.js";
|
|
22
22
|
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
23
|
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
24
|
+
import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
|
|
24
25
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
+
// Keep this library's own root state out of the saved bytes.
|
|
28
|
+
//
|
|
29
|
+
// This used to write `savestatus="saved"` onto the clone instead of removing it,
|
|
30
|
+
// which stopped a mid-save "saving" from being baked in but still put a library
|
|
31
|
+
// attribute on disk. It is there today in four LOCAL_APPS documents. These three
|
|
32
|
+
// are this tab's UI truth, re-stamped on every load by edit-mode and by the save
|
|
33
|
+
// lane itself, so a stored copy is at best noise and at worst a lie: a file whose
|
|
34
|
+
// disk bytes say `savestatus="saved"` reads as saved before clayjs has booted.
|
|
35
|
+
//
|
|
36
|
+
// Scoped to the ROOT on purpose. `savestatus` on any other element is an authored
|
|
37
|
+
// attribute that `option:savestatus` reads, and stripping those would delete page
|
|
38
|
+
// content. Both clones get this, so the dirty comparison sees no difference.
|
|
27
39
|
addDocumentTransform(clone => {
|
|
28
|
-
clone.
|
|
40
|
+
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
29
41
|
});
|
|
30
42
|
|
|
31
43
|
// ============================================
|
package/src/loader-logic.js
CHANGED
|
@@ -21,11 +21,27 @@ export const PLUGIN_PATHS = {
|
|
|
21
21
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
22
22
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
23
23
|
cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
|
|
24
|
+
quickcrop: { path: "vendor/quickcrop.vendor.js", editOnly: false, default: false },
|
|
25
|
+
// editOnly, because a file picker only ever appears in edit mode: the cms
|
|
26
|
+
// injects its own editing toggle there and clayjs's edit-mode signal is a
|
|
27
|
+
// superset of the cms's, so the plugin is present exactly when it can be used.
|
|
28
|
+
upload: { path: "plugins/upload.js", editOnly: true, default: false },
|
|
24
29
|
wire: { path: "plugins/wire.js", editOnly: false, default: false },
|
|
25
30
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
26
31
|
};
|
|
27
32
|
|
|
28
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "wire", "demo"];
|
|
33
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo"];
|
|
34
|
+
|
|
35
|
+
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
36
|
+
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
37
|
+
// finds nothing, so `plugins=cms` has to bring quickcrop with it or image crop is
|
|
38
|
+
// dead with no error and no log. quickcrop loads BEFORE cms in the order above,
|
|
39
|
+
// because the loader attaches each plugin's member as it lands and cms reads what
|
|
40
|
+
// earlier plugins attached during its own evaluation.
|
|
41
|
+
// `upload` is deliberately NOT implied by cms yet. Adding it flips how an
|
|
42
|
+
// existing page behaves, from embedding an image to storing it, and that is
|
|
43
|
+
// isolated into its own one-line release so it can be reverted alone.
|
|
44
|
+
const IMPLIES = { cms: ["quickcrop"] };
|
|
29
45
|
|
|
30
46
|
function parseCsv(params, key, enabled, apply) {
|
|
31
47
|
const raw = params.get(key);
|
|
@@ -50,6 +66,11 @@ export function resolveModules(params, isEditMode) {
|
|
|
50
66
|
if (spec.default) enabled.add(name);
|
|
51
67
|
}
|
|
52
68
|
parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
|
|
69
|
+
// Between the two: exclude still wins, so `plugins=cms&exclude=quickcrop` opts
|
|
70
|
+
// back out of the cropper.
|
|
71
|
+
for (const name of [...enabled]) {
|
|
72
|
+
for (const implied of IMPLIES[name] || []) enabled.add(implied);
|
|
73
|
+
}
|
|
53
74
|
parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
|
|
54
75
|
|
|
55
76
|
const plugins = [];
|
package/src/loader.js
CHANGED
|
@@ -102,12 +102,16 @@ function attachPluginMember(path, mod) {
|
|
|
102
102
|
clay.morph = mod.morph;
|
|
103
103
|
} else if (path === "vendor/hypercms.vendor.js") {
|
|
104
104
|
clay.cms = mod.cms || mod.default;
|
|
105
|
+
} else if (path === "plugins/upload.js") {
|
|
106
|
+
clay.upload = mod.upload || mod.default;
|
|
105
107
|
} else if (path === "plugins/wire.js") {
|
|
106
108
|
clay.wire = mod.wire || mod.default;
|
|
107
109
|
} else if (path === "plugins/demo.js") {
|
|
108
110
|
clay.demo = mod.demo;
|
|
109
111
|
} else if (path === "vendor/richclay.vendor.js") {
|
|
110
112
|
clay.RichClay = mod.RichClay || mod.default;
|
|
113
|
+
} else if (path === "vendor/quickcrop.vendor.js") {
|
|
114
|
+
clay.quickcrop = mod.quickcrop || mod.default;
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upload.js — store a file on the host instead of embedding it (spec §9).
|
|
3
|
+
*
|
|
4
|
+
* The problem this exists for: a document that cannot upload has to put the image
|
|
5
|
+
* INSIDE itself as a data: URL. A two megabyte photo then costs 2.7 MB of base64
|
|
6
|
+
* on this save, on every future save, and in every stored version. Embedding is
|
|
7
|
+
* the right answer for a file nobody is serving, and the wrong answer everywhere
|
|
8
|
+
* else, and until now clayjs had no way to tell the difference.
|
|
9
|
+
*
|
|
10
|
+
* const result = await clay.upload(file, { onProgress, signal });
|
|
11
|
+
* // { ok, msg, msgType, code, uploads }
|
|
12
|
+
*
|
|
13
|
+
* The result NEVER rejects, matching the save lane. One shape from every exit,
|
|
14
|
+
* because two channels for three outcomes (stored, cannot store here, failed) is
|
|
15
|
+
* the exact pattern save-core.js documents as the source of its own past bugs: a
|
|
16
|
+
* "cannot store here" thrown as an error reads as a failure, and a caller that
|
|
17
|
+
* catches it embeds when it should have stopped, or stops when it should have
|
|
18
|
+
* embedded.
|
|
19
|
+
*
|
|
20
|
+
* Codes a caller branches on:
|
|
21
|
+
* unsupported this host does not store files; embedding is correct
|
|
22
|
+
* payment-required the host stores files, but not for this account
|
|
23
|
+
* too-large over the host's cap, refused here or by the host
|
|
24
|
+
* unsupported-type the host will not store this kind of file
|
|
25
|
+
* timeout no answer; the upload may or may not have landed
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { hostMeta } from "../core/host-meta.js";
|
|
29
|
+
import { saveToken } from "../core/host-attrs.js";
|
|
30
|
+
|
|
31
|
+
const UPLOAD_PATH = "/_/upload";
|
|
32
|
+
const UPLOAD_TIMEOUT_MS = 120000;
|
|
33
|
+
|
|
34
|
+
function result(ok, msg, msgType, code, uploads = []) {
|
|
35
|
+
return { ok, msg, msgType, code, uploads };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function emit(name, detail) {
|
|
39
|
+
if (typeof document === "undefined") return;
|
|
40
|
+
document.dispatchEvent(new CustomEvent(`clay:upload-${name}`, { detail }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Store one file on the host.
|
|
45
|
+
*
|
|
46
|
+
* @param {File|Blob} file
|
|
47
|
+
* @param {Object} [options]
|
|
48
|
+
* @param {function} [options.onProgress] - ({loaded, total, percent})
|
|
49
|
+
* @param {AbortSignal} [options.signal]
|
|
50
|
+
* @returns {Promise<{ok: boolean, msg: string, msgType: string, code: ?string, uploads: Array}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function upload(file, { onProgress, signal } = {}) {
|
|
53
|
+
if (!file) return result(false, "No file to upload", "error", "bad-request");
|
|
54
|
+
|
|
55
|
+
const meta = await hostMeta();
|
|
56
|
+
if (!meta.extensions.includes("upload")) {
|
|
57
|
+
// Not an error. This is a document open from a plain file server, or a bare
|
|
58
|
+
// core host, and embedding is what it should do.
|
|
59
|
+
return result(false, "This host does not store uploaded files", "skipped", "unsupported");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The one local pre-check the spec asks for, and the only reason `maxBytes` is
|
|
63
|
+
// published: refusing a 40 MB photo here costs nothing, and sending it to be
|
|
64
|
+
// refused costs the person the whole upload first. Deliberately the ONLY thing
|
|
65
|
+
// decided locally. `document.upload.allowed` is not read as a veto: it can be
|
|
66
|
+
// stale (a plan upgraded in another tab, a share link granted since load), and
|
|
67
|
+
// the host answers the real reason with its own code on the request itself.
|
|
68
|
+
const cap = meta.document?.upload?.maxBytes;
|
|
69
|
+
if (typeof cap === "number" && cap > 0 && file.size > cap) {
|
|
70
|
+
return result(false, `That file is larger than this host accepts (${cap} bytes)`, "error", "too-large");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return send(file, { onProgress, signal });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send(file, { onProgress, signal }) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const token = saveToken();
|
|
79
|
+
const path = token ? `${UPLOAD_PATH}/${token}` : UPLOAD_PATH;
|
|
80
|
+
// Absolute against the real origin, never left relative: a <base href> in the
|
|
81
|
+
// authored document would otherwise redirect this request, and the
|
|
82
|
+
// per-document token in its path, to an origin the document chose.
|
|
83
|
+
const url = new URL(path, window.location.origin).href;
|
|
84
|
+
|
|
85
|
+
// XMLHttpRequest for one reason: fetch cannot report upload progress. A photo
|
|
86
|
+
// over a phone connection is the case this whole capability is for, and a
|
|
87
|
+
// picker that sits there saying nothing for twenty seconds reads as broken.
|
|
88
|
+
const xhr = new XMLHttpRequest();
|
|
89
|
+
let settled = false;
|
|
90
|
+
|
|
91
|
+
const finish = (value) => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timeoutId);
|
|
95
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
96
|
+
resolve(value);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const fail = (msg, msgType, code) => {
|
|
100
|
+
emit("error", { msg, code });
|
|
101
|
+
finish(result(false, msg, msgType, code));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const timeoutId = setTimeout(() => {
|
|
105
|
+
xhr.abort();
|
|
106
|
+
// A timeout is not evidence the upload failed. The bytes may well have
|
|
107
|
+
// landed, so this says "we do not know" rather than asserting a failure,
|
|
108
|
+
// and the content-hash name makes the retry idempotent if the caller resends.
|
|
109
|
+
fail("Upload timed out", "unknown", "timeout");
|
|
110
|
+
}, UPLOAD_TIMEOUT_MS);
|
|
111
|
+
|
|
112
|
+
const onAbort = () => {
|
|
113
|
+
xhr.abort();
|
|
114
|
+
finish(result(false, "Upload cancelled", "skipped", "aborted"));
|
|
115
|
+
};
|
|
116
|
+
if (signal) {
|
|
117
|
+
if (signal.aborted) return onAbort();
|
|
118
|
+
signal.addEventListener("abort", onAbort);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
122
|
+
if (!event.lengthComputable) return;
|
|
123
|
+
const detail = {
|
|
124
|
+
loaded: event.loaded,
|
|
125
|
+
total: event.total,
|
|
126
|
+
percent: Math.round((event.loaded / event.total) * 100)
|
|
127
|
+
};
|
|
128
|
+
emit("progress", detail);
|
|
129
|
+
if (onProgress) {
|
|
130
|
+
try { onProgress(detail); } catch (_) { /* a caller's drawing must not kill the upload */ }
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
xhr.addEventListener("load", () => {
|
|
135
|
+
let body = {};
|
|
136
|
+
try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) { body = {}; }
|
|
137
|
+
|
|
138
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
139
|
+
const uploads = Array.isArray(body.uploads) ? body.uploads : [];
|
|
140
|
+
if (!uploads.length || typeof uploads[0]?.url !== "string") {
|
|
141
|
+
return fail("The host accepted the file but did not say where it put it", "error", "bad-response");
|
|
142
|
+
}
|
|
143
|
+
emit("done", { uploads });
|
|
144
|
+
return finish(result(true, body.msg ?? "Uploaded", body.msgType || "success", body.code ?? null, uploads));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The host's own code when it sent one, so a caller branches on the reason
|
|
148
|
+
// rather than pattern-matching a message. Falling back to the status keeps
|
|
149
|
+
// a host that answers a bare 413 readable.
|
|
150
|
+
const code = body.code || STATUS_CODES[xhr.status] || "error";
|
|
151
|
+
return fail(body.msg || `Upload failed (${xhr.status})`, "error", code);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
xhr.addEventListener("error", () => fail("Upload failed", "error", "network"));
|
|
155
|
+
xhr.addEventListener("abort", () => finish(result(false, "Upload cancelled", "skipped", "aborted")));
|
|
156
|
+
|
|
157
|
+
xhr.open("POST", url, true);
|
|
158
|
+
// The exact equivalent of the save lane's `credentials: 'omit'` in the case
|
|
159
|
+
// where the two differ: a sandboxed document is cross-origin to its own host,
|
|
160
|
+
// and asking for credentials there needs Access-Control-Allow-Credentials
|
|
161
|
+
// back, which a token-minting host must never send.
|
|
162
|
+
xhr.withCredentials = false;
|
|
163
|
+
xhr.setRequestHeader("Document-URL", window.location.href);
|
|
164
|
+
|
|
165
|
+
// FormData, so the file streams as bytes. No FileReader and no base64: a
|
|
166
|
+
// whole-file string costs a third more bytes and holds the entire file in
|
|
167
|
+
// memory twice.
|
|
168
|
+
const form = new FormData();
|
|
169
|
+
form.append("file", file, file.name || "upload");
|
|
170
|
+
|
|
171
|
+
emit("start", { name: file.name || null, size: file.size ?? null });
|
|
172
|
+
xhr.send(form);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const STATUS_CODES = {
|
|
177
|
+
402: "payment-required",
|
|
178
|
+
413: "too-large",
|
|
179
|
+
415: "unsupported-type",
|
|
180
|
+
401: "unauthorized",
|
|
181
|
+
403: "forbidden",
|
|
182
|
+
404: "not-found"
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export default upload;
|