@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Image editor's edit, as a record (studio.md §9.5).
|
|
3
|
+
*
|
|
4
|
+
* An edit is about a dozen numbers rather than a buffer of pixels: a crop rectangle, a quarter-turn
|
|
5
|
+
* count, two flip flags, an output width, an encode quality and three tone values. Nothing is
|
|
6
|
+
* applied to pixels until a save bakes it (`media/image-bake.ts`), so undo is an assignment, the
|
|
7
|
+
* preview is CSS over the image already on screen, and the preview and the bake read one filter
|
|
8
|
+
* string written here.
|
|
9
|
+
*
|
|
10
|
+
* **Geometry has one canonical space.** The crop is stored in the SOURCE bitmap's coordinates,
|
|
11
|
+
* before any turn or flip, because that is the frame `drawImage`'s source rectangle takes: the bake
|
|
12
|
+
* draws the stored rectangle directly and never inverse-maps it. The author looks at a different
|
|
13
|
+
* frame (the source turned clockwise, then flipped as they see it), and exactly two functions cross
|
|
14
|
+
* between the frames: {@link sourceToDisplay} and {@link displayToSource}. A handle is named for
|
|
15
|
+
* where it sits ON SCREEN, so under one quarter-turn the top handle moves the source's left edge,
|
|
16
|
+
* and a flip reverses that again; {@link moveHandle} gets that right by moving the edge in the
|
|
17
|
+
* display frame and mapping the result back.
|
|
18
|
+
*
|
|
19
|
+
* Pure: no DOM, no canvas, no file, no tab, no host primitive.
|
|
20
|
+
*
|
|
21
|
+
* @docs studio/projects/media
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A bitmap's size in pixels. */
|
|
25
|
+
export type PixelSize = Pick<DOMRectReadOnly, "height" | "width">;
|
|
26
|
+
|
|
27
|
+
/** A rectangle in pixels — a size at an origin — in whichever frame the function naming it says. */
|
|
28
|
+
export type PixelRect = PixelSize & { x: number; y: number };
|
|
29
|
+
|
|
30
|
+
/** Clockwise quarter turns. */
|
|
31
|
+
export type QuarterTurns = 0 | 1 | 2 | 3;
|
|
32
|
+
|
|
33
|
+
/** Every turn count, indexed by itself, so arithmetic on a count lands back on the type. */
|
|
34
|
+
const QUARTER_TURNS: readonly QuarterTurns[] = [0, 1, 2, 3];
|
|
35
|
+
|
|
36
|
+
/** The ratios a crop can be held to, as the author sees them: width to height. */
|
|
37
|
+
export const ASPECTS = [
|
|
38
|
+
{ id: "free", label: "Free" },
|
|
39
|
+
{ id: "original", label: "Original" },
|
|
40
|
+
{ id: "1:1", label: "Square (1:1)" },
|
|
41
|
+
{ id: "4:3", label: "4:3" },
|
|
42
|
+
{ id: "3:4", label: "3:4" },
|
|
43
|
+
{ id: "3:2", label: "3:2" },
|
|
44
|
+
{ id: "2:3", label: "2:3" },
|
|
45
|
+
{ id: "16:9", label: "16:9" },
|
|
46
|
+
{ id: "9:16", label: "9:16" },
|
|
47
|
+
] as const;
|
|
48
|
+
export type AspectId = (typeof ASPECTS)[number]["id"];
|
|
49
|
+
|
|
50
|
+
/** Each ratio after a quarter turn, which swaps the sides the author sees. */
|
|
51
|
+
const TURNED_ASPECT: Readonly<Record<AspectId, AspectId>> = {
|
|
52
|
+
"1:1": "1:1",
|
|
53
|
+
"16:9": "9:16",
|
|
54
|
+
"2:3": "3:2",
|
|
55
|
+
"3:2": "2:3",
|
|
56
|
+
"3:4": "4:3",
|
|
57
|
+
"4:3": "3:4",
|
|
58
|
+
"9:16": "16:9",
|
|
59
|
+
free: "free",
|
|
60
|
+
original: "original",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The edit. Every field is a number, a flag or a word, so a structured clone is a deep copy. */
|
|
64
|
+
export interface ImageRecipe {
|
|
65
|
+
/** The kept region in SOURCE coordinates, or null for the whole source. */
|
|
66
|
+
crop: PixelRect | null;
|
|
67
|
+
/**
|
|
68
|
+
* The ratio the crop is held to. A constraint on gestures rather than an instruction to the bake,
|
|
69
|
+
* so it changes nothing about the file on its own; it lives here so undo restores it with the
|
|
70
|
+
* crop it shaped.
|
|
71
|
+
*/
|
|
72
|
+
aspect: AspectId;
|
|
73
|
+
/** Clockwise quarter turns, applied before the flips. */
|
|
74
|
+
turns: QuarterTurns;
|
|
75
|
+
/** Mirror left to right, as the author sees the turned image. */
|
|
76
|
+
flipH: boolean;
|
|
77
|
+
/** Mirror top to bottom, as the author sees the turned image. */
|
|
78
|
+
flipV: boolean;
|
|
79
|
+
/** The output width in pixels, or null for the cropped width. Only ever a reduction. */
|
|
80
|
+
width: number | null;
|
|
81
|
+
/** The encode quality for `.jpg` and `.webp`, as a percentage. Meaningless for `.png`. */
|
|
82
|
+
quality: number;
|
|
83
|
+
/** Tone, as CSS filter percentages; 100 is neutral. */
|
|
84
|
+
brightness: number;
|
|
85
|
+
contrast: number;
|
|
86
|
+
saturation: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The quality a re-encode asks for when the author has not chosen one. */
|
|
90
|
+
export const DEFAULT_QUALITY = 90;
|
|
91
|
+
|
|
92
|
+
/** The recipe that changes nothing. */
|
|
93
|
+
export const IDENTITY: Readonly<ImageRecipe> = Object.freeze({
|
|
94
|
+
aspect: "free",
|
|
95
|
+
brightness: 100,
|
|
96
|
+
contrast: 100,
|
|
97
|
+
crop: null,
|
|
98
|
+
flipH: false,
|
|
99
|
+
flipV: false,
|
|
100
|
+
quality: DEFAULT_QUALITY,
|
|
101
|
+
saturation: 100,
|
|
102
|
+
turns: 0,
|
|
103
|
+
width: null,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** A deep copy of a recipe, so a stored step can never be mutated through a live one. */
|
|
107
|
+
export function cloneRecipe(recipe: Readonly<ImageRecipe>): ImageRecipe {
|
|
108
|
+
return { ...recipe, crop: recipe.crop === null ? null : { ...recipe.crop } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The crop as a rectangle, the whole source when none is stored. */
|
|
112
|
+
export function cropOf(recipe: Readonly<ImageRecipe>, source: PixelSize): PixelRect {
|
|
113
|
+
return recipe.crop ?? { height: source.height, width: source.width, x: 0, y: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a stored crop keeps the whole source. */
|
|
117
|
+
function cropIsWhole(crop: PixelRect | null, source: PixelSize): boolean {
|
|
118
|
+
return (
|
|
119
|
+
crop === null ||
|
|
120
|
+
(crop.x === 0 && crop.y === 0 && crop.width === source.width && crop.height === source.height)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether the recipe changes nothing about the file: the whole source, no turn, no flip, no
|
|
126
|
+
* reduction and neutral tone. Quality counts, because asking for a different one is asking for a
|
|
127
|
+
* re-encode. A save of an identity recipe writes nothing (§9.5).
|
|
128
|
+
*/
|
|
129
|
+
export function isIdentity(recipe: Readonly<ImageRecipe>, source: PixelSize): boolean {
|
|
130
|
+
return (
|
|
131
|
+
cropIsWhole(recipe.crop, source) &&
|
|
132
|
+
/* A half turn and both mirrors are the same picture, so a lossy re-encode for them would degrade
|
|
133
|
+
the master for nothing. */
|
|
134
|
+
(recipe.turns === 0
|
|
135
|
+
? !recipe.flipH && !recipe.flipV
|
|
136
|
+
: recipe.turns === 2 && recipe.flipH && recipe.flipV) &&
|
|
137
|
+
(recipe.width === null || recipe.width >= orientedSize(recipe, source).width) &&
|
|
138
|
+
recipe.quality === DEFAULT_QUALITY &&
|
|
139
|
+
recipe.brightness === 100 &&
|
|
140
|
+
recipe.contrast === 100 &&
|
|
141
|
+
recipe.saturation === 100
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether `turns` swaps width and height. */
|
|
146
|
+
export function swapsAxes(turns: QuarterTurns): boolean {
|
|
147
|
+
return turns % 2 === 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The crop's size as the author sees it: swapped on an odd turn, before any reduction. */
|
|
151
|
+
export function orientedSize(recipe: Readonly<ImageRecipe>, source: PixelSize): PixelSize {
|
|
152
|
+
const crop = cropOf(recipe, source);
|
|
153
|
+
return swapsAxes(recipe.turns)
|
|
154
|
+
? { height: crop.width, width: crop.height }
|
|
155
|
+
: { height: crop.height, width: crop.width };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The size the bake writes: the oriented crop, reduced to `width` when one is set below it. The
|
|
160
|
+
* height follows the aspect and is never less than one pixel.
|
|
161
|
+
*/
|
|
162
|
+
export function outputSize(recipe: Readonly<ImageRecipe>, source: PixelSize): PixelSize {
|
|
163
|
+
const oriented = orientedSize(recipe, source);
|
|
164
|
+
if (recipe.width === null || recipe.width >= oriented.width) {
|
|
165
|
+
return oriented;
|
|
166
|
+
}
|
|
167
|
+
const width = Math.max(1, Math.round(recipe.width));
|
|
168
|
+
return { height: Math.max(1, Math.round((oriented.height * width) / oriented.width)), width };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The CSS filter for the tone pass, or `""` when all three values are neutral.
|
|
173
|
+
*
|
|
174
|
+
* One string, read twice: the preview sets it on the `<img>` and the bake sets it on the drawing
|
|
175
|
+
* context, so the two cannot be written differently. Every function here is scale-invariant, which
|
|
176
|
+
* is the admission rule for a tone control (a blur's radius is a length, and would not be).
|
|
177
|
+
*/
|
|
178
|
+
export function filterString(recipe: Readonly<ImageRecipe>): string {
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
if (recipe.brightness !== 100) {
|
|
181
|
+
parts.push(`brightness(${recipe.brightness}%)`);
|
|
182
|
+
}
|
|
183
|
+
if (recipe.contrast !== 100) {
|
|
184
|
+
parts.push(`contrast(${recipe.contrast}%)`);
|
|
185
|
+
}
|
|
186
|
+
if (recipe.saturation !== 100) {
|
|
187
|
+
parts.push(`saturate(${recipe.saturation}%)`);
|
|
188
|
+
}
|
|
189
|
+
return parts.join(" ");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── The two frames ──────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** The size of the whole source as the author sees it. */
|
|
195
|
+
export function displaySize(turns: QuarterTurns, source: PixelSize): PixelSize {
|
|
196
|
+
return swapsAxes(turns)
|
|
197
|
+
? { height: source.width, width: source.height }
|
|
198
|
+
: { height: source.height, width: source.width };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type Point = [number, number];
|
|
202
|
+
|
|
203
|
+
/** One source point, turned clockwise and then flipped in the display frame. */
|
|
204
|
+
function pointToDisplay([x, y]: Point, recipe: Readonly<ImageRecipe>, source: PixelSize): Point {
|
|
205
|
+
const { width: w, height: h } = source;
|
|
206
|
+
let p: Point;
|
|
207
|
+
switch (recipe.turns) {
|
|
208
|
+
case 1: {
|
|
209
|
+
p = [h - y, x];
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 2: {
|
|
213
|
+
p = [w - x, h - y];
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 3: {
|
|
217
|
+
p = [y, w - x];
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
default: {
|
|
221
|
+
p = [x, y];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const shown = displaySize(recipe.turns, source);
|
|
225
|
+
return [recipe.flipH ? shown.width - p[0] : p[0], recipe.flipV ? shown.height - p[1] : p[1]];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One display point, unflipped and then turned back. The inverse of {@link pointToDisplay}. */
|
|
229
|
+
function pointToSource([dx, dy]: Point, recipe: Readonly<ImageRecipe>, source: PixelSize): Point {
|
|
230
|
+
const { width: w, height: h } = source;
|
|
231
|
+
const shown = displaySize(recipe.turns, source);
|
|
232
|
+
const x = recipe.flipH ? shown.width - dx : dx;
|
|
233
|
+
const y = recipe.flipV ? shown.height - dy : dy;
|
|
234
|
+
switch (recipe.turns) {
|
|
235
|
+
case 1: {
|
|
236
|
+
return [y, h - x];
|
|
237
|
+
}
|
|
238
|
+
case 2: {
|
|
239
|
+
return [w - x, h - y];
|
|
240
|
+
}
|
|
241
|
+
case 3: {
|
|
242
|
+
return [w - y, x];
|
|
243
|
+
}
|
|
244
|
+
default: {
|
|
245
|
+
return [x, y];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A rectangle through a point map: both corners mapped, then normalised. */
|
|
251
|
+
function mapRect(rect: PixelRect, map: (p: Point) => Point): PixelRect {
|
|
252
|
+
const [ax, ay] = map([rect.x, rect.y]);
|
|
253
|
+
const [bx, by] = map([rect.x + rect.width, rect.y + rect.height]);
|
|
254
|
+
return {
|
|
255
|
+
height: Math.abs(by - ay),
|
|
256
|
+
width: Math.abs(bx - ax),
|
|
257
|
+
x: Math.min(ax, bx),
|
|
258
|
+
y: Math.min(ay, by),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A rectangle in source coordinates, as the author sees it under the recipe's turn and flips. */
|
|
263
|
+
export function sourceToDisplay(
|
|
264
|
+
rect: PixelRect,
|
|
265
|
+
recipe: Readonly<ImageRecipe>,
|
|
266
|
+
source: PixelSize,
|
|
267
|
+
): PixelRect {
|
|
268
|
+
return mapRect(rect, (p) => pointToDisplay(p, recipe, source));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A rectangle the author sees, back in source coordinates. The inverse of the other one. */
|
|
272
|
+
export function displayToSource(
|
|
273
|
+
rect: PixelRect,
|
|
274
|
+
recipe: Readonly<ImageRecipe>,
|
|
275
|
+
source: PixelSize,
|
|
276
|
+
): PixelRect {
|
|
277
|
+
return mapRect(rect, (p) => pointToSource(p, recipe, source));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Handles ─────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/** The eight crop handles, named for where each sits ON SCREEN, clockwise from the top. */
|
|
283
|
+
export const HANDLES = ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const;
|
|
284
|
+
export type CropHandle = (typeof HANDLES)[number];
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Everything in the crop that can be grabbed: the kept area itself, which moves the crop, and the
|
|
288
|
+
* eight handles, which resize it. The area comes first, so the handles paint over it.
|
|
289
|
+
*/
|
|
290
|
+
export const CROP_STOPS = ["move", ...HANDLES] as const;
|
|
291
|
+
export type CropStop = (typeof CROP_STOPS)[number];
|
|
292
|
+
|
|
293
|
+
/** What a handle is called to a reader. */
|
|
294
|
+
export const HANDLE_LABELS: Readonly<Record<CropStop, string>> = {
|
|
295
|
+
e: "Right edge",
|
|
296
|
+
move: "Crop area",
|
|
297
|
+
n: "Top edge",
|
|
298
|
+
ne: "Top right corner",
|
|
299
|
+
nw: "Top left corner",
|
|
300
|
+
s: "Bottom edge",
|
|
301
|
+
se: "Bottom right corner",
|
|
302
|
+
sw: "Bottom left corner",
|
|
303
|
+
w: "Left edge",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** The smallest crop a handle may leave, in source pixels. */
|
|
307
|
+
export const MIN_CROP = 1;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The recipe with one handle moved by `(dx, dy)` display pixels.
|
|
311
|
+
*
|
|
312
|
+
* The edge moves where the author sees it — the crop is mapped into the display frame, the edges
|
|
313
|
+
* the handle names move there, clamped to the image and to {@link MIN_CROP}, and the result is
|
|
314
|
+
* mapped back. That is the whole of the turn-and-flip bookkeeping, and it is why no handle needs a
|
|
315
|
+
* table of which source edge it means.
|
|
316
|
+
*/
|
|
317
|
+
export function moveHandle(
|
|
318
|
+
recipe: Readonly<ImageRecipe>,
|
|
319
|
+
source: PixelSize,
|
|
320
|
+
handle: CropHandle,
|
|
321
|
+
dx: number,
|
|
322
|
+
dy: number,
|
|
323
|
+
): ImageRecipe {
|
|
324
|
+
const shown = displaySize(recipe.turns, source);
|
|
325
|
+
const rect = sourceToDisplay(cropOf(recipe, source), recipe, source);
|
|
326
|
+
const ratio = aspectRatio(recipe, source);
|
|
327
|
+
const moved =
|
|
328
|
+
ratio === null
|
|
329
|
+
? movedEdges(rect, shown, handle, dx, dy)
|
|
330
|
+
: movedAtRatio(rect, shown, handle, dx, dy, ratio);
|
|
331
|
+
return withDisplayCrop(recipe, source, moved);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The recipe with its crop moved by `(dx, dy)` display pixels, its size kept and inside the image. */
|
|
335
|
+
export function moveCrop(
|
|
336
|
+
recipe: Readonly<ImageRecipe>,
|
|
337
|
+
source: PixelSize,
|
|
338
|
+
dx: number,
|
|
339
|
+
dy: number,
|
|
340
|
+
): ImageRecipe {
|
|
341
|
+
const shown = displaySize(recipe.turns, source);
|
|
342
|
+
const rect = sourceToDisplay(cropOf(recipe, source), recipe, source);
|
|
343
|
+
return withDisplayCrop(recipe, source, {
|
|
344
|
+
...rect,
|
|
345
|
+
x: clamp(Math.round(rect.x + dx), 0, shown.width - rect.width),
|
|
346
|
+
y: clamp(Math.round(rect.y + dy), 0, shown.height - rect.height),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** What dragging or nudging one stop does: the area moves the crop, and a handle resizes it. */
|
|
351
|
+
export function moveStop(
|
|
352
|
+
recipe: Readonly<ImageRecipe>,
|
|
353
|
+
source: PixelSize,
|
|
354
|
+
stop: CropStop,
|
|
355
|
+
dx: number,
|
|
356
|
+
dy: number,
|
|
357
|
+
): ImageRecipe {
|
|
358
|
+
return stop === "move"
|
|
359
|
+
? moveCrop(recipe, source, dx, dy)
|
|
360
|
+
: moveHandle(recipe, source, stop, dx, dy);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The recipe with `rect`, a rectangle the author sees, as its crop. */
|
|
364
|
+
function withDisplayCrop(
|
|
365
|
+
recipe: Readonly<ImageRecipe>,
|
|
366
|
+
source: PixelSize,
|
|
367
|
+
rect: PixelRect,
|
|
368
|
+
): ImageRecipe {
|
|
369
|
+
const next = cloneRecipe(recipe);
|
|
370
|
+
const crop = displayToSource(rect, recipe, source);
|
|
371
|
+
next.crop = cropIsWhole(crop, source) ? null : crop;
|
|
372
|
+
return clampWidth(next, source);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A free handle: only the edges it names move, each clamped to the image and to {@link MIN_CROP}. */
|
|
376
|
+
function movedEdges(
|
|
377
|
+
rect: PixelRect,
|
|
378
|
+
shown: PixelSize,
|
|
379
|
+
handle: CropHandle,
|
|
380
|
+
dx: number,
|
|
381
|
+
dy: number,
|
|
382
|
+
): PixelRect {
|
|
383
|
+
let left = rect.x;
|
|
384
|
+
let top = rect.y;
|
|
385
|
+
let right = rect.x + rect.width;
|
|
386
|
+
let bottom = rect.y + rect.height;
|
|
387
|
+
if (handle.includes("w")) {
|
|
388
|
+
left = clamp(Math.round(left + dx), 0, right - MIN_CROP);
|
|
389
|
+
}
|
|
390
|
+
if (handle.includes("e")) {
|
|
391
|
+
right = clamp(Math.round(right + dx), left + MIN_CROP, shown.width);
|
|
392
|
+
}
|
|
393
|
+
if (handle.includes("n")) {
|
|
394
|
+
top = clamp(Math.round(top + dy), 0, bottom - MIN_CROP);
|
|
395
|
+
}
|
|
396
|
+
if (handle.includes("s")) {
|
|
397
|
+
bottom = clamp(Math.round(bottom + dy), top + MIN_CROP, shown.height);
|
|
398
|
+
}
|
|
399
|
+
return { height: bottom - top, width: right - left, x: left, y: top };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A handle under a held ratio. A corner keeps the opposite corner still and follows whichever axis
|
|
404
|
+
* the pointer has moved further, because following one axis alone would ignore a drag along the
|
|
405
|
+
* other. An edge keeps the opposite edge still and grows the other two sides evenly about the
|
|
406
|
+
* crop's centre. Either way the size is limited by the room the fixed point leaves in both
|
|
407
|
+
* directions, so the ratio holds at the edge of the image rather than giving way there.
|
|
408
|
+
*/
|
|
409
|
+
function movedAtRatio(
|
|
410
|
+
rect: PixelRect,
|
|
411
|
+
shown: PixelSize,
|
|
412
|
+
handle: CropHandle,
|
|
413
|
+
dx: number,
|
|
414
|
+
dy: number,
|
|
415
|
+
ratio: number,
|
|
416
|
+
): PixelRect {
|
|
417
|
+
const west = handle.includes("w");
|
|
418
|
+
const east = handle.includes("e");
|
|
419
|
+
const north = handle.includes("n");
|
|
420
|
+
const south = handle.includes("s");
|
|
421
|
+
const wider = east ? dx : west ? -dx : 0;
|
|
422
|
+
const taller = south ? dy : north ? -dy : 0;
|
|
423
|
+
const followsWidth =
|
|
424
|
+
(east || west) && (!(north || south) || Math.abs(wider) >= Math.abs(taller) * ratio);
|
|
425
|
+
const wanted = followsWidth ? rect.width + wider : (rect.height + taller) * ratio;
|
|
426
|
+
|
|
427
|
+
// The point that stays still, on each axis: an edge, or the centre when the handle names neither.
|
|
428
|
+
const anchorX = west ? rect.x + rect.width : east ? rect.x : rect.x + rect.width / 2;
|
|
429
|
+
const anchorY = north ? rect.y + rect.height : south ? rect.y : rect.y + rect.height / 2;
|
|
430
|
+
const roomX = west
|
|
431
|
+
? anchorX
|
|
432
|
+
: east
|
|
433
|
+
? shown.width - anchorX
|
|
434
|
+
: 2 * Math.min(anchorX, shown.width - anchorX);
|
|
435
|
+
const roomY = north
|
|
436
|
+
? anchorY
|
|
437
|
+
: south
|
|
438
|
+
? shown.height - anchorY
|
|
439
|
+
: 2 * Math.min(anchorY, shown.height - anchorY);
|
|
440
|
+
|
|
441
|
+
const limit = Math.min(roomX, roomY * ratio);
|
|
442
|
+
const exact = Math.min(Math.max(wanted, Math.max(MIN_CROP, MIN_CROP * ratio)), limit);
|
|
443
|
+
const width = Math.max(MIN_CROP, Math.round(exact));
|
|
444
|
+
const height = Math.max(MIN_CROP, Math.round(exact / ratio));
|
|
445
|
+
const x = west ? anchorX - width : east ? anchorX : Math.round(anchorX - width / 2);
|
|
446
|
+
const y = north ? anchorY - height : south ? anchorY : Math.round(anchorY - height / 2);
|
|
447
|
+
return {
|
|
448
|
+
height,
|
|
449
|
+
width,
|
|
450
|
+
x: clamp(x, 0, shown.width - width),
|
|
451
|
+
y: clamp(y, 0, shown.height - height),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The ratio the recipe holds its crop to, width over height as the author sees it; null when free. */
|
|
456
|
+
export function aspectRatio(recipe: Readonly<ImageRecipe>, source: PixelSize): number | null {
|
|
457
|
+
if (recipe.aspect === "free") {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
if (recipe.aspect === "original") {
|
|
461
|
+
const shown = displaySize(recipe.turns, source);
|
|
462
|
+
return shown.width / shown.height;
|
|
463
|
+
}
|
|
464
|
+
const [width, height] = recipe.aspect.split(":");
|
|
465
|
+
return Number(width) / Number(height);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The recipe held to a ratio, with its crop reshaped to the largest rectangle of that ratio in the
|
|
470
|
+
* whole image, centred where the crop is now. The whole image rather than the current crop, so that
|
|
471
|
+
* trying 1:1 and then 16:9 gives what 16:9 alone would have, instead of shrinking at every choice.
|
|
472
|
+
* Choosing Free only lets go: the crop stays as it is.
|
|
473
|
+
*/
|
|
474
|
+
export function withAspect(
|
|
475
|
+
recipe: Readonly<ImageRecipe>,
|
|
476
|
+
source: PixelSize,
|
|
477
|
+
aspect: AspectId,
|
|
478
|
+
): ImageRecipe {
|
|
479
|
+
const held = { ...cloneRecipe(recipe), aspect };
|
|
480
|
+
const ratio = aspectRatio(held, source);
|
|
481
|
+
if (ratio === null) {
|
|
482
|
+
return held;
|
|
483
|
+
}
|
|
484
|
+
const shown = displaySize(recipe.turns, source);
|
|
485
|
+
const rect = sourceToDisplay(cropOf(recipe, source), recipe, source);
|
|
486
|
+
const exact = Math.min(shown.width, shown.height * ratio);
|
|
487
|
+
const width = Math.max(MIN_CROP, Math.round(exact));
|
|
488
|
+
const height = Math.max(MIN_CROP, Math.round(exact / ratio));
|
|
489
|
+
return withDisplayCrop(held, source, {
|
|
490
|
+
height,
|
|
491
|
+
width,
|
|
492
|
+
x: clamp(Math.round(rect.x + rect.width / 2 - width / 2), 0, shown.width - width),
|
|
493
|
+
y: clamp(Math.round(rect.y + rect.height / 2 - height / 2), 0, shown.height - height),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function clamp(value: number, min: number, max: number): number {
|
|
498
|
+
return Math.min(Math.max(value, min), max);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Drop a reduction the crop has made meaningless (a width at or above the new crop width). */
|
|
502
|
+
function clampWidth(recipe: ImageRecipe, source: PixelSize): ImageRecipe {
|
|
503
|
+
if (recipe.width !== null && recipe.width >= orientedSize(recipe, source).width) {
|
|
504
|
+
recipe.width = null;
|
|
505
|
+
}
|
|
506
|
+
return recipe;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ─── Steps ───────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
/** The recipe turned one quarter clockwise (`+1`) or counter-clockwise (`-1`). */
|
|
512
|
+
export function turn(recipe: Readonly<ImageRecipe>, direction: 1 | -1): ImageRecipe {
|
|
513
|
+
const next = cloneRecipe(recipe);
|
|
514
|
+
next.turns = QUARTER_TURNS[(recipe.turns + direction + 4) % 4]!;
|
|
515
|
+
/* The flips are in the DISPLAY frame, and a quarter turn of a left-right mirror is a top-bottom
|
|
516
|
+
one, so swapping them keeps the picture the author sees turning as a whole. With both set (or
|
|
517
|
+
neither) the swap changes nothing, which is also right: two mirrors are a half turn. */
|
|
518
|
+
next.flipH = recipe.flipV;
|
|
519
|
+
next.flipV = recipe.flipH;
|
|
520
|
+
// A held ratio is as the author sees it too, and the crop has just turned with the picture.
|
|
521
|
+
next.aspect = TURNED_ASPECT[recipe.aspect];
|
|
522
|
+
/* A reduction is a width as the author sees it, and a quarter turn swaps which side that is, so
|
|
523
|
+
the same number would be a different scale. It is dropped rather than reinterpreted. */
|
|
524
|
+
next.width = null;
|
|
525
|
+
return next;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The recipe mirrored on one axis as the author sees it. */
|
|
529
|
+
export function flip(recipe: Readonly<ImageRecipe>, axis: "h" | "v"): ImageRecipe {
|
|
530
|
+
const next = cloneRecipe(recipe);
|
|
531
|
+
if (axis === "h") {
|
|
532
|
+
next.flipH = !recipe.flipH;
|
|
533
|
+
} else {
|
|
534
|
+
next.flipV = !recipe.flipV;
|
|
535
|
+
}
|
|
536
|
+
return next;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The recipe with its output width set. Only a reduction is kept: a width at or above the oriented
|
|
541
|
+
* crop, or one that is not a positive number, clears it, because a bake cannot add detail.
|
|
542
|
+
*/
|
|
543
|
+
export function withWidth(
|
|
544
|
+
recipe: Readonly<ImageRecipe>,
|
|
545
|
+
source: PixelSize,
|
|
546
|
+
width: number | null,
|
|
547
|
+
): ImageRecipe {
|
|
548
|
+
const next = cloneRecipe(recipe);
|
|
549
|
+
next.width = width === null || !Number.isFinite(width) || width < 1 ? null : Math.round(width);
|
|
550
|
+
return clampWidth(next, source);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The bounds each numeric control accepts, so the field and the recipe agree on them. */
|
|
554
|
+
export const TONE_RANGE = { max: 200, min: 0 } as const;
|
|
555
|
+
export const QUALITY_RANGE = { max: 100, min: 1 } as const;
|
|
556
|
+
|
|
557
|
+
/** The recipe with one tone or quality value set, clamped to its range. */
|
|
558
|
+
export function withValue(
|
|
559
|
+
recipe: Readonly<ImageRecipe>,
|
|
560
|
+
key: "brightness" | "contrast" | "saturation" | "quality",
|
|
561
|
+
value: number,
|
|
562
|
+
): ImageRecipe {
|
|
563
|
+
const next = cloneRecipe(recipe);
|
|
564
|
+
const range = key === "quality" ? QUALITY_RANGE : TONE_RANGE;
|
|
565
|
+
next[key] = Number.isFinite(value) ? clamp(Math.round(value), range.min, range.max) : recipe[key];
|
|
566
|
+
return next;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The width **Fit for web** proposes: the largest of the project's `images.widths` that is below
|
|
571
|
+
* the oriented crop, or null when none is (nothing to reduce to). A reduction here produces what
|
|
572
|
+
* the build would have produced from the same source.
|
|
573
|
+
*/
|
|
574
|
+
export function fitForWebWidth(
|
|
575
|
+
recipe: Readonly<ImageRecipe>,
|
|
576
|
+
source: PixelSize,
|
|
577
|
+
widths: readonly number[],
|
|
578
|
+
): number | null {
|
|
579
|
+
const oriented = orientedSize(recipe, source).width;
|
|
580
|
+
let best: number | null = null;
|
|
581
|
+
for (const width of widths) {
|
|
582
|
+
if (
|
|
583
|
+
Number.isFinite(width) &&
|
|
584
|
+
width >= 1 &&
|
|
585
|
+
width < oriented &&
|
|
586
|
+
(best === null || width > best)
|
|
587
|
+
) {
|
|
588
|
+
best = Math.round(width);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return best;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ─── Undo ────────────────────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
/** A recipe and the steps either side of it. Whole records: undo is an assignment. */
|
|
597
|
+
export interface RecipeHistory {
|
|
598
|
+
past: ImageRecipe[];
|
|
599
|
+
present: ImageRecipe;
|
|
600
|
+
future: ImageRecipe[];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** How many steps are kept behind the present. A step is a dozen numbers, so this is generous. */
|
|
604
|
+
export const RECIPE_HISTORY_LIMIT = 100;
|
|
605
|
+
|
|
606
|
+
/** A history holding only `recipe`. */
|
|
607
|
+
export function historyOf(recipe: Readonly<ImageRecipe> = IDENTITY): RecipeHistory {
|
|
608
|
+
return { future: [], past: [], present: cloneRecipe(recipe) };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Whether two recipes are the same edit. */
|
|
612
|
+
export function sameRecipe(a: Readonly<ImageRecipe>, b: Readonly<ImageRecipe>): boolean {
|
|
613
|
+
return JSON.stringify(cloneRecipe(a)) === JSON.stringify(cloneRecipe(b));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The history with `next` as the present, the old present pushed behind it and the redo side
|
|
618
|
+
* dropped. Pushing the recipe already present is not a step.
|
|
619
|
+
*/
|
|
620
|
+
export function pushStep(history: RecipeHistory, next: Readonly<ImageRecipe>): RecipeHistory {
|
|
621
|
+
if (sameRecipe(history.present, next)) {
|
|
622
|
+
return history;
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
future: [],
|
|
626
|
+
past: [...history.past, history.present].slice(-RECIPE_HISTORY_LIMIT),
|
|
627
|
+
present: cloneRecipe(next),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** One step back, or the same history when there is none. */
|
|
632
|
+
export function undoStep(history: RecipeHistory): RecipeHistory {
|
|
633
|
+
const previous = history.past.at(-1);
|
|
634
|
+
if (previous === undefined) {
|
|
635
|
+
return history;
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
future: [history.present, ...history.future],
|
|
639
|
+
past: history.past.slice(0, -1),
|
|
640
|
+
present: previous,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** One step forward, or the same history when there is none. */
|
|
645
|
+
export function redoStep(history: RecipeHistory): RecipeHistory {
|
|
646
|
+
const [next, ...rest] = history.future;
|
|
647
|
+
if (next === undefined) {
|
|
648
|
+
return history;
|
|
649
|
+
}
|
|
650
|
+
return { future: rest, past: [...history.past, history.present], present: next };
|
|
651
|
+
}
|