@jxsuite/studio 10.2.0 → 10.3.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/dist/chunks/{2020-bx702wqa.js → 2020-906vx2dq.js} +3 -3
- package/dist/chunks/adapter-hnvad61x.js +4 -0
- package/dist/chunks/add-new-flow-3tkn72ey.js +4 -0
- package/dist/chunks/cf-account-picker-xb8vvt2h.js +4 -0
- package/dist/chunks/cf-settings-mkbcz4z2.js +4 -0
- package/dist/chunks/components-w5zqwva1.js +4 -0
- package/dist/chunks/content-source-1vzxq1ba.js +4 -0
- package/dist/chunks/convert-to-repeater-gwzh9gar.js +4 -0
- package/dist/chunks/editor-wvf5e6j9.js +4 -0
- package/dist/chunks/entry-commands-mx2whrjx.js +4 -0
- package/dist/chunks/extension-sections-338ee35t.js +4 -0
- package/dist/chunks/file-ops-9bxvk59j.js +4 -0
- package/dist/chunks/files-t07yqqej.js +4 -0
- package/dist/chunks/format-host-5ja5gxyk.js +4 -0
- package/dist/chunks/fs-events-3424ezp3.js +4 -0
- package/dist/chunks/grid-open-1mpq9mzb.js +4 -0
- package/dist/chunks/{grid-view-tghcwrrt.js → grid-view-w9nfy203.js} +16 -16
- package/dist/chunks/icons-gb967qc8.js +4 -0
- package/dist/chunks/iframe-host-wsg0bn73.js +4 -0
- package/dist/chunks/image-bake-4afr4y6a.js +4 -0
- package/dist/chunks/image-editor-x7t8jbjn.js +4 -0
- package/dist/chunks/index-f9qr7kzh.js +4 -0
- package/dist/chunks/{index-vr006kbt.js → index-yjrfeks8.js} +3 -3
- package/dist/chunks/{javascript-z1g3s9fd.js → javascript-hy2mws8v.js} +3 -3
- package/dist/chunks/{jsonMode-7szwg8c4.js → jsonMode-vj30te2h.js} +6 -6
- package/dist/chunks/jx-validate-ttr5ewf0.js +4 -0
- package/dist/chunks/layers-m49nmwvx.js +4 -0
- package/dist/chunks/layers-panel-hvy2e549.js +4 -0
- package/dist/chunks/lit-html-k2mb9k0r.js +4 -0
- package/dist/chunks/media-library-ae67q4at.js +4 -0
- package/dist/chunks/media-open-yp9jm834.js +4 -0
- package/dist/chunks/media-picker-s41ngdfr.js +4 -0
- package/dist/chunks/{monaco-binding-v42w9c1v.js → monaco-binding-a51py33c.js} +3 -3
- package/dist/chunks/monaco-buffer-pa0g6ax7.js +4 -0
- package/dist/chunks/{monaco-setup-7bffxd6v.js → monaco-setup-g9kterhx.js} +3 -3
- package/dist/chunks/notify-bwehrp7q.js +4 -0
- package/dist/chunks/parse-hee7ne9b.js +4 -0
- package/dist/chunks/platform-kv0k65cy.js +4 -0
- package/dist/chunks/publish-panel-gzfxcm0t.js +4 -0
- package/dist/chunks/publish-quick-w3nxd7q4.js +4 -0
- package/dist/chunks/publish-workspace-dppd82tc.js +4 -0
- package/dist/chunks/quick-search-tdxaam55.js +4 -0
- package/dist/chunks/references-vqyr5s9x.js +4 -0
- package/dist/chunks/right-panel-0yrw4cgz.js +4 -0
- package/dist/chunks/source-control-6fk72me2.js +4 -0
- package/dist/chunks/{studio-j3d2fjqz.js → studio-0939t1q1.js} +4 -4
- package/dist/chunks/studio-0gvvqj3h.js +5 -0
- package/dist/chunks/studio-0khd8xtx.js +4 -0
- package/dist/chunks/studio-0w3541dg.js +7 -0
- package/dist/chunks/{studio-9b7177jq.js → studio-0x6f3nk9.js} +4 -4
- package/dist/chunks/studio-1410q5dw.js +5 -0
- package/dist/chunks/{studio-awxwjawq.js → studio-15hwqgd8.js} +4 -4
- package/dist/chunks/studio-1ks5d9y4.js +5 -0
- package/dist/chunks/{studio-881z1xrx.js → studio-2ggfzrrd.js} +4 -4
- package/dist/chunks/{studio-4jhw38b2.js → studio-321ym1tt.js} +4 -4
- package/dist/chunks/studio-3c10v2q1.js +5 -0
- package/dist/chunks/{studio-bvs9kpst.js → studio-3na1r48j.js} +8 -8
- package/dist/chunks/{studio-86r8hp7r.js → studio-3q7mhbgx.js} +5 -5
- package/dist/chunks/studio-3qr08sxq.js +4 -0
- package/dist/chunks/studio-3xk9zdgb.js +5 -0
- package/dist/chunks/{studio-qh8berdz.js → studio-423tfdte.js} +4 -4
- package/dist/chunks/{studio-75kcdbsk.js → studio-45mj4jqe.js} +4 -4
- package/dist/chunks/studio-4f7thq0p.js +5 -0
- package/dist/chunks/studio-4fnb5648.js +5 -0
- package/dist/chunks/studio-4zd6an2b.js +5 -0
- package/dist/chunks/studio-53wy9ek3.js +5 -0
- package/dist/chunks/studio-5n193zqd.js +5 -0
- package/dist/chunks/{studio-9rbe2xpw.js → studio-5tet77de.js} +3 -3
- package/dist/chunks/{studio-ybnwymxw.js → studio-7rrdbvex.js} +4 -4
- package/dist/chunks/studio-8kx6m0rg.js +5 -0
- package/dist/chunks/{studio-sxdb9h3q.js → studio-90zkgne5.js} +7 -7
- package/dist/chunks/studio-9c6cbq2c.js +4 -0
- package/dist/chunks/studio-9dq7rwc7.js +5 -0
- package/dist/chunks/{studio-cg1bfyhf.js → studio-9qx1ywbv.js} +8 -8
- package/dist/chunks/studio-a65kbs2e.js +9 -0
- package/dist/chunks/studio-aam8zt8z.js +5 -0
- package/dist/chunks/{studio-er5f383a.js → studio-abh9kfdh.js} +4 -4
- package/dist/chunks/studio-b4e0ftkt.js +5 -0
- package/dist/chunks/studio-b68k0z00.js +5 -0
- package/dist/chunks/{studio-399a25za.js → studio-bct64zmj.js} +4 -4
- package/dist/chunks/studio-bz0b5brj.js +6 -0
- package/dist/chunks/studio-cfnn70h1.js +5 -0
- package/dist/chunks/studio-daksw8kh.js +5 -0
- package/dist/chunks/{studio-r7pf0rkv.js → studio-e7vjyp04.js} +7 -7
- package/dist/chunks/studio-ea6r1zyr.js +5 -0
- package/dist/chunks/studio-egnjpyde.js +9 -0
- package/dist/chunks/studio-ejrypvmq.js +5 -0
- package/dist/chunks/studio-eqcddvc2.js +928 -0
- package/dist/chunks/studio-f8tyeyzt.js +5 -0
- package/dist/chunks/studio-fj6c905s.js +5 -0
- package/dist/chunks/{studio-646nn2ys.js → studio-fkrr7kgg.js} +5 -5
- package/dist/chunks/studio-fspe7m9t.js +5 -0
- package/dist/chunks/studio-g0hxtcga.js +5 -0
- package/dist/chunks/{studio-th7z3fg7.js → studio-g9brbewz.js} +4 -4
- package/dist/chunks/studio-ggx3j7nd.js +5 -0
- package/dist/chunks/{studio-43y04qck.js → studio-hfxttkyr.js} +4 -4
- package/dist/chunks/studio-hh394phe.js +5 -0
- package/dist/chunks/{studio-pjtntksy.js → studio-j0zf5x2x.js} +4 -4
- package/dist/chunks/studio-jeb7hth3.js +5 -0
- package/dist/chunks/studio-k0xzrse2.js +64 -0
- package/dist/chunks/studio-k1xqrgm5.js +5 -0
- package/dist/chunks/studio-k2evngrj.js +7 -0
- package/dist/chunks/studio-k3we3kqb.js +5 -0
- package/dist/chunks/{studio-sevbc3xv.js → studio-n9qba0gx.js} +4 -4
- package/dist/chunks/{studio-6hzdjfv0.js → studio-nf8esxxc.js} +4 -4
- package/dist/chunks/studio-nn4f9m9c.js +5 -0
- package/dist/chunks/studio-nskxe9q1.js +6 -0
- package/dist/chunks/{studio-ea74s8c1.js → studio-nvcn2ykc.js} +4 -4
- package/dist/chunks/studio-nwynxqtb.js +5 -0
- package/dist/chunks/studio-nzemfbkk.js +14 -0
- package/dist/chunks/studio-p2a03pgm.js +5 -0
- package/dist/chunks/{studio-4g8yb6jn.js → studio-pm17wdc4.js} +4 -4
- package/dist/chunks/{studio-71m92v9s.js → studio-q80fg1d6.js} +7 -7
- package/dist/chunks/studio-r050sez0.js +6 -0
- package/dist/chunks/studio-s7t4vp1d.js +6 -0
- package/dist/chunks/studio-s9fng7m8.js +7 -0
- package/dist/chunks/studio-sce30r7b.js +4 -0
- package/dist/chunks/studio-stahswgb.js +7 -0
- package/dist/chunks/studio-swedpd82.js +5 -0
- package/dist/chunks/studio-thz5t120.js +5 -0
- package/dist/chunks/studio-tpxcryf2.js +5 -0
- package/dist/chunks/{studio-wc6za3zs.js → studio-v5wktj6f.js} +4 -4
- package/dist/chunks/{studio-kg1abnz0.js → studio-vpevkfxw.js} +4 -4
- package/dist/chunks/studio-waekn290.js +5 -0
- package/dist/chunks/studio-wfct4h6n.js +5 -0
- package/dist/chunks/studio-wgc54sxf.js +5 -0
- package/dist/chunks/studio-wqfaqw4m.js +5 -0
- package/dist/chunks/studio-wse1rs8t.js +7 -0
- package/dist/chunks/studio-x0gsfr1d.js +16 -0
- package/dist/chunks/{studio-hhp4x8xf.js → studio-xpx91fby.js} +5 -5
- package/dist/chunks/studio-y1b86ast.js +5 -0
- package/dist/chunks/studio-yrcrncev.js +5 -0
- package/dist/chunks/{studio-rzqaz21g.js → studio-yzpskb0j.js} +4 -4
- package/dist/chunks/studio-zfdbmhp3.js +5 -0
- package/dist/chunks/{tsMode-f54j66ym.js → tsMode-jg1dtphj.js} +7 -7
- package/dist/chunks/workspace-9btkc8tk.js +4 -0
- package/dist/chunks/workspace-home-qcc4z5ag.js +4 -0
- package/dist/chunks/{ws-client-e8kj684q.js → ws-client-arz2qsy9.js} +3 -3
- package/dist/iframe-entry.js +13 -13
- package/dist/manifest.json +83 -78
- package/dist/studio.js +84 -84
- package/package.json +2 -2
- package/src/browse/library-pane.ts +44 -29
- package/src/browse/library-preview.ts +6 -2
- package/src/canvas/asset-refs.ts +13 -4
- package/src/canvas/asset-resolve.ts +54 -11
- package/src/canvas/canvas-render.ts +25 -0
- package/src/canvas/iframe-entry.ts +5 -1
- package/src/canvas/iframe-host.ts +16 -6
- package/src/canvas/iframe-protocol.ts +9 -1
- package/src/commands/context.ts +5 -0
- package/src/commands/live-context.ts +5 -2
- package/src/editor/shortcuts.ts +3 -0
- package/src/files/asset-versions.ts +108 -2
- package/src/files/file-ops.ts +11 -1
- package/src/files/files.ts +111 -48
- package/src/files/media-paths.ts +12 -4
- package/src/files/media-upload.ts +195 -21
- package/src/files/new-file-name.ts +39 -1
- package/src/media/image-bake.ts +164 -0
- package/src/media/image-editor.ts +846 -0
- package/src/media/image-format.ts +262 -0
- package/src/media/image-recipe.ts +651 -0
- package/src/media/media-commands.ts +65 -1
- package/src/media/media-library-model.ts +1 -1
- package/src/media/media-library.ts +175 -15
- package/src/media/media-open.ts +156 -3
- package/src/media/media-pane.ts +8 -4
- package/src/panels/pane-context.ts +6 -1
- package/src/panels/tab-strip.ts +12 -3
- package/src/studio.ts +13 -2
- package/src/surfaces/image-editor.json +1390 -0
- package/src/surfaces/image-editor.ts +272 -0
- package/src/surfaces/media-library.json +129 -1
- package/src/surfaces/media-library.ts +17 -2
- package/src/types.ts +2 -1
- package/src/workspace/tab-workspace.ts +2 -1
- package/src/workspace/workspace-switch.ts +3 -1
- package/src/workspace/workspace.ts +22 -1
- package/dist/chunks/adapter-pp011m5x.js +0 -4
- package/dist/chunks/add-new-flow-ecqmzz76.js +0 -4
- package/dist/chunks/cf-account-picker-c07znkqt.js +0 -4
- package/dist/chunks/cf-settings-0v2bmqy0.js +0 -4
- package/dist/chunks/components-yn0dn2pq.js +0 -4
- package/dist/chunks/content-source-g0j41yy6.js +0 -4
- package/dist/chunks/convert-to-repeater-sbjk7tgn.js +0 -4
- package/dist/chunks/editor-xz02jeh1.js +0 -4
- package/dist/chunks/entry-commands-kmt02crp.js +0 -4
- package/dist/chunks/extension-sections-0rprf12d.js +0 -4
- package/dist/chunks/file-ops-mexj5qed.js +0 -4
- package/dist/chunks/files-b2tcppdd.js +0 -4
- package/dist/chunks/format-host-btsevv0c.js +0 -4
- package/dist/chunks/fs-events-r0apejva.js +0 -4
- package/dist/chunks/grid-open-n2a8xkht.js +0 -4
- package/dist/chunks/icons-853n6qt3.js +0 -4
- package/dist/chunks/iframe-host-gx0ypyh3.js +0 -4
- package/dist/chunks/index-bs4k9tr6.js +0 -4
- package/dist/chunks/jx-validate-ymf10teh.js +0 -4
- package/dist/chunks/layers-p9nfya38.js +0 -4
- package/dist/chunks/layers-panel-tt1hzhhf.js +0 -4
- package/dist/chunks/lit-html-x18n2sfz.js +0 -4
- package/dist/chunks/media-library-0n45gv8r.js +0 -4
- package/dist/chunks/media-picker-s09at95z.js +0 -4
- package/dist/chunks/monaco-buffer-een2ea0v.js +0 -4
- package/dist/chunks/notify-qwb8radt.js +0 -4
- package/dist/chunks/parse-v77xtwv7.js +0 -4
- package/dist/chunks/platform-8jnez0wr.js +0 -4
- package/dist/chunks/publish-panel-aysnj611.js +0 -4
- package/dist/chunks/publish-quick-kpbqs9mz.js +0 -4
- package/dist/chunks/publish-workspace-nd0a8q99.js +0 -4
- package/dist/chunks/quick-search-vg0jkhk2.js +0 -4
- package/dist/chunks/references-a7v6a0e2.js +0 -4
- package/dist/chunks/right-panel-cvk1vxx5.js +0 -4
- package/dist/chunks/source-control-fsqzc6he.js +0 -4
- package/dist/chunks/studio-05jfwbmf.js +0 -5
- package/dist/chunks/studio-0q5jwddh.js +0 -9
- package/dist/chunks/studio-0w2dr0q8.js +0 -5
- package/dist/chunks/studio-1tzpds97.js +0 -9
- package/dist/chunks/studio-216b27sv.js +0 -4
- package/dist/chunks/studio-2ejhqns4.js +0 -5
- package/dist/chunks/studio-2r90cts3.js +0 -16
- package/dist/chunks/studio-399xknkb.js +0 -6
- package/dist/chunks/studio-4ncmwqw5.js +0 -5
- package/dist/chunks/studio-5atwjgkj.js +0 -7
- package/dist/chunks/studio-5frqk7c0.js +0 -5
- package/dist/chunks/studio-6cwj6xrx.js +0 -5
- package/dist/chunks/studio-78pywnp2.js +0 -6
- package/dist/chunks/studio-886t6ztg.js +0 -5
- package/dist/chunks/studio-8wnvtr4z.js +0 -5
- package/dist/chunks/studio-8z66jk4z.js +0 -5
- package/dist/chunks/studio-b2qpt0xy.js +0 -14
- package/dist/chunks/studio-c71806xz.js +0 -7
- package/dist/chunks/studio-cbwks69x.js +0 -928
- package/dist/chunks/studio-cg710sk9.js +0 -5
- package/dist/chunks/studio-cw0wb9qk.js +0 -7
- package/dist/chunks/studio-cyheys7k.js +0 -5
- package/dist/chunks/studio-ea5m9kg5.js +0 -5
- package/dist/chunks/studio-efdxjwyp.js +0 -64
- package/dist/chunks/studio-egmw2837.js +0 -5
- package/dist/chunks/studio-f746039f.js +0 -5
- package/dist/chunks/studio-fmdymd9v.js +0 -5
- package/dist/chunks/studio-gcm12z00.js +0 -6
- package/dist/chunks/studio-gj98m8gy.js +0 -7
- package/dist/chunks/studio-hasbgjah.js +0 -5
- package/dist/chunks/studio-hz5n7x27.js +0 -5
- package/dist/chunks/studio-jeraxdgv.js +0 -7
- package/dist/chunks/studio-jvgek9xt.js +0 -5
- package/dist/chunks/studio-jws1a87n.js +0 -5
- package/dist/chunks/studio-k2sz13zg.js +0 -5
- package/dist/chunks/studio-k3m4jkdk.js +0 -5
- package/dist/chunks/studio-kg5ya5r2.js +0 -5
- package/dist/chunks/studio-khwbgrtg.js +0 -5
- package/dist/chunks/studio-mx6wzh25.js +0 -5
- package/dist/chunks/studio-mxs3ar9t.js +0 -5
- package/dist/chunks/studio-nm4szey4.js +0 -5
- package/dist/chunks/studio-nqxqf8p2.js +0 -4
- package/dist/chunks/studio-qn7z4krm.js +0 -5
- package/dist/chunks/studio-qyed0ah6.js +0 -5
- package/dist/chunks/studio-r4bcdwhq.js +0 -5
- package/dist/chunks/studio-rtfnhrdg.js +0 -5
- package/dist/chunks/studio-s5tzw84t.js +0 -5
- package/dist/chunks/studio-t51y8v1d.js +0 -5
- package/dist/chunks/studio-t98vq4r6.js +0 -5
- package/dist/chunks/studio-tsxpqvh0.js +0 -6
- package/dist/chunks/studio-v1yascv2.js +0 -5
- package/dist/chunks/studio-wxq6v5sv.js +0 -5
- package/dist/chunks/studio-y60sts62.js +0 -4
- package/dist/chunks/studio-zndrqawb.js +0 -5
- package/dist/chunks/studio-zwaxn5s2.js +0 -5
- package/dist/chunks/workspace-home-x1787wpv.js +0 -4
- package/dist/chunks/workspace-zpe1np43.js +0 -4
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* {@link DEFAULT_MEDIA_DIR}, `public/media/`, which is served from the site root as `/media/`.
|
|
15
15
|
*
|
|
16
16
|
* {@link replaceAsset} is the one door that writes over an existing file, and it is asked for by
|
|
17
|
-
* name: the Media workspace's **Replace** (studio.md §9.3).
|
|
17
|
+
* name: the Media workspace's **Replace** (studio.md §9.3). {@link duplicateAsset} is the upload
|
|
18
|
+
* core fed from the project rather than the desktop, and {@link mediaRenameRefusal} is what a new
|
|
19
|
+
* name for a media file may not be.
|
|
18
20
|
*
|
|
19
21
|
* @docs studio/projects/media
|
|
20
22
|
*/
|
|
@@ -24,7 +26,9 @@ import { getPlatform, hasPlatform } from "../platform";
|
|
|
24
26
|
import { notify } from "../services/notify";
|
|
25
27
|
import { invalidateUsages } from "../services/references";
|
|
26
28
|
import { activeTab } from "../workspace/workspace";
|
|
27
|
-
import { forgetVersions, notedListing } from "./asset-versions";
|
|
29
|
+
import { forgetVersions, noteLocalWrite, notedListing } from "./asset-versions";
|
|
30
|
+
import { markLocalMutation } from "./fs-events";
|
|
31
|
+
import { renameRefusal } from "./new-file-name";
|
|
28
32
|
import {
|
|
29
33
|
AUDIO_EXTENSIONS,
|
|
30
34
|
IMAGE_EXTENSIONS,
|
|
@@ -202,17 +206,20 @@ export function assetRef(destPath: string): string {
|
|
|
202
206
|
|
|
203
207
|
/**
|
|
204
208
|
* A filename that doesn't collide with `taken`, suffixing `-1`, `-2`, … before the extension. Never
|
|
205
|
-
* overwrites existing media (the previous behaviour clobbered silently).
|
|
209
|
+
* overwrites existing media (the previous behaviour clobbered silently). Compared without regard to
|
|
210
|
+
* case: APFS and NTFS take `Hero.jpg` for `hero.jpg`, so a name that differs only in case is a name
|
|
211
|
+
* that overwrites there.
|
|
206
212
|
*/
|
|
207
213
|
export function uniqueName(name: string, taken: ReadonlySet<string>): string {
|
|
208
|
-
|
|
214
|
+
const lower = new Set([...taken].map((entry) => entry.toLowerCase()));
|
|
215
|
+
if (!lower.has(name.toLowerCase())) {
|
|
209
216
|
return name;
|
|
210
217
|
}
|
|
211
218
|
const ext = extensionOf(name);
|
|
212
219
|
const base = ext ? name.slice(0, -ext.length) : name;
|
|
213
220
|
for (let i = 1; ; i++) {
|
|
214
221
|
const candidate = `${base}-${i}${ext}`;
|
|
215
|
-
if (!
|
|
222
|
+
if (!lower.has(candidate.toLowerCase())) {
|
|
216
223
|
return candidate;
|
|
217
224
|
}
|
|
218
225
|
}
|
|
@@ -231,10 +238,17 @@ export function joinDir(dir: string, name: string): string {
|
|
|
231
238
|
* this module doesn't import the media-picker / Manage / file-tree caches it needs to invalidate
|
|
232
239
|
* (they all import from here) — the same injection pattern as the canvas host's drop handlers.
|
|
233
240
|
*/
|
|
234
|
-
let mediaChangedHandler:
|
|
241
|
+
let mediaChangedHandler: MediaChangedHandler | null = null;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The post-upload cache invalidator. `overwritten` names a path whose bytes were replaced IN PLACE,
|
|
245
|
+
* which is the one change a re-list cannot show: the listing is unchanged, so anything already
|
|
246
|
+
* drawn from that path has to be drawn again (studio.md §3.4).
|
|
247
|
+
*/
|
|
248
|
+
export type MediaChangedHandler = (dir: string, overwritten?: string) => void | Promise<void>;
|
|
235
249
|
|
|
236
250
|
/** Register the post-upload cache invalidator (see {@link mediaChangedHandler}). */
|
|
237
|
-
export function setMediaChangedHandler(fn:
|
|
251
|
+
export function setMediaChangedHandler(fn: MediaChangedHandler | null): void {
|
|
238
252
|
mediaChangedHandler = fn;
|
|
239
253
|
}
|
|
240
254
|
|
|
@@ -242,8 +256,10 @@ export function setMediaChangedHandler(fn: ((dir: string) => void | Promise<void
|
|
|
242
256
|
* Say that the media in `dir` changed by some door other than {@link uploadAssets} — a replace, or
|
|
243
257
|
* the Media workspace's delete — so every cache keyed on the listing is dropped the same way.
|
|
244
258
|
*/
|
|
245
|
-
export async function announceMediaChanged(dir: string): Promise<void> {
|
|
246
|
-
await
|
|
259
|
+
export async function announceMediaChanged(dir: string, overwritten?: string): Promise<void> {
|
|
260
|
+
await (overwritten === undefined
|
|
261
|
+
? mediaChangedHandler?.(dir)
|
|
262
|
+
: mediaChangedHandler?.(dir, overwritten));
|
|
247
263
|
}
|
|
248
264
|
|
|
249
265
|
/** The directory part of a project path, `.` for a file at the root. */
|
|
@@ -303,7 +319,16 @@ function keepListable(files: File[], only: UploadOnly): File[] {
|
|
|
303
319
|
*/
|
|
304
320
|
export async function uploadAssets(
|
|
305
321
|
files: Iterable<File>,
|
|
306
|
-
opts: {
|
|
322
|
+
opts: {
|
|
323
|
+
dir?: string;
|
|
324
|
+
/** The destination holds a file already, so a listing that fails says nothing about it. */
|
|
325
|
+
dirExists?: boolean;
|
|
326
|
+
only?: UploadOnly;
|
|
327
|
+
quiet?: boolean;
|
|
328
|
+
source?: string;
|
|
329
|
+
/** Writes each file instead of `uploadFile`, for a caller with a better door than an upload. */
|
|
330
|
+
write?: (path: string) => Promise<{ path: string }>;
|
|
331
|
+
} = {},
|
|
307
332
|
): Promise<UploadedAsset[]> {
|
|
308
333
|
const list = opts.only === undefined ? [...files] : keepListable([...files], opts.only);
|
|
309
334
|
if (list.length === 0) {
|
|
@@ -311,13 +336,22 @@ export async function uploadAssets(
|
|
|
311
336
|
}
|
|
312
337
|
const platform = getPlatform();
|
|
313
338
|
const dir = opts.dir ?? uploadDirFor(activeTab.value?.documentPath ?? null);
|
|
339
|
+
const source = opts.source ?? "Media";
|
|
314
340
|
|
|
315
341
|
let taken: Set<string>;
|
|
316
342
|
try {
|
|
317
343
|
// The upload directory is a media directory, so its listing vouches for versions as well.
|
|
318
344
|
const existing = await notedListing(() => platform.listDirectory(dir));
|
|
319
345
|
taken = new Set(existing.map((entry) => entry.name));
|
|
320
|
-
} catch {
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (opts.dirExists === true) {
|
|
348
|
+
notify.error(`Could not write to ${dir}/.`, {
|
|
349
|
+
detail: `The folder could not be read to choose a name that overwrites nothing: ${errorMessage(error)}`,
|
|
350
|
+
path: dir,
|
|
351
|
+
source,
|
|
352
|
+
});
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
321
355
|
taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
|
|
322
356
|
}
|
|
323
357
|
|
|
@@ -342,7 +376,9 @@ export async function uploadAssets(
|
|
|
342
376
|
/* The path the backend REPORTS, not the one asked for. A store that de-duplicates by content
|
|
343
377
|
hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
|
|
344
378
|
reference that goes into the document has to name what is really there. */
|
|
345
|
-
const { path } = await
|
|
379
|
+
const { path } = await (opts.write
|
|
380
|
+
? opts.write(requested)
|
|
381
|
+
: platform.uploadFile(requested, file));
|
|
346
382
|
// New bytes at `path`: whatever version a listing held for it no longer names them.
|
|
347
383
|
forgetVersions([path]);
|
|
348
384
|
uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
|
|
@@ -350,22 +386,119 @@ export async function uploadAssets(
|
|
|
350
386
|
notify.error(`Could not upload ${file.name}.`, {
|
|
351
387
|
detail: errorMessage(error),
|
|
352
388
|
path: requested,
|
|
353
|
-
source
|
|
389
|
+
source,
|
|
354
390
|
});
|
|
355
391
|
}
|
|
356
392
|
}
|
|
357
393
|
|
|
358
394
|
if (uploaded.length > 0) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
395
|
+
// `quiet` is a caller with its own sentence: one toast per write, and "uploaded" is not always it.
|
|
396
|
+
if (opts.quiet !== true) {
|
|
397
|
+
notify.success(
|
|
398
|
+
uploaded.length === 1
|
|
399
|
+
? `Uploaded ${uploaded[0]!.name} to ${dir}/`
|
|
400
|
+
: `Uploaded ${uploaded.length} files to ${dir}/`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
364
403
|
await mediaChangedHandler?.(dir);
|
|
365
404
|
}
|
|
366
405
|
return uploaded;
|
|
367
406
|
}
|
|
368
407
|
|
|
408
|
+
/** `bytes` as text when `writeFile` can put the same bytes back: valid UTF-8, with no BOM to lose. */
|
|
409
|
+
function strictUtf8(bytes: ArrayBuffer): string | null {
|
|
410
|
+
try {
|
|
411
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
412
|
+
return text.startsWith("\uFEFF") ? null : text;
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* What a copy of `name` is called before any collision: `hero.jpg` is `hero-copy.jpg`.
|
|
420
|
+
*
|
|
421
|
+
* A copy of a copy keeps counting ({@link uniqueName} adds the number) rather than growing a second
|
|
422
|
+
* `-copy`, so duplicating `hero-copy.jpg` or `hero-copy-1.jpg` asks for `hero-copy.jpg` again.
|
|
423
|
+
*/
|
|
424
|
+
export function duplicateName(name: string): string {
|
|
425
|
+
const stem = name.slice(0, name.length - extensionOf(name).length);
|
|
426
|
+
return `${stem.replace(/-copy(?:-\d+)?$/, "")}-copy${name.slice(stem.length)}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Copy the file at `path` to a new name beside it — **Duplicate**, in the Media workspace and the
|
|
431
|
+
* Content home alike.
|
|
432
|
+
*
|
|
433
|
+
* Through `readFileBytes`, because `readFile` answers UTF-8: a picture read that way comes back as
|
|
434
|
+
* replacement characters, and so does every non-UTF-8 byte of a text file, such as a Latin-1
|
|
435
|
+
* Markdown entry from an imported site. And through {@link uploadAssets}, so a copy is named,
|
|
436
|
+
* limited and announced the way any new file is and can never overwrite anything. What it copies is
|
|
437
|
+
* the file on disk: a pending image edit or an unsaved change belongs to its tab and stays there.
|
|
438
|
+
*
|
|
439
|
+
* A backend that reports the original's own path (a store that keeps identical bytes once) made no
|
|
440
|
+
* second file, which is said rather than called success.
|
|
441
|
+
*
|
|
442
|
+
* `document` is for a file that is usually a document, and it decides the write rather than the
|
|
443
|
+
* read. Studio Cloud files every upload as a binary and answers a text read of one with a 415, so
|
|
444
|
+
* an uploaded copy of a Markdown entry could never be opened. Bytes that are valid UTF-8 go through
|
|
445
|
+
* `writeFile` instead, which loses nothing, since they encode back the same. Bytes that are not
|
|
446
|
+
* stay an upload, and so does a file with a BOM: the dev server decodes a written body as a Fetch
|
|
447
|
+
* `text()` does, which drops one.
|
|
448
|
+
*
|
|
449
|
+
* @param path The file to copy.
|
|
450
|
+
* @param opts `source` names the flow in a Problem; `document` is described above.
|
|
451
|
+
* @returns The copy, or `null` when none was made; the reason has been reported.
|
|
452
|
+
*/
|
|
453
|
+
export async function duplicateAsset(
|
|
454
|
+
path: string,
|
|
455
|
+
opts: { document?: boolean; source?: string } = {},
|
|
456
|
+
): Promise<UploadedAsset | null> {
|
|
457
|
+
const name = path.split("/").pop() ?? path;
|
|
458
|
+
const source = opts.source ?? "Media";
|
|
459
|
+
let bytes: ArrayBuffer;
|
|
460
|
+
try {
|
|
461
|
+
const platform = getPlatform();
|
|
462
|
+
if (platform.readFileBytes === undefined) {
|
|
463
|
+
throw new Error("This backend cannot read a file's raw bytes.");
|
|
464
|
+
}
|
|
465
|
+
bytes = await platform.readFileBytes(path);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
notify.error(`Could not duplicate ${name}.`, {
|
|
468
|
+
detail: errorMessage(error),
|
|
469
|
+
path,
|
|
470
|
+
source,
|
|
471
|
+
});
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
const text = opts.document === true ? strictUtf8(bytes) : null;
|
|
475
|
+
const [copy] = await uploadAssets([new File([bytes], duplicateName(name))], {
|
|
476
|
+
dir: parentDir(path),
|
|
477
|
+
dirExists: true,
|
|
478
|
+
quiet: true,
|
|
479
|
+
source,
|
|
480
|
+
...(text !== null && {
|
|
481
|
+
write: async (target: string) => {
|
|
482
|
+
await getPlatform().writeFile(target, text);
|
|
483
|
+
return { path: target };
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
});
|
|
487
|
+
if (!copy) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
if (copy.path === path) {
|
|
491
|
+
notify.warn(`${name} was not duplicated.`, {
|
|
492
|
+
detail: "The backend keeps identical files once, so it made no second file.",
|
|
493
|
+
path,
|
|
494
|
+
source,
|
|
495
|
+
});
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
notify.success(`Duplicated ${name} as ${copy.name}`);
|
|
499
|
+
return copy;
|
|
500
|
+
}
|
|
501
|
+
|
|
369
502
|
/** Extensions that name one format, so either may stand in for the other. */
|
|
370
503
|
const SAME_FORMAT: readonly ReadonlySet<string>[] = [
|
|
371
504
|
new Set([".jpg", ".jpeg"]),
|
|
@@ -392,6 +525,29 @@ export function canReplaceWith(path: string, file: { name: string }): boolean {
|
|
|
392
525
|
return formatSiblings(extensionOf(path)).has(extensionOf(file.name));
|
|
393
526
|
}
|
|
394
527
|
|
|
528
|
+
/**
|
|
529
|
+
* The refusal a new name for the media file at `path` earns, or `""` when it is usable (studio.md
|
|
530
|
+
* §9.3): every entry's rename rules, and then the one only a media file has.
|
|
531
|
+
*
|
|
532
|
+
* @param path The file being renamed.
|
|
533
|
+
* @param value What the reader typed.
|
|
534
|
+
* @param taken The names in the file's folder.
|
|
535
|
+
*/
|
|
536
|
+
export function mediaRenameRefusal(
|
|
537
|
+
path: string,
|
|
538
|
+
value: string,
|
|
539
|
+
taken: ReadonlySet<string>,
|
|
540
|
+
): string {
|
|
541
|
+
const general = renameRefusal(path, value, taken);
|
|
542
|
+
if (general !== "") {
|
|
543
|
+
return general;
|
|
544
|
+
}
|
|
545
|
+
if (!canReplaceWith(path, { name: value.trim() })) {
|
|
546
|
+
return `Keep the ${extensionOf(path)} extension. A rename does not convert the file, so another extension would break it on every page.`;
|
|
547
|
+
}
|
|
548
|
+
return "";
|
|
549
|
+
}
|
|
550
|
+
|
|
395
551
|
/**
|
|
396
552
|
* Write `file`'s bytes over the existing media file at `path` — the Media workspace's **Replace**.
|
|
397
553
|
*
|
|
@@ -405,7 +561,11 @@ export function canReplaceWith(path: string, file: { name: string }): boolean {
|
|
|
405
561
|
*
|
|
406
562
|
* @returns Whether the bytes now at `path` are the new ones.
|
|
407
563
|
*/
|
|
408
|
-
export async function replaceAsset(
|
|
564
|
+
export async function replaceAsset(
|
|
565
|
+
path: string,
|
|
566
|
+
file: File,
|
|
567
|
+
opts: { note?: string } = {},
|
|
568
|
+
): Promise<boolean> {
|
|
409
569
|
const name = path.split("/").pop() ?? path;
|
|
410
570
|
/* An `accept` narrows only the chooser (and a backend's own picker may ignore it), so the format is
|
|
411
571
|
checked here too: same path, other format, is every reference broken at once. */
|
|
@@ -425,6 +585,10 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
|
|
|
425
585
|
});
|
|
426
586
|
return false;
|
|
427
587
|
}
|
|
588
|
+
/* Studio's own write, marked BEFORE it happens (§3.4): the watcher's echo of it is absorbed rather
|
|
589
|
+
than reported as somebody else's change, and the path's content version is forgotten while the
|
|
590
|
+
bytes are still the old ones, so no listing in flight can vouch the old version afterwards. */
|
|
591
|
+
markLocalMutation(path);
|
|
428
592
|
let written: string;
|
|
429
593
|
try {
|
|
430
594
|
({ path: written } = await getPlatform().uploadFile(path, file));
|
|
@@ -437,10 +601,18 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
|
|
|
437
601
|
return false;
|
|
438
602
|
}
|
|
439
603
|
forgetVersions([path, written]);
|
|
604
|
+
/* The repaint key: on a host that vouches no version (`jx dev`, desktop) the URL of `path` is
|
|
605
|
+
otherwise byte-identical before and after, and every image already decoded keeps the old
|
|
606
|
+
pixels. Only on the path that was actually written; a second file beside it is new. */
|
|
607
|
+
if (written === path) {
|
|
608
|
+
noteLocalWrite(path);
|
|
609
|
+
}
|
|
440
610
|
// The answers were about the old bytes' place in the project; the place is the same, but a stale
|
|
441
611
|
// "Used on" is a list the reader acts on, so it is asked again rather than trusted.
|
|
442
612
|
invalidateUsages();
|
|
443
|
-
await
|
|
613
|
+
await (written === path
|
|
614
|
+
? announceMediaChanged(parentDir(path), path)
|
|
615
|
+
: announceMediaChanged(parentDir(path)));
|
|
444
616
|
if (written !== path) {
|
|
445
617
|
notify.warn(`${name} was not replaced.`, {
|
|
446
618
|
detail: `The backend stored the new file as ${written}, so every reference still shows the old one.`,
|
|
@@ -451,6 +623,8 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
|
|
|
451
623
|
});
|
|
452
624
|
return false;
|
|
453
625
|
}
|
|
454
|
-
|
|
626
|
+
/* One toast per write. A caller with something to add (the Image editor's written size) adds it
|
|
627
|
+
to the message, because a toast draws its message alone: `detail` is for Problems. */
|
|
628
|
+
notify.success(opts.note === undefined ? `Replaced ${name}` : `Replaced ${name}: ${opts.note}`);
|
|
455
629
|
return true;
|
|
456
630
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules a new file's name is held to, with no dialog and no write attached
|
|
2
|
+
* The rules a new file's name is held to, with no dialog and no write attached, and the rules a
|
|
3
|
+
* rename's new name is held to ({@link renameRefusal}).
|
|
3
4
|
*
|
|
4
5
|
* `createFileIn` (`files/files.ts`) used to keep these inside one closure over its prompt, which
|
|
5
6
|
* was right while the prompt was the only creation flow there was. Add new (`create/add-new.ts`,
|
|
@@ -154,3 +155,40 @@ export function validateNewFileName(
|
|
|
154
155
|
const candidate = composeNewFileName(value, picked, choice);
|
|
155
156
|
return taken.has(candidate.toLowerCase()) ? `${candidate} already exists in ${dir}/.` : "";
|
|
156
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The refusal a new name for the file or folder at `path` earns, or `""` when it is usable
|
|
161
|
+
* (studio.md §9.1.1). The first that applies is the sentence.
|
|
162
|
+
*
|
|
163
|
+
* `taken` is every name already in the entry's folder, and it is the check that matters most: a
|
|
164
|
+
* backend's `renameFile` is a filesystem rename, which replaces its destination in silence.
|
|
165
|
+
* Compared without regard to case, since a case-insensitive disk would take `Hero.jpg` for
|
|
166
|
+
* `hero.jpg`, and the entry's own name is never in the way, so its case can change.
|
|
167
|
+
*
|
|
168
|
+
* The other two refusals are what keep that check honest. A listing answers for one folder, and a
|
|
169
|
+
* path would land in another. A dev server or desktop listing leaves out every dotfile, so `.env`
|
|
170
|
+
* looks free whether or not there is one. An entry that is itself a dotfile is exempt: a host that
|
|
171
|
+
* listed it is a host whose listing can answer.
|
|
172
|
+
*
|
|
173
|
+
* @param path The entry being renamed.
|
|
174
|
+
* @param value What the reader typed.
|
|
175
|
+
* @param taken The names in the entry's folder, in any case.
|
|
176
|
+
*/
|
|
177
|
+
export function renameRefusal(path: string, value: string, taken: ReadonlySet<string>): string {
|
|
178
|
+
const name = value.trim();
|
|
179
|
+
if (name === "") {
|
|
180
|
+
return "Enter a file name.";
|
|
181
|
+
}
|
|
182
|
+
if (/[/\\]/.test(name)) {
|
|
183
|
+
return "Enter a name, not a path. A rename keeps the file in its folder.";
|
|
184
|
+
}
|
|
185
|
+
const current = (path.split("/").pop() ?? path).toLowerCase();
|
|
186
|
+
if (name.startsWith(".") && !current.startsWith(".")) {
|
|
187
|
+
return "A name cannot start with a dot: hidden files are not listed, so the file could disappear.";
|
|
188
|
+
}
|
|
189
|
+
const lower = name.toLowerCase();
|
|
190
|
+
if (lower !== current && [...taken].some((entry) => entry.toLowerCase() === lower)) {
|
|
191
|
+
return `${name} already exists in this folder.`;
|
|
192
|
+
}
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bake: a recipe applied to an image's bytes, once, at save (studio.md §9.5).
|
|
3
|
+
*
|
|
4
|
+
* The only module in the Image editor that touches pixels. It decodes the source with the
|
|
5
|
+
* orientation tag applied (stated explicitly, so the decode and the `<img>` the author edited over
|
|
6
|
+
* cannot disagree), sizes an `OffscreenCanvas` to the output, turns and flips the context, asks for
|
|
7
|
+
* the host's best resampling rather than its default (which is its fastest, and which visibly
|
|
8
|
+
* aliases a reduction of more than about a factor of two), sets the same filter string the preview
|
|
9
|
+
* showed, draws the stored SOURCE rectangle directly, and encodes.
|
|
10
|
+
*
|
|
11
|
+
* **It verifies before anything is written, because every relevant host failure is silent.** Past
|
|
12
|
+
* the maximum drawing surface a context draws and reads back zeros; `convertToBlob` asked for a
|
|
13
|
+
* type it cannot encode returns a PNG rather than rejecting; and a surface that produced nothing
|
|
14
|
+
* still produces a blob. So the output must be within the surface, the encoded type must be the one
|
|
15
|
+
* the path promises, and the blob must be large enough to be an image. A failed check returns a
|
|
16
|
+
* reason and the caller writes nothing.
|
|
17
|
+
*
|
|
18
|
+
* The two host primitives are parameters, defaulting to the globals, so the module is exercised
|
|
19
|
+
* against fakes under happy-dom (which has neither) with no `mock.module()` of a first-party file.
|
|
20
|
+
* `OffscreenCanvas` is not a DOM node, so nothing here constructs UI.
|
|
21
|
+
*
|
|
22
|
+
* @docs studio/projects/media
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { MAX_SURFACE_AREA, MAX_SURFACE_SIDE, takesQuality } from "./image-format";
|
|
26
|
+
import { cropOf, filterString, outputSize, swapsAxes } from "./image-recipe";
|
|
27
|
+
import type { ImageRecipe, PixelSize } from "./image-recipe";
|
|
28
|
+
|
|
29
|
+
/** The part of a 2D context a bake uses — all of it, so a fake can be honest about its shape. */
|
|
30
|
+
export type BakeContext = Pick<
|
|
31
|
+
OffscreenCanvasRenderingContext2D,
|
|
32
|
+
| "drawImage"
|
|
33
|
+
| "filter"
|
|
34
|
+
| "imageSmoothingEnabled"
|
|
35
|
+
| "imageSmoothingQuality"
|
|
36
|
+
| "rotate"
|
|
37
|
+
| "scale"
|
|
38
|
+
| "translate"
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/** The part of an `OffscreenCanvas` a bake uses. */
|
|
42
|
+
export interface BakeSurface {
|
|
43
|
+
getContext: (contextId: "2d") => BakeContext | null;
|
|
44
|
+
convertToBlob: (options?: ImageEncodeOptions) => Promise<Blob>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The two host primitives a bake needs, replaceable for a test. */
|
|
48
|
+
export interface BakePorts {
|
|
49
|
+
createBitmap?: (image: Blob, options: ImageBitmapOptions) => Promise<ImageBitmap>;
|
|
50
|
+
Offscreen?: new (width: number, height: number) => BakeSurface;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A bake that produced bytes fit to write, or the reason it did not. */
|
|
54
|
+
export type BakeResult = { ok: true; blob: Blob; size: PixelSize } | { ok: false; reason: string };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The smallest encoded output accepted as an image. The smallest valid PNG a canvas writes is about
|
|
58
|
+
* seventy bytes and a JPEG well over a hundred, so anything under this is a surface that produced
|
|
59
|
+
* nothing, and it must not be written over a file that was something.
|
|
60
|
+
*/
|
|
61
|
+
export const MIN_ENCODED_BYTES = 64;
|
|
62
|
+
|
|
63
|
+
/** Why `size` cannot be drawn on this host, or null when it can. */
|
|
64
|
+
export function surfaceRefusal(size: PixelSize): string | null {
|
|
65
|
+
if (
|
|
66
|
+
size.width > MAX_SURFACE_SIDE ||
|
|
67
|
+
size.height > MAX_SURFACE_SIDE ||
|
|
68
|
+
size.width * size.height > MAX_SURFACE_AREA
|
|
69
|
+
) {
|
|
70
|
+
return `The result would be ${size.width} × ${size.height}, past the ${MAX_SURFACE_SIDE} × ${MAX_SURFACE_SIDE} the browser can draw. Reduce the width or crop first.`;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Bake `recipe` over `bytes`, encoded as `mime`.
|
|
77
|
+
*
|
|
78
|
+
* @param recipe - The edit, with its crop in source coordinates
|
|
79
|
+
* @param bytes - The file's bytes, exactly as read
|
|
80
|
+
* @param mime - The type the path's extension promises, which the output must be
|
|
81
|
+
* @param ports - The host primitives; the globals when omitted
|
|
82
|
+
*/
|
|
83
|
+
export async function bakeImage(
|
|
84
|
+
recipe: Readonly<ImageRecipe>,
|
|
85
|
+
bytes: Uint8Array<ArrayBuffer>,
|
|
86
|
+
mime: string,
|
|
87
|
+
{
|
|
88
|
+
createBitmap = (image, options) => globalThis.createImageBitmap(image, options),
|
|
89
|
+
Offscreen = globalThis.OffscreenCanvas,
|
|
90
|
+
}: BakePorts = {},
|
|
91
|
+
): Promise<BakeResult> {
|
|
92
|
+
const bitmap = await createBitmap(new Blob([bytes], { type: mime }), {
|
|
93
|
+
imageOrientation: "from-image",
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
const source = { height: bitmap.height, width: bitmap.width };
|
|
97
|
+
const out = outputSize(recipe, source);
|
|
98
|
+
const tooLarge = surfaceRefusal(out);
|
|
99
|
+
if (tooLarge !== null) {
|
|
100
|
+
return { ok: false, reason: tooLarge };
|
|
101
|
+
}
|
|
102
|
+
/* A crop outside the bitmap draws nothing and encodes a blank image that passes every check
|
|
103
|
+
below, so a crop made against bytes that have since changed stops here. */
|
|
104
|
+
const stored = cropOf(recipe, source);
|
|
105
|
+
if (
|
|
106
|
+
stored.x < 0 ||
|
|
107
|
+
stored.y < 0 ||
|
|
108
|
+
stored.x + stored.width > source.width ||
|
|
109
|
+
stored.y + stored.height > source.height
|
|
110
|
+
) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: "The crop no longer fits this image, which changed after the edit began.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const canvas = new Offscreen(out.width, out.height);
|
|
117
|
+
const ctx = canvas.getContext("2d");
|
|
118
|
+
if (ctx === null) {
|
|
119
|
+
return { ok: false, reason: "The browser would not give the editor a drawing surface." };
|
|
120
|
+
}
|
|
121
|
+
ctx.imageSmoothingEnabled = true;
|
|
122
|
+
ctx.imageSmoothingQuality = "high";
|
|
123
|
+
ctx.filter = filterString(recipe) || "none";
|
|
124
|
+
/* Centre, mirror, then turn — applied to points in the reverse order, so a point is turned
|
|
125
|
+
first and mirrored in the frame the author sees, which is what the recipe's flips mean. */
|
|
126
|
+
ctx.translate(out.width / 2, out.height / 2);
|
|
127
|
+
ctx.scale(recipe.flipH ? -1 : 1, recipe.flipV ? -1 : 1);
|
|
128
|
+
ctx.rotate((recipe.turns * Math.PI) / 2);
|
|
129
|
+
const crop = cropOf(recipe, source);
|
|
130
|
+
// In the pre-turn frame the destination is the output with its sides swapped back.
|
|
131
|
+
const width = swapsAxes(recipe.turns) ? out.height : out.width;
|
|
132
|
+
const height = swapsAxes(recipe.turns) ? out.width : out.height;
|
|
133
|
+
ctx.drawImage(
|
|
134
|
+
bitmap,
|
|
135
|
+
crop.x,
|
|
136
|
+
crop.y,
|
|
137
|
+
crop.width,
|
|
138
|
+
crop.height,
|
|
139
|
+
-width / 2,
|
|
140
|
+
-height / 2,
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
);
|
|
144
|
+
const blob = await canvas.convertToBlob(
|
|
145
|
+
takesQuality(mime) ? { quality: recipe.quality / 100, type: mime } : { type: mime },
|
|
146
|
+
);
|
|
147
|
+
if (blob.type !== mime) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: `The browser encoded ${blob.type || "an unknown type"} when asked for ${mime}, and those bytes would not be what the file's extension promises.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (blob.size < MIN_ENCODED_BYTES) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: `The browser produced ${blob.size} bytes, which is not an image. Nothing was written.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { blob, ok: true, size: out };
|
|
160
|
+
} finally {
|
|
161
|
+
// The one full-resolution allocation, released as soon as the draw no longer needs it.
|
|
162
|
+
bitmap.close();
|
|
163
|
+
}
|
|
164
|
+
}
|