@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.
Files changed (271) hide show
  1. package/dist/chunks/{2020-bx702wqa.js → 2020-906vx2dq.js} +3 -3
  2. package/dist/chunks/adapter-hnvad61x.js +4 -0
  3. package/dist/chunks/add-new-flow-3tkn72ey.js +4 -0
  4. package/dist/chunks/cf-account-picker-xb8vvt2h.js +4 -0
  5. package/dist/chunks/cf-settings-mkbcz4z2.js +4 -0
  6. package/dist/chunks/components-w5zqwva1.js +4 -0
  7. package/dist/chunks/content-source-1vzxq1ba.js +4 -0
  8. package/dist/chunks/convert-to-repeater-gwzh9gar.js +4 -0
  9. package/dist/chunks/editor-wvf5e6j9.js +4 -0
  10. package/dist/chunks/entry-commands-mx2whrjx.js +4 -0
  11. package/dist/chunks/extension-sections-338ee35t.js +4 -0
  12. package/dist/chunks/file-ops-9bxvk59j.js +4 -0
  13. package/dist/chunks/files-t07yqqej.js +4 -0
  14. package/dist/chunks/format-host-5ja5gxyk.js +4 -0
  15. package/dist/chunks/fs-events-3424ezp3.js +4 -0
  16. package/dist/chunks/grid-open-1mpq9mzb.js +4 -0
  17. package/dist/chunks/{grid-view-tghcwrrt.js → grid-view-w9nfy203.js} +16 -16
  18. package/dist/chunks/icons-gb967qc8.js +4 -0
  19. package/dist/chunks/iframe-host-wsg0bn73.js +4 -0
  20. package/dist/chunks/image-bake-4afr4y6a.js +4 -0
  21. package/dist/chunks/image-editor-x7t8jbjn.js +4 -0
  22. package/dist/chunks/index-f9qr7kzh.js +4 -0
  23. package/dist/chunks/{index-vr006kbt.js → index-yjrfeks8.js} +3 -3
  24. package/dist/chunks/{javascript-z1g3s9fd.js → javascript-hy2mws8v.js} +3 -3
  25. package/dist/chunks/{jsonMode-7szwg8c4.js → jsonMode-vj30te2h.js} +6 -6
  26. package/dist/chunks/jx-validate-ttr5ewf0.js +4 -0
  27. package/dist/chunks/layers-m49nmwvx.js +4 -0
  28. package/dist/chunks/layers-panel-hvy2e549.js +4 -0
  29. package/dist/chunks/lit-html-k2mb9k0r.js +4 -0
  30. package/dist/chunks/media-library-ae67q4at.js +4 -0
  31. package/dist/chunks/media-open-yp9jm834.js +4 -0
  32. package/dist/chunks/media-picker-s41ngdfr.js +4 -0
  33. package/dist/chunks/{monaco-binding-v42w9c1v.js → monaco-binding-a51py33c.js} +3 -3
  34. package/dist/chunks/monaco-buffer-pa0g6ax7.js +4 -0
  35. package/dist/chunks/{monaco-setup-7bffxd6v.js → monaco-setup-g9kterhx.js} +3 -3
  36. package/dist/chunks/notify-bwehrp7q.js +4 -0
  37. package/dist/chunks/parse-hee7ne9b.js +4 -0
  38. package/dist/chunks/platform-kv0k65cy.js +4 -0
  39. package/dist/chunks/publish-panel-gzfxcm0t.js +4 -0
  40. package/dist/chunks/publish-quick-w3nxd7q4.js +4 -0
  41. package/dist/chunks/publish-workspace-dppd82tc.js +4 -0
  42. package/dist/chunks/quick-search-tdxaam55.js +4 -0
  43. package/dist/chunks/references-vqyr5s9x.js +4 -0
  44. package/dist/chunks/right-panel-0yrw4cgz.js +4 -0
  45. package/dist/chunks/source-control-6fk72me2.js +4 -0
  46. package/dist/chunks/{studio-j3d2fjqz.js → studio-0939t1q1.js} +4 -4
  47. package/dist/chunks/studio-0gvvqj3h.js +5 -0
  48. package/dist/chunks/studio-0khd8xtx.js +4 -0
  49. package/dist/chunks/studio-0w3541dg.js +7 -0
  50. package/dist/chunks/{studio-9b7177jq.js → studio-0x6f3nk9.js} +4 -4
  51. package/dist/chunks/studio-1410q5dw.js +5 -0
  52. package/dist/chunks/{studio-awxwjawq.js → studio-15hwqgd8.js} +4 -4
  53. package/dist/chunks/studio-1ks5d9y4.js +5 -0
  54. package/dist/chunks/{studio-881z1xrx.js → studio-2ggfzrrd.js} +4 -4
  55. package/dist/chunks/{studio-4jhw38b2.js → studio-321ym1tt.js} +4 -4
  56. package/dist/chunks/studio-3c10v2q1.js +5 -0
  57. package/dist/chunks/{studio-bvs9kpst.js → studio-3na1r48j.js} +8 -8
  58. package/dist/chunks/{studio-86r8hp7r.js → studio-3q7mhbgx.js} +5 -5
  59. package/dist/chunks/studio-3qr08sxq.js +4 -0
  60. package/dist/chunks/studio-3xk9zdgb.js +5 -0
  61. package/dist/chunks/{studio-qh8berdz.js → studio-423tfdte.js} +4 -4
  62. package/dist/chunks/{studio-75kcdbsk.js → studio-45mj4jqe.js} +4 -4
  63. package/dist/chunks/studio-4f7thq0p.js +5 -0
  64. package/dist/chunks/studio-4fnb5648.js +5 -0
  65. package/dist/chunks/studio-4zd6an2b.js +5 -0
  66. package/dist/chunks/studio-53wy9ek3.js +5 -0
  67. package/dist/chunks/studio-5n193zqd.js +5 -0
  68. package/dist/chunks/{studio-9rbe2xpw.js → studio-5tet77de.js} +3 -3
  69. package/dist/chunks/{studio-ybnwymxw.js → studio-7rrdbvex.js} +4 -4
  70. package/dist/chunks/studio-8kx6m0rg.js +5 -0
  71. package/dist/chunks/{studio-sxdb9h3q.js → studio-90zkgne5.js} +7 -7
  72. package/dist/chunks/studio-9c6cbq2c.js +4 -0
  73. package/dist/chunks/studio-9dq7rwc7.js +5 -0
  74. package/dist/chunks/{studio-cg1bfyhf.js → studio-9qx1ywbv.js} +8 -8
  75. package/dist/chunks/studio-a65kbs2e.js +9 -0
  76. package/dist/chunks/studio-aam8zt8z.js +5 -0
  77. package/dist/chunks/{studio-er5f383a.js → studio-abh9kfdh.js} +4 -4
  78. package/dist/chunks/studio-b4e0ftkt.js +5 -0
  79. package/dist/chunks/studio-b68k0z00.js +5 -0
  80. package/dist/chunks/{studio-399a25za.js → studio-bct64zmj.js} +4 -4
  81. package/dist/chunks/studio-bz0b5brj.js +6 -0
  82. package/dist/chunks/studio-cfnn70h1.js +5 -0
  83. package/dist/chunks/studio-daksw8kh.js +5 -0
  84. package/dist/chunks/{studio-r7pf0rkv.js → studio-e7vjyp04.js} +7 -7
  85. package/dist/chunks/studio-ea6r1zyr.js +5 -0
  86. package/dist/chunks/studio-egnjpyde.js +9 -0
  87. package/dist/chunks/studio-ejrypvmq.js +5 -0
  88. package/dist/chunks/studio-eqcddvc2.js +928 -0
  89. package/dist/chunks/studio-f8tyeyzt.js +5 -0
  90. package/dist/chunks/studio-fj6c905s.js +5 -0
  91. package/dist/chunks/{studio-646nn2ys.js → studio-fkrr7kgg.js} +5 -5
  92. package/dist/chunks/studio-fspe7m9t.js +5 -0
  93. package/dist/chunks/studio-g0hxtcga.js +5 -0
  94. package/dist/chunks/{studio-th7z3fg7.js → studio-g9brbewz.js} +4 -4
  95. package/dist/chunks/studio-ggx3j7nd.js +5 -0
  96. package/dist/chunks/{studio-43y04qck.js → studio-hfxttkyr.js} +4 -4
  97. package/dist/chunks/studio-hh394phe.js +5 -0
  98. package/dist/chunks/{studio-pjtntksy.js → studio-j0zf5x2x.js} +4 -4
  99. package/dist/chunks/studio-jeb7hth3.js +5 -0
  100. package/dist/chunks/studio-k0xzrse2.js +64 -0
  101. package/dist/chunks/studio-k1xqrgm5.js +5 -0
  102. package/dist/chunks/studio-k2evngrj.js +7 -0
  103. package/dist/chunks/studio-k3we3kqb.js +5 -0
  104. package/dist/chunks/{studio-sevbc3xv.js → studio-n9qba0gx.js} +4 -4
  105. package/dist/chunks/{studio-6hzdjfv0.js → studio-nf8esxxc.js} +4 -4
  106. package/dist/chunks/studio-nn4f9m9c.js +5 -0
  107. package/dist/chunks/studio-nskxe9q1.js +6 -0
  108. package/dist/chunks/{studio-ea74s8c1.js → studio-nvcn2ykc.js} +4 -4
  109. package/dist/chunks/studio-nwynxqtb.js +5 -0
  110. package/dist/chunks/studio-nzemfbkk.js +14 -0
  111. package/dist/chunks/studio-p2a03pgm.js +5 -0
  112. package/dist/chunks/{studio-4g8yb6jn.js → studio-pm17wdc4.js} +4 -4
  113. package/dist/chunks/{studio-71m92v9s.js → studio-q80fg1d6.js} +7 -7
  114. package/dist/chunks/studio-r050sez0.js +6 -0
  115. package/dist/chunks/studio-s7t4vp1d.js +6 -0
  116. package/dist/chunks/studio-s9fng7m8.js +7 -0
  117. package/dist/chunks/studio-sce30r7b.js +4 -0
  118. package/dist/chunks/studio-stahswgb.js +7 -0
  119. package/dist/chunks/studio-swedpd82.js +5 -0
  120. package/dist/chunks/studio-thz5t120.js +5 -0
  121. package/dist/chunks/studio-tpxcryf2.js +5 -0
  122. package/dist/chunks/{studio-wc6za3zs.js → studio-v5wktj6f.js} +4 -4
  123. package/dist/chunks/{studio-kg1abnz0.js → studio-vpevkfxw.js} +4 -4
  124. package/dist/chunks/studio-waekn290.js +5 -0
  125. package/dist/chunks/studio-wfct4h6n.js +5 -0
  126. package/dist/chunks/studio-wgc54sxf.js +5 -0
  127. package/dist/chunks/studio-wqfaqw4m.js +5 -0
  128. package/dist/chunks/studio-wse1rs8t.js +7 -0
  129. package/dist/chunks/studio-x0gsfr1d.js +16 -0
  130. package/dist/chunks/{studio-hhp4x8xf.js → studio-xpx91fby.js} +5 -5
  131. package/dist/chunks/studio-y1b86ast.js +5 -0
  132. package/dist/chunks/studio-yrcrncev.js +5 -0
  133. package/dist/chunks/{studio-rzqaz21g.js → studio-yzpskb0j.js} +4 -4
  134. package/dist/chunks/studio-zfdbmhp3.js +5 -0
  135. package/dist/chunks/{tsMode-f54j66ym.js → tsMode-jg1dtphj.js} +7 -7
  136. package/dist/chunks/workspace-9btkc8tk.js +4 -0
  137. package/dist/chunks/workspace-home-qcc4z5ag.js +4 -0
  138. package/dist/chunks/{ws-client-e8kj684q.js → ws-client-arz2qsy9.js} +3 -3
  139. package/dist/iframe-entry.js +13 -13
  140. package/dist/manifest.json +83 -78
  141. package/dist/studio.js +84 -84
  142. package/package.json +2 -2
  143. package/src/browse/library-pane.ts +44 -29
  144. package/src/browse/library-preview.ts +6 -2
  145. package/src/canvas/asset-refs.ts +13 -4
  146. package/src/canvas/asset-resolve.ts +54 -11
  147. package/src/canvas/canvas-render.ts +25 -0
  148. package/src/canvas/iframe-entry.ts +5 -1
  149. package/src/canvas/iframe-host.ts +16 -6
  150. package/src/canvas/iframe-protocol.ts +9 -1
  151. package/src/commands/context.ts +5 -0
  152. package/src/commands/live-context.ts +5 -2
  153. package/src/editor/shortcuts.ts +3 -0
  154. package/src/files/asset-versions.ts +108 -2
  155. package/src/files/file-ops.ts +11 -1
  156. package/src/files/files.ts +111 -48
  157. package/src/files/media-paths.ts +12 -4
  158. package/src/files/media-upload.ts +195 -21
  159. package/src/files/new-file-name.ts +39 -1
  160. package/src/media/image-bake.ts +164 -0
  161. package/src/media/image-editor.ts +846 -0
  162. package/src/media/image-format.ts +262 -0
  163. package/src/media/image-recipe.ts +651 -0
  164. package/src/media/media-commands.ts +65 -1
  165. package/src/media/media-library-model.ts +1 -1
  166. package/src/media/media-library.ts +175 -15
  167. package/src/media/media-open.ts +156 -3
  168. package/src/media/media-pane.ts +8 -4
  169. package/src/panels/pane-context.ts +6 -1
  170. package/src/panels/tab-strip.ts +12 -3
  171. package/src/studio.ts +13 -2
  172. package/src/surfaces/image-editor.json +1390 -0
  173. package/src/surfaces/image-editor.ts +272 -0
  174. package/src/surfaces/media-library.json +129 -1
  175. package/src/surfaces/media-library.ts +17 -2
  176. package/src/types.ts +2 -1
  177. package/src/workspace/tab-workspace.ts +2 -1
  178. package/src/workspace/workspace-switch.ts +3 -1
  179. package/src/workspace/workspace.ts +22 -1
  180. package/dist/chunks/adapter-pp011m5x.js +0 -4
  181. package/dist/chunks/add-new-flow-ecqmzz76.js +0 -4
  182. package/dist/chunks/cf-account-picker-c07znkqt.js +0 -4
  183. package/dist/chunks/cf-settings-0v2bmqy0.js +0 -4
  184. package/dist/chunks/components-yn0dn2pq.js +0 -4
  185. package/dist/chunks/content-source-g0j41yy6.js +0 -4
  186. package/dist/chunks/convert-to-repeater-sbjk7tgn.js +0 -4
  187. package/dist/chunks/editor-xz02jeh1.js +0 -4
  188. package/dist/chunks/entry-commands-kmt02crp.js +0 -4
  189. package/dist/chunks/extension-sections-0rprf12d.js +0 -4
  190. package/dist/chunks/file-ops-mexj5qed.js +0 -4
  191. package/dist/chunks/files-b2tcppdd.js +0 -4
  192. package/dist/chunks/format-host-btsevv0c.js +0 -4
  193. package/dist/chunks/fs-events-r0apejva.js +0 -4
  194. package/dist/chunks/grid-open-n2a8xkht.js +0 -4
  195. package/dist/chunks/icons-853n6qt3.js +0 -4
  196. package/dist/chunks/iframe-host-gx0ypyh3.js +0 -4
  197. package/dist/chunks/index-bs4k9tr6.js +0 -4
  198. package/dist/chunks/jx-validate-ymf10teh.js +0 -4
  199. package/dist/chunks/layers-p9nfya38.js +0 -4
  200. package/dist/chunks/layers-panel-tt1hzhhf.js +0 -4
  201. package/dist/chunks/lit-html-x18n2sfz.js +0 -4
  202. package/dist/chunks/media-library-0n45gv8r.js +0 -4
  203. package/dist/chunks/media-picker-s09at95z.js +0 -4
  204. package/dist/chunks/monaco-buffer-een2ea0v.js +0 -4
  205. package/dist/chunks/notify-qwb8radt.js +0 -4
  206. package/dist/chunks/parse-v77xtwv7.js +0 -4
  207. package/dist/chunks/platform-8jnez0wr.js +0 -4
  208. package/dist/chunks/publish-panel-aysnj611.js +0 -4
  209. package/dist/chunks/publish-quick-kpbqs9mz.js +0 -4
  210. package/dist/chunks/publish-workspace-nd0a8q99.js +0 -4
  211. package/dist/chunks/quick-search-vg0jkhk2.js +0 -4
  212. package/dist/chunks/references-a7v6a0e2.js +0 -4
  213. package/dist/chunks/right-panel-cvk1vxx5.js +0 -4
  214. package/dist/chunks/source-control-fsqzc6he.js +0 -4
  215. package/dist/chunks/studio-05jfwbmf.js +0 -5
  216. package/dist/chunks/studio-0q5jwddh.js +0 -9
  217. package/dist/chunks/studio-0w2dr0q8.js +0 -5
  218. package/dist/chunks/studio-1tzpds97.js +0 -9
  219. package/dist/chunks/studio-216b27sv.js +0 -4
  220. package/dist/chunks/studio-2ejhqns4.js +0 -5
  221. package/dist/chunks/studio-2r90cts3.js +0 -16
  222. package/dist/chunks/studio-399xknkb.js +0 -6
  223. package/dist/chunks/studio-4ncmwqw5.js +0 -5
  224. package/dist/chunks/studio-5atwjgkj.js +0 -7
  225. package/dist/chunks/studio-5frqk7c0.js +0 -5
  226. package/dist/chunks/studio-6cwj6xrx.js +0 -5
  227. package/dist/chunks/studio-78pywnp2.js +0 -6
  228. package/dist/chunks/studio-886t6ztg.js +0 -5
  229. package/dist/chunks/studio-8wnvtr4z.js +0 -5
  230. package/dist/chunks/studio-8z66jk4z.js +0 -5
  231. package/dist/chunks/studio-b2qpt0xy.js +0 -14
  232. package/dist/chunks/studio-c71806xz.js +0 -7
  233. package/dist/chunks/studio-cbwks69x.js +0 -928
  234. package/dist/chunks/studio-cg710sk9.js +0 -5
  235. package/dist/chunks/studio-cw0wb9qk.js +0 -7
  236. package/dist/chunks/studio-cyheys7k.js +0 -5
  237. package/dist/chunks/studio-ea5m9kg5.js +0 -5
  238. package/dist/chunks/studio-efdxjwyp.js +0 -64
  239. package/dist/chunks/studio-egmw2837.js +0 -5
  240. package/dist/chunks/studio-f746039f.js +0 -5
  241. package/dist/chunks/studio-fmdymd9v.js +0 -5
  242. package/dist/chunks/studio-gcm12z00.js +0 -6
  243. package/dist/chunks/studio-gj98m8gy.js +0 -7
  244. package/dist/chunks/studio-hasbgjah.js +0 -5
  245. package/dist/chunks/studio-hz5n7x27.js +0 -5
  246. package/dist/chunks/studio-jeraxdgv.js +0 -7
  247. package/dist/chunks/studio-jvgek9xt.js +0 -5
  248. package/dist/chunks/studio-jws1a87n.js +0 -5
  249. package/dist/chunks/studio-k2sz13zg.js +0 -5
  250. package/dist/chunks/studio-k3m4jkdk.js +0 -5
  251. package/dist/chunks/studio-kg5ya5r2.js +0 -5
  252. package/dist/chunks/studio-khwbgrtg.js +0 -5
  253. package/dist/chunks/studio-mx6wzh25.js +0 -5
  254. package/dist/chunks/studio-mxs3ar9t.js +0 -5
  255. package/dist/chunks/studio-nm4szey4.js +0 -5
  256. package/dist/chunks/studio-nqxqf8p2.js +0 -4
  257. package/dist/chunks/studio-qn7z4krm.js +0 -5
  258. package/dist/chunks/studio-qyed0ah6.js +0 -5
  259. package/dist/chunks/studio-r4bcdwhq.js +0 -5
  260. package/dist/chunks/studio-rtfnhrdg.js +0 -5
  261. package/dist/chunks/studio-s5tzw84t.js +0 -5
  262. package/dist/chunks/studio-t51y8v1d.js +0 -5
  263. package/dist/chunks/studio-t98vq4r6.js +0 -5
  264. package/dist/chunks/studio-tsxpqvh0.js +0 -6
  265. package/dist/chunks/studio-v1yascv2.js +0 -5
  266. package/dist/chunks/studio-wxq6v5sv.js +0 -5
  267. package/dist/chunks/studio-y60sts62.js +0 -4
  268. package/dist/chunks/studio-zndrqawb.js +0 -5
  269. package/dist/chunks/studio-zwaxn5s2.js +0 -5
  270. package/dist/chunks/workspace-home-x1787wpv.js +0 -4
  271. package/dist/chunks/workspace-zpe1np43.js +0 -4
