clay.js
  1. ClayJS
  2. Reference
  3. Plugins
  4. upload and quickcrop

upload and quickcrop

Send a file you supply to the host, and crop images before they are uploaded.

Two plugins for getting files into a page: upload sends a file you supply to the host and gives you its URL, and quickcrop crops an image before it is uploaded.

upload#

clay.upload(file) sends a file you supply (from a file input, a drop or a paste) to the host and resolves with its URL, which you write into the page. It opens no picker itself. The CMS uses it for image fields.

HTML
<script src="https://clayjs.com/v1/clay.js?plugins=upload"></script>
JavaScript
const result = await clay.upload(file);
// { ok, msg, msgType, code, uploads }
const url = result.ok ? result.uploads[0].url : null;

The result never rejects, so one shape covers every ending: the host stored the file, this host cannot store files at all (code: "unsupported"), or the request failed. A host that could not be reached for its metadata is a failure, code: "network", never unsupported: embed the file instead only on unsupported. clay.upload is one attempt of one upload and has nothing to cancel or retry. Use a task for that.

Tasks#

clay.upload.start(file, options) is the same transport and the same checks as clay.upload, plus an identity, so progress, cancellation and an explicit retry belong to one upload rather than to whichever callback fires.

JavaScript
const task = clay.upload.start(file, { requiredAccess: "document-or-local" });
const { unmount } = await clay.upload.mount(task, container);
const unsubscribe = task.subscribe(({ state, percent }) => draw(state, percent));

const result = await task.result;
task.cancel();
await task.retry();
unsubscribe();
unmount();
task.dispose();

start begins the first attempt before it returns, so subscribe calls back at once with the state the task is in. A snapshot is { id, attempt, state, loaded, total, percent, result }, where id stays the same for the life of the task, attempt increases only on an explicit retry(), and loaded, total and percent describe the attempt now running. Four states: uploading, succeeded, failed and cancelled.

  • task.result is the promise of the current attempt and has the same shape as clay.upload’s. It never rejects.
  • task.subscribe(fn) returns the function that stops the snapshots.
  • task.cancel() ends the attempt at once, even while the host’s metadata is still loading, and deletes nothing: the host may already hold the bytes.
  • task.retry() starts a new attempt of the same file and returns its promise, asking the host for fresh metadata first. It starts one only from cancelled, or from failed with a code a second try could change (network, timeout, error, bad-response, aborted); otherwise it returns the current result and sends nothing. So a second click while an attempt runs joins the attempt already going, and a refusal such as privacy-unsupported, forbidden or too-large is never sent again.
  • task.dispose() cancels and releases the file. A failed or cancelled task keeps its file, so retry() does not ask the person to pick it again.

A percent of 100 means the bytes were sent, not that the file is stored: the attempt stays uploading and the shared row reads “Finishing upload” until the host answers. Nothing retries automatically, and nothing falls back to a data URL, so a failed or cancelled attempt inserts nothing.

clay.upload.mount(task, container) draws the shared row for one task: the file name, a progress bar, Cancel while it runs, Retry where a retry could answer differently, and Dismiss once it has settled. container is an element or a selector. It resolves { unmount }, and the row and its stylesheet load on the first call, so a page that never uploads never downloads them. Everything it draws is marked clay="editor-ui", so none of it reaches a saved file or a sync frame.

Private media#

requiredAccess is how a caller says it cannot use a publicly readable URL. It takes three names:

requiredAccessAccepted from the host
"document"document: a host that binds each returned URL to that document’s live read policy
"local"local: a local host, where the bytes follow its own file-serving rules
"document-or-local"either of the two

The requirement is checked twice: against document.upload.access in the host’s metadata before any bytes are sent, and against the access on every upload the host returns. A host that promises neither, or an answer that drops the access it promised, settles as { ok: false, code: "privacy-unsupported" }, and the global event for that attempt is clay:upload-error, never clay:upload-done. The uploads stay in that result on purpose, so a person can still copy or download the URL deliberately; a caller that asked for privacy must not insert it on its own. An unknown name is a typo, not a host answer, and clay.upload and clay.upload.start throw for it before a request exists.

Read document.upload.access from the host’s /_/meta before offering private pictures. A missing value means public, and so does any value you do not recognize. Access is never inferred from the page URL, the plan or the filename.

Across a mode change#

upload is a participant of the mode lifecycle, with the id clay:upload and order 340. Switching without a reload describes how a participant is prepared, and Leaving edit how an exit refuses.

A pending upload refuses the exit until it finishes, and the exit flushes it: it waits on every attempt in flight and, while one is still uploading, refuses pending-input with pending: ["upload"]. A refused exit hands the same lease back, and uploads go on.

Admission is closed from the start of an exit until the commit, and while it is closed a start answers { ok: false, code: "aborted" }. A retry made then sends nothing: the task keeps its ending and its file, and the next entry asks again. With no mode lifecycle running, which is every page that never uses one, admission is open.

quickcrop#

A crop modal for image uploads: pick a file, drag a box, confirm. It resolves a cropped, re-encoded blob and a data URL. The CMS uses it for any image field marked data-hcms-crop, so ?plugins=cms loads it for you; ask for it by name to call it from your own code.

HTML
<img class="avatar" data-hcms-crop="1:1" src="avatar.jpg">
JavaScript
const shot = await clay.quickcrop(file, { aspect: 16/9 });
// { blob, dataURL, width, height }, or null if they cancelled

Aspects are 1:1, 16:9 or free on the attribute, a plain number in code. The modal and its stylesheet are marked save-remove, the legacy alias for no-save, so cropping chrome never reaches the saved file.

On Hyperclay#

On Hyperclay, uploads go to the host rather than into the file. A file uploaded from inside a document lands in that document’s own folder, uploads/assets-document-<document id>/, and is linked at /_/uploads/assets-document-<document id>/<file>. The folder is named after the document’s id, so renaming or moving the document keeps its pictures working. Per-file and total limits depend on the plan: see Uploads.

That folder also binds the upload to the document. On hosts that advertise access: "document" in document.upload.access, the bytes of a bound upload follow the document’s current read permission on every route that serves them: give a teammate access to the document and its pictures answer to that too; make it private and they stop answering for everyone who is not on its access list. The permission is checked on each request, so no URL has to be rewritten when it changes, and a request without access gets the same 404 as a file that does not exist. Bound files are sent Cache-Control: private, no-store, so no shared cache keeps a copy from a time when the document was readable by more people.

Two limits on that. Uploads made before this binding existed stay reachable by URL until the account’s protection migration has run, and Hyperclay advertises document only after the deployment setting and that account’s migration both say yes, so a missing access field means those uploads are still public. And a copy someone already downloaded while a URL was public cannot be taken back by any later change.