@@ -0,0 +1,846 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * The Image editor — an image asset edited where it is opened (studio.md §9.5).
4
+ *
5
+ * A mode of the media tab, beside the read-only viewer (`media/media-pane.ts`), because losing
6
+ * unsaved work is this feature's worst outcome and §14.7 already owns that problem: the dirty-close
7
+ * prompt, the close that waits on the write, and the two-answer variant for a collaborator who
8
+ * cannot write. This module is the flow; `surfaces/image-editor.json` is the markup, reached
9
+ * through a dynamic import so neither it nor the bake (`media/image-bake.ts`) is in the entry
10
+ * bundle.
11
+ *
12
+ * **Two maps, at two scopes.** The recipe, its undo stack, the decoded source and the dirty flag
13
+ * are per TAB, because the mode is per tab (`tab.session.ui.canvasMode`) and a crop belongs to the
14
+ * file: two panes showing one image tab are both in the editor, and they show one pending crop, so
15
+ * {@link saveImage}, {@link imageEditorDirty} and `shouldWarnOnClose` all have one subject. Only
16
+ * the mount, its effect scope, the roving handle and an in-flight drag are per PANE.
17
+ *
18
+ * **One write door.** The chord (`saveFile` delegates here), the Save button and the close prompt
19
+ * all reach {@link saveImage}, and the refusals live inside it rather than at any caller.
20
+ *
21
+ * @docs studio/projects/media
22
+ */
23
+
24
+ import { isNumber } from "@jxsuite/schema/guards";
25
+ import { errorMessage } from "@jxsuite/schema/parse";
26
+ import { effect, effectScope, reactive, toRaw } from "../reactivity";
27
+ import { getPlatform, hasPlatform } from "../platform";
28
+ import { projectState } from "../store";
29
+ import { notify } from "../services/notify";
30
+ import { announce } from "../services/announce";
31
+ import { paneRegion } from "../ui/regions";
32
+ import { collabReadOnly } from "../collab/collab-session";
33
+ import { setHistoryDelegate } from "../tabs/transact";
34
+ import { setEditBesideDocument, tabIsLive, workspace } from "../workspace/workspace";
35
+ import { formatBytes, recordImageSize } from "../files/media-meta";
36
+ import { replaceAsset } from "../files/media-upload";
37
+ import { editRefusal, mimeForPath, sniffImage, takesQuality } from "./image-format";
38
+ import {
39
+ ASPECTS,
40
+ CROP_STOPS,
41
+ HANDLE_LABELS,
42
+ cropOf,
43
+ displaySize,
44
+ filterString,
45
+ fitForWebWidth,
46
+ flip,
47
+ historyOf,
48
+ isIdentity,
49
+ moveStop,
50
+ orientedSize,
51
+ outputSize,
52
+ pushStep,
53
+ redoStep,
54
+ sourceToDisplay,
55
+ swapsAxes,
56
+ turn,
57
+ undoStep,
58
+ withAspect,
59
+ withValue,
60
+ withWidth,
61
+ } from "./image-recipe";
62
+ import type { CropStop, ImageRecipe, PixelSize, RecipeHistory } from "./image-recipe";
63
+ import type {
64
+ ImageEditorActions,
65
+ ImageEditorSurfaceHandle,
66
+ ImageEditorView,
67
+ ImageHandleRow,
68
+ } from "../surfaces/image-editor";
69
+ import type * as ImageEditorSurfaceModule from "../surfaces/image-editor";
70
+ import type * as ImageBakeModule from "./image-bake";
71
+ import type { BakeResult } from "./image-bake";
72
+ import type { CanvasSurface } from "../canvas/canvas-surface";
73
+ import type { HistoryDelegate } from "../tabs/transact";
74
+ import type { Tab } from "../tabs/tab";
75
+
76
+ /** The `canvasMode` the Image editor draws under. */
77
+ export const IMAGE_MODE = "image";
78
+
79
+ /**
80
+ * The widths the build derives when a project declares none (`packages/compiler`'s site loader).
81
+ * Read here rather than imported, so Studio keeps its dependencies; **Fit for web** proposes from
82
+ * the project's own `images.widths` first and from these only in its absence.
83
+ */
84
+ export const BUILD_DEFAULT_WIDTHS: readonly number[] = [320, 640, 960, 1280, 1920];
85
+
86
+ // ─── Lazy modules ────────────────────────────────────────────────────────────
87
+
88
+ /* One cached promise per module: two dynamic imports of one module in flight at once is the shape
89
+ that loses its coverage record (AGENTS.md), and it would also be two requests for one chunk. */
90
+ let _surface: Promise<typeof ImageEditorSurfaceModule> | null = null;
91
+ let _bake: Promise<typeof ImageBakeModule> | null = null;
92
+
93
+ function surfaceModule(): Promise<typeof ImageEditorSurfaceModule> {
94
+ _surface ??= import("../surfaces/image-editor");
95
+ return _surface;
96
+ }
97
+
98
+ function bakeModule(): Promise<typeof ImageBakeModule> {
99
+ _bake ??= import("./image-bake");
100
+ return _bake;
101
+ }
102
+
103
+ // ─── Per tab ─────────────────────────────────────────────────────────────────
104
+
105
+ /** The bytes the editor holds, and what the page shows them through. */
106
+ interface ImageSource {
107
+ bytes: Uint8Array<ArrayBuffer>;
108
+ /** A same-origin `blob:` URL for {@link ImageSource.bytes}, revoked when the source is replaced. */
109
+ url: string;
110
+ }
111
+
112
+ /** One tab's edit. Reactive, so every pane showing the tab repaints from one record. */
113
+ interface ImageSession {
114
+ history: RecipeHistory;
115
+ /** The recipe mid-drag, pushed as ONE step when the gesture ends; null between gestures. */
116
+ live: ImageRecipe | null;
117
+ source: ImageSource | null;
118
+ /**
119
+ * The decoded size, orientation applied: the frame the crop is stored in. Kept when the bytes are
120
+ * released, because whether the tab holds unsaved work must not depend on a pane showing it.
121
+ */
122
+ size: PixelSize | null;
123
+ /**
124
+ * Whether {@link ImageSession.size} is the decoded size rather than the header's first guess. No
125
+ * gesture is taken until it is, because a crop is stored in the decoded frame, and the two differ
126
+ * for a photograph with an orientation tag: a crop made in the header's frame lands elsewhere.
127
+ */
128
+ decoded: boolean;
129
+ /**
130
+ * Whether a number field holds text it has not committed. That is unsaved work like a Monaco
131
+ * buffer's (`tabs/typing.ts`), and what lets the Save chord through to commit and write it.
132
+ */
133
+ typed: boolean;
134
+ status: "loading" | "ready" | "refused";
135
+ message: string;
136
+ saving: boolean;
137
+ /** Bumped by every load, so a read that lands after a newer one began is dropped. */
138
+ generation: number;
139
+ }
140
+
141
+ /**
142
+ * Per-tab sessions, keyed by the RAW tab: the readers reach tabs through reactive proxies and the
143
+ * writers through the raw object, and a closed tab's edit goes with it rather than resurfacing when
144
+ * the same path is opened again.
145
+ */
146
+ const _sessions = new WeakMap<Tab, ImageSession>();
147
+
148
+ function sessionOf(tab: Tab): ImageSession | undefined {
149
+ return _sessions.get(toRaw(tab));
150
+ }
151
+
152
+ function sessionFor(tab: Tab): ImageSession {
153
+ const raw = toRaw(tab);
154
+ let session = _sessions.get(raw);
155
+ if (!session) {
156
+ session = reactive<ImageSession>({
157
+ decoded: false,
158
+ generation: 0,
159
+ history: historyOf(),
160
+ live: null,
161
+ message: "Reading the image…",
162
+ saving: false,
163
+ size: null,
164
+ source: null,
165
+ status: "loading",
166
+ typed: false,
167
+ });
168
+ _sessions.set(raw, session);
169
+ /* ⌘Z and the command bar's Undo are the tab's history, and this tab's history is the recipe: its
170
+ document is a stub with nothing to undo. Registered under both faces of the tab, because a
171
+ writer holds the raw object and a reader the reactive proxy, and `transact`'s map is keyed by
172
+ whichever it is handed. */
173
+ const delegate = historyDelegateFor(session);
174
+ setHistoryDelegate(raw, delegate);
175
+ setHistoryDelegate(tab, delegate);
176
+ }
177
+ return session;
178
+ }
179
+
180
+ /** The recipe's undo stack, as the tab's history. */
181
+ function historyDelegateFor(session: ImageSession): HistoryDelegate {
182
+ return {
183
+ canRedo: () => !session.saving && session.history.future.length > 0,
184
+ canUndo: () => !session.saving && session.history.past.length > 0,
185
+ redo: () => {
186
+ if (!session.saving) {
187
+ session.history = redoStep(session.history);
188
+ }
189
+ },
190
+ undo: () => {
191
+ if (!session.saving) {
192
+ session.history = undoStep(session.history);
193
+ }
194
+ },
195
+ };
196
+ }
197
+
198
+ /** The recipe on screen: the in-flight drag's, or the history's present. */
199
+ function currentRecipe(session: ImageSession): ImageRecipe {
200
+ return session.live ?? session.history.present;
201
+ }
202
+
203
+ /** Point a session at new bytes, revoking the URL the old ones were shown through. */
204
+ function setSource(session: ImageSession, bytes: Uint8Array<ArrayBuffer>, mime: string): void {
205
+ /* Swapped in place rather than through `loading`, so a save leaves the frame standing: only the
206
+ picture's src changes, and a keyboard user's focus stays on the handle they were using. */
207
+ const previous = session.source;
208
+ session.source = { bytes, url: URL.createObjectURL(new Blob([bytes], { type: mime })) };
209
+ session.status = "ready";
210
+ session.message = "";
211
+ if (previous !== null) {
212
+ URL.revokeObjectURL(previous.url);
213
+ }
214
+ }
215
+
216
+ /** Drop the bytes and their URL; the next mount or save reads them again. */
217
+ function releaseSource(session: ImageSession): void {
218
+ if (session.source !== null) {
219
+ URL.revokeObjectURL(session.source.url);
220
+ session.source = null;
221
+ session.status = "loading";
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Read the file's bytes into the session, unless it already holds them.
227
+ *
228
+ * The editable set is decided HERE, from the bytes (§9.5): an animated `.png` or `.webp`, bytes of
229
+ * another format than the extension says, or a source past the pixel ceiling is refused with the
230
+ * reason, before anything is decoded.
231
+ */
232
+ async function ensureSource(session: ImageSession, path: string): Promise<void> {
233
+ if (session.source !== null) {
234
+ return;
235
+ }
236
+ session.generation += 1;
237
+ const { generation } = session;
238
+ session.status = "loading";
239
+ session.message = "Reading the image…";
240
+ const platform = hasPlatform() ? getPlatform() : null;
241
+ const read = platform?.readFileBytes;
242
+ if (read === undefined) {
243
+ session.status = "refused";
244
+ session.message = "This backend cannot hand Studio a file's raw bytes, so it cannot edit one.";
245
+ return;
246
+ }
247
+ let bytes: Uint8Array<ArrayBuffer>;
248
+ try {
249
+ bytes = new Uint8Array(await read.call(platform, path));
250
+ } catch (error) {
251
+ if (session.generation === generation) {
252
+ session.status = "refused";
253
+ session.message = `Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`;
254
+ }
255
+ return;
256
+ }
257
+ if (session.generation !== generation) {
258
+ return;
259
+ }
260
+ const refusal = editRefusal(path, bytes);
261
+ if (refusal !== null) {
262
+ session.status = "refused";
263
+ session.message = refusal;
264
+ return;
265
+ }
266
+ /* The header's size is a first guess, replaced by the decoded size the moment the picture loads;
267
+ the two differ only for a photograph with an orientation tag. A size already known (the bytes
268
+ were released and are being read again) is the decoded one, and is kept. */
269
+ session.size ??= sniffImage(bytes).size;
270
+ setSource(session, bytes, mimeForPath(path) ?? "");
271
+ }
272
+
273
+ /** Whether this tab holds an image edit that has not been written. */
274
+ export function imageEditorDirty(tab: Tab): boolean {
275
+ const session = sessionOf(tab);
276
+ const size = session?.size;
277
+ if (session === undefined || size === null || size === undefined) {
278
+ return false;
279
+ }
280
+ return session.typed || !isIdentity(currentRecipe(session), size);
281
+ }
282
+
283
+ /** Replace the recipe as one undoable step. */
284
+ function commit(session: ImageSession, next: ImageRecipe): void {
285
+ session.live = null;
286
+ session.history = pushStep(session.history, next);
287
+ }
288
+
289
+ // ─── Per pane ────────────────────────────────────────────────────────────────
290
+
291
+ interface DragState {
292
+ handle: CropStop;
293
+ pointerId: number;
294
+ startX: number;
295
+ startY: number;
296
+ start: ImageRecipe;
297
+ /** Image pixels per screen pixel in the frame the author is looking at. */
298
+ scale: number;
299
+ }
300
+
301
+ interface ImagePane {
302
+ paneId: string;
303
+ tab: Tab;
304
+ wrap: HTMLElement;
305
+ scope: { stop: () => void; run: <T>(fn: () => T) => T | undefined };
306
+ surface: ImageEditorSurfaceHandle | null;
307
+ disposed: boolean;
308
+ drag: DragState | null;
309
+ /** The one crop stop in the tab order. */
310
+ active: CropStop;
311
+ /** The rows last drawn, kept while the active handle is unchanged so the list is not rebuilt. */
312
+ rows: ImageHandleRow[];
313
+ }
314
+
315
+ const _panes = new Map<string, ImagePane>();
316
+
317
+ /** Whether this tab's editor is mounted in this pane and still in the document. */
318
+ export function imageEditorMounted(paneId: string, tab: Tab): boolean {
319
+ const pane = _panes.get(paneId);
320
+ return (
321
+ pane !== undefined &&
322
+ toRaw(pane.tab) === toRaw(tab) &&
323
+ pane.wrap.isConnected &&
324
+ (pane.surface === null || pane.surface.attached())
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Tear one pane's editor down (mode change, tab switch, project close). Idempotent.
330
+ *
331
+ * The recipe stays with the tab, because it is the tab's unsaved work and §14.7 asks about it on
332
+ * close. The decoded source goes when the last pane showing the tab does: it is re-read on the next
333
+ * mount, and holding a full-size image for a tab nobody is looking at is the one allocation here
334
+ * that is large.
335
+ */
336
+ export function detachImageEditor(paneId: string): void {
337
+ const pane = _panes.get(paneId);
338
+ if (!pane) {
339
+ return;
340
+ }
341
+ pane.disposed = true;
342
+ pane.scope.stop();
343
+ pane.surface?.dispose();
344
+ _panes.delete(paneId);
345
+ const session = sessionOf(pane.tab);
346
+ // A gesture that never ended is not a step (§9.5), so the drag this pane was running is abandoned.
347
+ if (pane.drag !== null && session !== undefined) {
348
+ session.live = null;
349
+ }
350
+ // The field that held any uncommitted text has just gone with the pane.
351
+ if (session !== undefined && panesShowing(pane.tab).length === 0) {
352
+ session.typed = false;
353
+ }
354
+ // A save running now releases them itself when it finishes, having needed them until then.
355
+ if (panesShowing(pane.tab).length === 0 && session !== undefined && !session.saving) {
356
+ releaseSource(session);
357
+ session.generation += 1;
358
+ }
359
+ }
360
+
361
+ /** The handle rows, rebuilt only when the roving stop moves. */
362
+ function handleRows(pane: ImagePane): ImageHandleRow[] {
363
+ if (pane.rows.length === 0 || pane.rows.find((row) => row.tabindex === "0")?.id !== pane.active) {
364
+ pane.rows = CROP_STOPS.map((id) => ({
365
+ id,
366
+ label: HANDLE_LABELS[id],
367
+ tabindex: id === pane.active ? "0" : "-1",
368
+ }));
369
+ }
370
+ return pane.rows;
371
+ }
372
+
373
+ /** A number as a percentage string, to four places — enough for a 16384-pixel side. */
374
+ function percent(value: number): string {
375
+ return `${Number(value.toFixed(4))}%`;
376
+ }
377
+
378
+ /** The widths **Fit for web** chooses from: the project's own, or the build's defaults. */
379
+ function projectWidths(): readonly number[] {
380
+ /* SAFETY: `images` is the project's build section; only an array of numbers is used, and any
381
+ other shape falls back to the build's own defaults, which is what the build would do. */
382
+ const images = projectState?.projectConfig?.images as { widths?: unknown } | undefined;
383
+ const widths = images?.widths;
384
+ return Array.isArray(widths) && widths.length > 0 && widths.every((width) => isNumber(width))
385
+ ? widths
386
+ : BUILD_DEFAULT_WIDTHS;
387
+ }
388
+
389
+ /** The ratio picker's rows, built once: the options never change, so the list is never redrawn. */
390
+ const ASPECT_OPTIONS = ASPECTS.map(({ id, label }) => ({ label, value: id }));
391
+
392
+ /** The projection for one pane. Pure over the session and the pane. */
393
+ function viewOf(pane: ImagePane, session: ImageSession): ImageEditorView {
394
+ const path = pane.tab.documentPath ?? "";
395
+ const name = path.split("/").pop() ?? path;
396
+ const recipe = currentRecipe(session);
397
+ const size = session.size ?? { height: 1, width: 1 };
398
+ const shown = displaySize(recipe.turns, size);
399
+ const crop = sourceToDisplay(cropOf(recipe, size), recipe, size);
400
+ const oriented = orientedSize(recipe, size);
401
+ const out = outputSize(recipe, size);
402
+ const fit = fitForWebWidth(recipe, size, projectWidths());
403
+ const mime = mimeForPath(path) ?? "";
404
+ const known = session.source !== null && session.decoded && session.size !== null;
405
+ const dirty = known && (session.typed || !isIdentity(recipe, size));
406
+ const odd = swapsAxes(recipe.turns);
407
+ return {
408
+ alt: `${name}, being edited`,
409
+ aspect: recipe.aspect,
410
+ aspectOptions: ASPECT_OPTIONS,
411
+ brightness: String(recipe.brightness),
412
+ busy: session.saving || !known,
413
+ /* Not while saving: a browser takes focus from an input as it is disabled, and a save from the
414
+ keyboard must leave the author where they were. A save refuses edits itself. */
415
+ undecoded: !known,
416
+ cannotFit: session.saving || !known || fit === null || fit === out.width,
417
+ cannotRedo: session.saving || session.history.future.length === 0,
418
+ cannotSave: session.saving || !dirty || collabReadOnly(pane.tab),
419
+ cannotUndo: session.saving || session.history.past.length === 0,
420
+ clean: session.saving || !dirty,
421
+ contrast: String(recipe.contrast),
422
+ cropH: percent((crop.height / shown.height) * 100),
423
+ cropW: percent((crop.width / shown.width) * 100),
424
+ cropX: percent((crop.x / shown.width) * 100),
425
+ cropY: percent((crop.y / shown.height) * 100),
426
+ facts: known
427
+ ? `Output ${out.width} × ${out.height} · crop ${oriented.width} × ${oriented.height} · source ${size.width} × ${size.height}`
428
+ : "",
429
+ filter: filterString(recipe) || "none",
430
+ fitHint:
431
+ fit === null
432
+ ? "Already no wider than the largest width the build derives."
433
+ : "The largest of the project's image widths (images.widths, or the build's defaults) below this crop.",
434
+ fitLabel: fit === null ? "Fit for web" : `Fit for web (${fit} px)`,
435
+ handleHelpId: `${paneRegion(pane.paneId, "image")}/handle-help`,
436
+ handles: handleRows(pane),
437
+ imageHeight: odd ? percent((size.height / shown.height) * 100) : "100%",
438
+ imageTransform: `translate(-50%, -50%) scale(${recipe.flipH ? -1 : 1}, ${recipe.flipV ? -1 : 1}) rotate(${recipe.turns * 90}deg)`,
439
+ imageWidth: odd ? percent((size.width / shown.width) * 100) : "100%",
440
+ maxOutputWidth: String(oriented.width),
441
+ maxWidth: `${shown.width}px`,
442
+ message: session.message,
443
+ // A crop of the whole image has nowhere to go, so the area does not offer to move.
444
+ moveCursor: recipe.crop === null ? "default" : "move",
445
+ name,
446
+ quality: takesQuality(mime) ? "shown" : "hidden",
447
+ qualityValue: String(recipe.quality),
448
+ ratio: String(shown.width / shown.height),
449
+ region: paneRegion(pane.paneId, "image"),
450
+ saturation: String(recipe.saturation),
451
+ saving: session.saving,
452
+ src: session.source?.url ?? "",
453
+ status: session.status,
454
+ warning: `Save overwrites ${path} and keeps no backup. Source control is how to get the original back.`,
455
+ width: String(out.width),
456
+ };
457
+ }
458
+
459
+ // ─── Gestures ────────────────────────────────────────────────────────────────
460
+
461
+ /** Say where the crop is now, for a reader who cannot see the frame move. */
462
+ function announceCrop(handle: CropStop, recipe: ImageRecipe, size: PixelSize): void {
463
+ const crop = sourceToDisplay(cropOf(recipe, size), recipe, size);
464
+ announce(`${HANDLE_LABELS[handle]}: crop ${crop.width} × ${crop.height} at ${crop.x}, ${crop.y}`);
465
+ }
466
+
467
+ function isHandle(value: string): value is CropStop {
468
+ return CROP_STOPS.some((stop) => stop === value);
469
+ }
470
+
471
+ /** The actions one pane's document calls. Each reads the session the moment it runs. */
472
+ function actionsFor(pane: ImagePane, session: ImageSession): ImageEditorActions {
473
+ const sized = (): PixelSize | null =>
474
+ session.source === null || !session.decoded ? null : session.size;
475
+ /* A value the recipe refused or clamped may leave the recipe as it was, and then nothing would
476
+ correct the field, which must show what a save would use rather than what was typed. */
477
+ const syncFields = () => pane.surface?.syncFields(viewOf(pane, session));
478
+ // The toolbar's Undo and Redo are the same two functions ⌘Z reaches.
479
+ const history = historyDelegateFor(session);
480
+ const step = (next: (recipe: ImageRecipe, size: PixelSize) => ImageRecipe) => {
481
+ const size = sized();
482
+ if (size !== null && !session.saving) {
483
+ commit(session, next(currentRecipe(session), size));
484
+ }
485
+ };
486
+ return {
487
+ cycleHandle: (handle, direction) => {
488
+ if (!isHandle(handle)) {
489
+ return;
490
+ }
491
+ const at = CROP_STOPS.indexOf(handle);
492
+ const by = direction < 0 ? -1 : 1;
493
+ const next = CROP_STOPS[(at + by + CROP_STOPS.length) % CROP_STOPS.length]!;
494
+ pane.active = next;
495
+ pane.surface?.update(viewOf(pane, session));
496
+ pane.surface?.focusHandle(next);
497
+ announce(HANDLE_LABELS[next]);
498
+ },
499
+ fieldEdited: () => {
500
+ session.typed = true;
501
+ },
502
+ fitForWeb: () => {
503
+ step((recipe, size) => {
504
+ const width = fitForWebWidth(recipe, size, projectWidths());
505
+ return width === null ? recipe : withWidth(recipe, size, width);
506
+ });
507
+ },
508
+ flip: (axis) => {
509
+ step((recipe) => flip(recipe, axis === "v" ? "v" : "h"));
510
+ },
511
+ handleDown: (handle, pointerId, clientX, clientY, target) => {
512
+ const size = sized();
513
+ const rect = pane.surface?.frameRect();
514
+ if (size === null || !isHandle(handle) || !rect || rect.width <= 0 || session.saving) {
515
+ return;
516
+ }
517
+ target.setPointerCapture(pointerId);
518
+ pane.active = handle;
519
+ pane.drag = {
520
+ handle,
521
+ pointerId,
522
+ scale: displaySize(currentRecipe(session).turns, size).width / rect.width,
523
+ start: currentRecipe(session),
524
+ startX: clientX,
525
+ startY: clientY,
526
+ };
527
+ },
528
+ handleFocused: (handle) => {
529
+ if (isHandle(handle) && pane.active !== handle) {
530
+ pane.active = handle;
531
+ pane.surface?.update(viewOf(pane, session));
532
+ }
533
+ },
534
+ handleMove: (pointerId, clientX, clientY) => {
535
+ const { drag } = pane;
536
+ const size = sized();
537
+ if (drag === null || drag.pointerId !== pointerId || size === null || session.saving) {
538
+ return;
539
+ }
540
+ session.live = moveStop(
541
+ drag.start,
542
+ size,
543
+ drag.handle,
544
+ (clientX - drag.startX) * drag.scale,
545
+ (clientY - drag.startY) * drag.scale,
546
+ );
547
+ },
548
+ handleUp: (pointerId) => {
549
+ const { drag } = pane;
550
+ if (drag === null || drag.pointerId !== pointerId) {
551
+ return;
552
+ }
553
+ pane.drag = null;
554
+ // One drag is one step, pushed as the gesture ends rather than while it moves.
555
+ if (session.live !== null) {
556
+ commit(session, session.live);
557
+ }
558
+ },
559
+ imageFailed: (src) => {
560
+ /* A header can parse over a body that does not decode, and the controls must not stand live
561
+ over a broken picture. The src is compared because a swap at save fires for the old URL. */
562
+ if (session.source?.url !== src) {
563
+ return;
564
+ }
565
+ releaseSource(session);
566
+ session.status = "refused";
567
+ session.message = `${pane.tab.documentPath ?? "This file"} could not be decoded, so it cannot be edited.`;
568
+ },
569
+ imageLoaded: (width, height) => {
570
+ if (session.source === null || !(width > 0 && height > 0)) {
571
+ return;
572
+ }
573
+ session.decoded = true;
574
+ if (session.size?.width !== width || session.size.height !== height) {
575
+ session.size = { height, width };
576
+ }
577
+ recordImageSize(pane.tab.documentPath ?? "", width, height);
578
+ },
579
+ nudge: (handle, dx, dy, large) => {
580
+ const size = sized();
581
+ if (size === null || !isHandle(handle) || session.saving) {
582
+ return;
583
+ }
584
+ const by = large ? 10 : 1;
585
+ const next = moveStop(currentRecipe(session), size, handle, dx * by, dy * by);
586
+ commit(session, next);
587
+ announceCrop(handle, next, size);
588
+ },
589
+ redo: () => {
590
+ history.redo(pane.tab);
591
+ },
592
+ reset: () => {
593
+ step(() => historyOf().present);
594
+ },
595
+ rotate: (direction) => {
596
+ step((recipe) => turn(recipe, direction < 0 ? -1 : 1));
597
+ },
598
+ save: () => {
599
+ void saveImage(pane.tab);
600
+ },
601
+ setAspect: (value) => {
602
+ const choice = ASPECTS.find((aspect) => aspect.id === value);
603
+ if (choice) {
604
+ step((recipe, size) => withAspect(recipe, size, choice.id));
605
+ }
606
+ },
607
+ setValue: (key, value) => {
608
+ /* An empty field reads as nothing rather than as zero: a cleared Quality would otherwise be a
609
+ 1% JPEG written over the master, with nothing on screen to say so. */
610
+ if (
611
+ value.trim() !== "" &&
612
+ (key === "brightness" || key === "contrast" || key === "saturation" || key === "quality")
613
+ ) {
614
+ step((recipe) => withValue(recipe, key, Number(value)));
615
+ }
616
+ session.typed = false;
617
+ syncFields();
618
+ },
619
+ setWidth: (value) => {
620
+ step((recipe, size) => withWidth(recipe, size, value.trim() === "" ? null : Number(value)));
621
+ session.typed = false;
622
+ syncFields();
623
+ },
624
+ undo: () => {
625
+ history.undo(pane.tab);
626
+ },
627
+ };
628
+ }
629
+
630
+ // ─── Render ──────────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Mount the Image editor into the pane. The same non-iframe-editor pattern as the media viewer: it
634
+ * owns its own effect scope from here, so a gesture repaints this pane (and any other pane showing
635
+ * the tab) and nothing reaches the canvas pipeline.
636
+ */
637
+ export function renderImageMode(surface: CanvasSurface, tab: Tab): void {
638
+ const { paneId, wrap } = surface;
639
+ if (imageEditorMounted(paneId, tab)) {
640
+ return;
641
+ }
642
+ detachImageEditor(paneId);
643
+ const session = sessionFor(tab);
644
+ const pane: ImagePane = {
645
+ active: "nw",
646
+ disposed: false,
647
+ drag: null,
648
+ paneId,
649
+ rows: [],
650
+ scope: effectScope(),
651
+ surface: null,
652
+ tab,
653
+ wrap,
654
+ };
655
+ _panes.set(paneId, pane);
656
+ void ensureSource(session, tab.documentPath ?? "");
657
+ void surfaceModule().then(({ mountImageEditorSurface }) => {
658
+ if (pane.disposed) {
659
+ return;
660
+ }
661
+ pane.surface = mountImageEditorSurface(wrap, viewOf(pane, session), actionsFor(pane, session));
662
+ pane.scope.run(() => {
663
+ effect(() => {
664
+ if (!pane.disposed) {
665
+ pane.surface?.update(viewOf(pane, session));
666
+ }
667
+ });
668
+ });
669
+ });
670
+ }
671
+
672
+ // ─── The one write door ──────────────────────────────────────────────────────
673
+
674
+ /**
675
+ * The session's bytes, read again when no pane is holding them or an earlier read failed.
676
+ *
677
+ * Read twice at most: a pane mounting during the first read starts a newer one, which drops this
678
+ * one, and the second read's answer stands either way. Null means the file could not be read, and
679
+ * the session's message says why.
680
+ */
681
+ async function settledSource(session: ImageSession, path: string): Promise<ImageSource | null> {
682
+ if (session.source === null) {
683
+ await ensureSource(session, path);
684
+ }
685
+ if (session.source === null && session.status === "loading") {
686
+ await ensureSource(session, path);
687
+ }
688
+ return session.source;
689
+ }
690
+
691
+ /** The panes showing this tab right now. */
692
+ function panesShowing(tab: Tab): ImagePane[] {
693
+ const raw = toRaw(tab);
694
+ return [..._panes.values()].filter((pane) => toRaw(pane.tab) === raw);
695
+ }
696
+
697
+ /**
698
+ * Write the tab's edit over its file. The one door every save of an image passes through (§9.5).
699
+ *
700
+ * In order: a read-only collaborator is refused, as every other save is (§14.7); a number still
701
+ * being typed and a drag still moving become part of the edit, so what is written is what is on
702
+ * screen; an edit that changes nothing writes nothing; the bake verifies its own output; a tab
703
+ * closed or renamed while that ran writes nothing, because the answer that closed it or the name it
704
+ * now has outranks the save that was pending; the write goes through §9.3's Replace path, so an
705
+ * edit has the same story about caches, usages and a backend that stored the file elsewhere as the
706
+ * gesture beside it; and only a write that landed at the path resets the recipe — after the source
707
+ * has been replaced by the bytes just written, so an empty recipe always means "what is on disk"
708
+ * and the next save cannot write the original back.
709
+ *
710
+ * @returns Whether the file now holds the edit (true, too, when no edit was pending). False is what
711
+ * stops the close prompt's Save from closing the tab on top of a write that did not happen.
712
+ */
713
+ export async function saveImage(tab: Tab): Promise<boolean> {
714
+ const path = tab.documentPath ?? "";
715
+ const name = path.split("/").pop() ?? path;
716
+ if (collabReadOnly(tab)) {
717
+ notify.warn(
718
+ `You have read access to this session — the edit to ${name} stays in your browser and cannot be saved.`,
719
+ { key: `image.readOnly:${path}`, path, source: "Collaboration" },
720
+ );
721
+ return false;
722
+ }
723
+ const session = sessionOf(tab);
724
+ if (session === undefined || session.saving) {
725
+ return session === undefined;
726
+ }
727
+ for (const pane of panesShowing(tab)) {
728
+ pane.surface?.flushFields();
729
+ pane.drag = null;
730
+ }
731
+ // Text the blur did not commit was typed back to what it already said.
732
+ session.typed = false;
733
+ if (session.live !== null) {
734
+ commit(session, session.live);
735
+ }
736
+ // Claimed before the first await, so a second Save pressed while this one reads is refused.
737
+ session.saving = true;
738
+ try {
739
+ const { size } = session;
740
+ const recipe = currentRecipe(session);
741
+ if (size === null || isIdentity(recipe, size)) {
742
+ notify.info(`${name} has no changes, so nothing was written.`, {
743
+ key: `image.identity:${path}`,
744
+ });
745
+ return true;
746
+ }
747
+ const failed = (detail: string): false => {
748
+ notify.error(`Could not save ${name}.`, { detail, path, source: "Image" });
749
+ return false;
750
+ };
751
+ const source = await settledSource(session, path);
752
+ if (source === null) {
753
+ return failed(session.message);
754
+ }
755
+ const mime = mimeForPath(path) ?? "";
756
+ const { bakeImage } = await bakeModule();
757
+ let result: BakeResult;
758
+ try {
759
+ result = await bakeImage(recipe, source.bytes, mime);
760
+ } catch (error) {
761
+ return failed(`The browser could not process this image: ${errorMessage(error)}`);
762
+ }
763
+ if (!result.ok) {
764
+ return failed(result.reason);
765
+ }
766
+ /* Checked in the same synchronous stretch as the upload call below, so neither the tab nor the
767
+ project root can move between the two. After a project switch a project-relative path names
768
+ a file in the OTHER project. */
769
+ if (!tabIsLive(tab)) {
770
+ return false;
771
+ }
772
+ if (tab.documentPath !== path) {
773
+ return failed(`It was renamed while saving, so the edit was not written to its old name.`);
774
+ }
775
+ const { blob } = result;
776
+ const was = formatBytes(source.bytes.byteLength);
777
+ const grew = blob.size > source.bytes.byteLength ? ", so the file grew" : "";
778
+ const replaced = await replaceAsset(path, new File([blob], name, { type: mime }), {
779
+ note: `${result.size.width} × ${result.size.height}, ${formatBytes(blob.size)} (was ${was}${grew})`,
780
+ });
781
+ if (!replaced) {
782
+ // Two files exist now, or none was written: the edit is still the author's to decide about.
783
+ return false;
784
+ }
785
+ /* Re-read from the bytes just written BEFORE the recipe resets: the source and the recipe are
786
+ always of one generation, so the next save starts from what is on disk. */
787
+ setSource(session, new Uint8Array(await blob.arrayBuffer()), mime);
788
+ session.size = result.size;
789
+ session.decoded = true;
790
+ recordImageSize(path, result.size.width, result.size.height);
791
+ session.live = null;
792
+ session.history = historyOf();
793
+ return true;
794
+ } finally {
795
+ session.saving = false;
796
+ // A save from the viewer, or from the close prompt, read bytes no pane is holding.
797
+ if (panesShowing(tab).length === 0) {
798
+ releaseSource(session);
799
+ session.generation += 1;
800
+ }
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Whether **Replace** may write over `path`. Not while its tab holds an edit: the recipe was made
806
+ * against the bytes Replace is about to overwrite, so the next save would apply an old crop to the
807
+ * new picture and write that over the file the author just chose. Refused with the way out.
808
+ */
809
+ export function imageEditorAllowsReplace(path: string): boolean {
810
+ const tab = workspace.tabs.get(path);
811
+ if (!tab || !imageEditorDirty(tab)) {
812
+ return true;
813
+ }
814
+ const name = path.split("/").pop() ?? path;
815
+ notify.warn(`${name} has an unsaved edit. Save or discard it before replacing the file.`, {
816
+ key: `image.replaceDirty:${path}`,
817
+ path,
818
+ source: "Image",
819
+ });
820
+ return false;
821
+ }
822
+
823
+ /**
824
+ * The file at `path` has new bytes that did not come from this editor: Replace, or a change the
825
+ * file-event stream reported. An editor with no pending edit reads them, so an empty recipe still
826
+ * means what is on disk. One holding an edit is left alone (§9.5 says what that means).
827
+ */
828
+ export function imageFileChanged(path: string): void {
829
+ const tab = workspace.tabs.get(path);
830
+ const session = tab && sessionOf(tab);
831
+ if (!tab || session === undefined || session.saving || imageEditorDirty(tab)) {
832
+ return;
833
+ }
834
+ releaseSource(session);
835
+ session.generation += 1;
836
+ session.size = null;
837
+ session.decoded = false;
838
+ session.live = null;
839
+ // The steps behind the present were made against the old picture.
840
+ session.history = historyOf();
841
+ if (panesShowing(tab).length > 0) {
842
+ void ensureSource(session, path);
843
+ }
844
+ }
845
+
846
+ setEditBesideDocument(imageEditorDirty);