@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
@@ -14,7 +14,9 @@
14
14
  * {@link DEFAULT_MEDIA_DIR}, `public/media/`, which is served from the site root as `/media/`.
15
15
  *
16
16
  * {@link replaceAsset} is the one door that writes over an existing file, and it is asked for by
17
- * name: the Media workspace's **Replace** (studio.md §9.3).
17
+ * name: the Media workspace's **Replace** (studio.md §9.3). {@link duplicateAsset} is the upload
18
+ * core fed from the project rather than the desktop, and {@link mediaRenameRefusal} is what a new
19
+ * name for a media file may not be.
18
20
  *
19
21
  * @docs studio/projects/media
20
22
  */
@@ -24,7 +26,9 @@ import { getPlatform, hasPlatform } from "../platform";
24
26
  import { notify } from "../services/notify";
25
27
  import { invalidateUsages } from "../services/references";
26
28
  import { activeTab } from "../workspace/workspace";
27
- import { forgetVersions, notedListing } from "./asset-versions";
29
+ import { forgetVersions, noteLocalWrite, notedListing } from "./asset-versions";
30
+ import { markLocalMutation } from "./fs-events";
31
+ import { renameRefusal } from "./new-file-name";
28
32
  import {
29
33
  AUDIO_EXTENSIONS,
30
34
  IMAGE_EXTENSIONS,
@@ -202,17 +206,20 @@ export function assetRef(destPath: string): string {
202
206
 
203
207
  /**
204
208
  * A filename that doesn't collide with `taken`, suffixing `-1`, `-2`, … before the extension. Never
205
- * overwrites existing media (the previous behaviour clobbered silently).
209
+ * overwrites existing media (the previous behaviour clobbered silently). Compared without regard to
210
+ * case: APFS and NTFS take `Hero.jpg` for `hero.jpg`, so a name that differs only in case is a name
211
+ * that overwrites there.
206
212
  */
207
213
  export function uniqueName(name: string, taken: ReadonlySet<string>): string {
208
- if (!taken.has(name)) {
214
+ const lower = new Set([...taken].map((entry) => entry.toLowerCase()));
215
+ if (!lower.has(name.toLowerCase())) {
209
216
  return name;
210
217
  }
211
218
  const ext = extensionOf(name);
212
219
  const base = ext ? name.slice(0, -ext.length) : name;
213
220
  for (let i = 1; ; i++) {
214
221
  const candidate = `${base}-${i}${ext}`;
215
- if (!taken.has(candidate)) {
222
+ if (!lower.has(candidate.toLowerCase())) {
216
223
  return candidate;
217
224
  }
218
225
  }
@@ -231,10 +238,17 @@ export function joinDir(dir: string, name: string): string {
231
238
  * this module doesn't import the media-picker / Manage / file-tree caches it needs to invalidate
232
239
  * (they all import from here) — the same injection pattern as the canvas host's drop handlers.
233
240
  */
234
- let mediaChangedHandler: ((dir: string) => void | Promise<void>) | null = null;
241
+ let mediaChangedHandler: MediaChangedHandler | null = null;
242
+
243
+ /**
244
+ * The post-upload cache invalidator. `overwritten` names a path whose bytes were replaced IN PLACE,
245
+ * which is the one change a re-list cannot show: the listing is unchanged, so anything already
246
+ * drawn from that path has to be drawn again (studio.md §3.4).
247
+ */
248
+ export type MediaChangedHandler = (dir: string, overwritten?: string) => void | Promise<void>;
235
249
 
236
250
  /** Register the post-upload cache invalidator (see {@link mediaChangedHandler}). */
237
- export function setMediaChangedHandler(fn: ((dir: string) => void | Promise<void>) | null): void {
251
+ export function setMediaChangedHandler(fn: MediaChangedHandler | null): void {
238
252
  mediaChangedHandler = fn;
239
253
  }
240
254
 
@@ -242,8 +256,10 @@ export function setMediaChangedHandler(fn: ((dir: string) => void | Promise<void
242
256
  * Say that the media in `dir` changed by some door other than {@link uploadAssets} — a replace, or
243
257
  * the Media workspace's delete — so every cache keyed on the listing is dropped the same way.
244
258
  */
245
- export async function announceMediaChanged(dir: string): Promise<void> {
246
- await mediaChangedHandler?.(dir);
259
+ export async function announceMediaChanged(dir: string, overwritten?: string): Promise<void> {
260
+ await (overwritten === undefined
261
+ ? mediaChangedHandler?.(dir)
262
+ : mediaChangedHandler?.(dir, overwritten));
247
263
  }
248
264
 
249
265
  /** The directory part of a project path, `.` for a file at the root. */
@@ -303,7 +319,16 @@ function keepListable(files: File[], only: UploadOnly): File[] {
303
319
  */
304
320
  export async function uploadAssets(
305
321
  files: Iterable<File>,
306
- opts: { dir?: string; only?: UploadOnly } = {},
322
+ opts: {
323
+ dir?: string;
324
+ /** The destination holds a file already, so a listing that fails says nothing about it. */
325
+ dirExists?: boolean;
326
+ only?: UploadOnly;
327
+ quiet?: boolean;
328
+ source?: string;
329
+ /** Writes each file instead of `uploadFile`, for a caller with a better door than an upload. */
330
+ write?: (path: string) => Promise<{ path: string }>;
331
+ } = {},
307
332
  ): Promise<UploadedAsset[]> {
308
333
  const list = opts.only === undefined ? [...files] : keepListable([...files], opts.only);
309
334
  if (list.length === 0) {
@@ -311,13 +336,22 @@ export async function uploadAssets(
311
336
  }
312
337
  const platform = getPlatform();
313
338
  const dir = opts.dir ?? uploadDirFor(activeTab.value?.documentPath ?? null);
339
+ const source = opts.source ?? "Media";
314
340
 
315
341
  let taken: Set<string>;
316
342
  try {
317
343
  // The upload directory is a media directory, so its listing vouches for versions as well.
318
344
  const existing = await notedListing(() => platform.listDirectory(dir));
319
345
  taken = new Set(existing.map((entry) => entry.name));
320
- } catch {
346
+ } catch (error) {
347
+ if (opts.dirExists === true) {
348
+ notify.error(`Could not write to ${dir}/.`, {
349
+ detail: `The folder could not be read to choose a name that overwrites nothing: ${errorMessage(error)}`,
350
+ path: dir,
351
+ source,
352
+ });
353
+ return [];
354
+ }
321
355
  taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
322
356
  }
323
357
 
@@ -342,7 +376,9 @@ export async function uploadAssets(
342
376
  /* The path the backend REPORTS, not the one asked for. A store that de-duplicates by content
343
377
  hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
344
378
  reference that goes into the document has to name what is really there. */
345
- const { path } = await platform.uploadFile(requested, file);
379
+ const { path } = await (opts.write
380
+ ? opts.write(requested)
381
+ : platform.uploadFile(requested, file));
346
382
  // New bytes at `path`: whatever version a listing held for it no longer names them.
347
383
  forgetVersions([path]);
348
384
  uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
@@ -350,22 +386,119 @@ export async function uploadAssets(
350
386
  notify.error(`Could not upload ${file.name}.`, {
351
387
  detail: errorMessage(error),
352
388
  path: requested,
353
- source: "Media",
389
+ source,
354
390
  });
355
391
  }
356
392
  }
357
393
 
358
394
  if (uploaded.length > 0) {
359
- notify.success(
360
- uploaded.length === 1
361
- ? `Uploaded ${uploaded[0]!.name} to ${dir}/`
362
- : `Uploaded ${uploaded.length} files to ${dir}/`,
363
- );
395
+ // `quiet` is a caller with its own sentence: one toast per write, and "uploaded" is not always it.
396
+ if (opts.quiet !== true) {
397
+ notify.success(
398
+ uploaded.length === 1
399
+ ? `Uploaded ${uploaded[0]!.name} to ${dir}/`
400
+ : `Uploaded ${uploaded.length} files to ${dir}/`,
401
+ );
402
+ }
364
403
  await mediaChangedHandler?.(dir);
365
404
  }
366
405
  return uploaded;
367
406
  }
368
407
 
408
+ /** `bytes` as text when `writeFile` can put the same bytes back: valid UTF-8, with no BOM to lose. */
409
+ function strictUtf8(bytes: ArrayBuffer): string | null {
410
+ try {
411
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
412
+ return text.startsWith("\uFEFF") ? null : text;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * What a copy of `name` is called before any collision: `hero.jpg` is `hero-copy.jpg`.
420
+ *
421
+ * A copy of a copy keeps counting ({@link uniqueName} adds the number) rather than growing a second
422
+ * `-copy`, so duplicating `hero-copy.jpg` or `hero-copy-1.jpg` asks for `hero-copy.jpg` again.
423
+ */
424
+ export function duplicateName(name: string): string {
425
+ const stem = name.slice(0, name.length - extensionOf(name).length);
426
+ return `${stem.replace(/-copy(?:-\d+)?$/, "")}-copy${name.slice(stem.length)}`;
427
+ }
428
+
429
+ /**
430
+ * Copy the file at `path` to a new name beside it — **Duplicate**, in the Media workspace and the
431
+ * Content home alike.
432
+ *
433
+ * Through `readFileBytes`, because `readFile` answers UTF-8: a picture read that way comes back as
434
+ * replacement characters, and so does every non-UTF-8 byte of a text file, such as a Latin-1
435
+ * Markdown entry from an imported site. And through {@link uploadAssets}, so a copy is named,
436
+ * limited and announced the way any new file is and can never overwrite anything. What it copies is
437
+ * the file on disk: a pending image edit or an unsaved change belongs to its tab and stays there.
438
+ *
439
+ * A backend that reports the original's own path (a store that keeps identical bytes once) made no
440
+ * second file, which is said rather than called success.
441
+ *
442
+ * `document` is for a file that is usually a document, and it decides the write rather than the
443
+ * read. Studio Cloud files every upload as a binary and answers a text read of one with a 415, so
444
+ * an uploaded copy of a Markdown entry could never be opened. Bytes that are valid UTF-8 go through
445
+ * `writeFile` instead, which loses nothing, since they encode back the same. Bytes that are not
446
+ * stay an upload, and so does a file with a BOM: the dev server decodes a written body as a Fetch
447
+ * `text()` does, which drops one.
448
+ *
449
+ * @param path The file to copy.
450
+ * @param opts `source` names the flow in a Problem; `document` is described above.
451
+ * @returns The copy, or `null` when none was made; the reason has been reported.
452
+ */
453
+ export async function duplicateAsset(
454
+ path: string,
455
+ opts: { document?: boolean; source?: string } = {},
456
+ ): Promise<UploadedAsset | null> {
457
+ const name = path.split("/").pop() ?? path;
458
+ const source = opts.source ?? "Media";
459
+ let bytes: ArrayBuffer;
460
+ try {
461
+ const platform = getPlatform();
462
+ if (platform.readFileBytes === undefined) {
463
+ throw new Error("This backend cannot read a file's raw bytes.");
464
+ }
465
+ bytes = await platform.readFileBytes(path);
466
+ } catch (error) {
467
+ notify.error(`Could not duplicate ${name}.`, {
468
+ detail: errorMessage(error),
469
+ path,
470
+ source,
471
+ });
472
+ return null;
473
+ }
474
+ const text = opts.document === true ? strictUtf8(bytes) : null;
475
+ const [copy] = await uploadAssets([new File([bytes], duplicateName(name))], {
476
+ dir: parentDir(path),
477
+ dirExists: true,
478
+ quiet: true,
479
+ source,
480
+ ...(text !== null && {
481
+ write: async (target: string) => {
482
+ await getPlatform().writeFile(target, text);
483
+ return { path: target };
484
+ },
485
+ }),
486
+ });
487
+ if (!copy) {
488
+ return null;
489
+ }
490
+ if (copy.path === path) {
491
+ notify.warn(`${name} was not duplicated.`, {
492
+ detail: "The backend keeps identical files once, so it made no second file.",
493
+ path,
494
+ source,
495
+ });
496
+ return null;
497
+ }
498
+ notify.success(`Duplicated ${name} as ${copy.name}`);
499
+ return copy;
500
+ }
501
+
369
502
  /** Extensions that name one format, so either may stand in for the other. */
370
503
  const SAME_FORMAT: readonly ReadonlySet<string>[] = [
371
504
  new Set([".jpg", ".jpeg"]),
@@ -392,6 +525,29 @@ export function canReplaceWith(path: string, file: { name: string }): boolean {
392
525
  return formatSiblings(extensionOf(path)).has(extensionOf(file.name));
393
526
  }
394
527
 
528
+ /**
529
+ * The refusal a new name for the media file at `path` earns, or `""` when it is usable (studio.md
530
+ * §9.3): every entry's rename rules, and then the one only a media file has.
531
+ *
532
+ * @param path The file being renamed.
533
+ * @param value What the reader typed.
534
+ * @param taken The names in the file's folder.
535
+ */
536
+ export function mediaRenameRefusal(
537
+ path: string,
538
+ value: string,
539
+ taken: ReadonlySet<string>,
540
+ ): string {
541
+ const general = renameRefusal(path, value, taken);
542
+ if (general !== "") {
543
+ return general;
544
+ }
545
+ if (!canReplaceWith(path, { name: value.trim() })) {
546
+ return `Keep the ${extensionOf(path)} extension. A rename does not convert the file, so another extension would break it on every page.`;
547
+ }
548
+ return "";
549
+ }
550
+
395
551
  /**
396
552
  * Write `file`'s bytes over the existing media file at `path` — the Media workspace's **Replace**.
397
553
  *
@@ -405,7 +561,11 @@ export function canReplaceWith(path: string, file: { name: string }): boolean {
405
561
  *
406
562
  * @returns Whether the bytes now at `path` are the new ones.
407
563
  */
408
- export async function replaceAsset(path: string, file: File): Promise<boolean> {
564
+ export async function replaceAsset(
565
+ path: string,
566
+ file: File,
567
+ opts: { note?: string } = {},
568
+ ): Promise<boolean> {
409
569
  const name = path.split("/").pop() ?? path;
410
570
  /* An `accept` narrows only the chooser (and a backend's own picker may ignore it), so the format is
411
571
  checked here too: same path, other format, is every reference broken at once. */
@@ -425,6 +585,10 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
425
585
  });
426
586
  return false;
427
587
  }
588
+ /* Studio's own write, marked BEFORE it happens (§3.4): the watcher's echo of it is absorbed rather
589
+ than reported as somebody else's change, and the path's content version is forgotten while the
590
+ bytes are still the old ones, so no listing in flight can vouch the old version afterwards. */
591
+ markLocalMutation(path);
428
592
  let written: string;
429
593
  try {
430
594
  ({ path: written } = await getPlatform().uploadFile(path, file));
@@ -437,10 +601,18 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
437
601
  return false;
438
602
  }
439
603
  forgetVersions([path, written]);
604
+ /* The repaint key: on a host that vouches no version (`jx dev`, desktop) the URL of `path` is
605
+ otherwise byte-identical before and after, and every image already decoded keeps the old
606
+ pixels. Only on the path that was actually written; a second file beside it is new. */
607
+ if (written === path) {
608
+ noteLocalWrite(path);
609
+ }
440
610
  // The answers were about the old bytes' place in the project; the place is the same, but a stale
441
611
  // "Used on" is a list the reader acts on, so it is asked again rather than trusted.
442
612
  invalidateUsages();
443
- await announceMediaChanged(parentDir(path));
613
+ await (written === path
614
+ ? announceMediaChanged(parentDir(path), path)
615
+ : announceMediaChanged(parentDir(path)));
444
616
  if (written !== path) {
445
617
  notify.warn(`${name} was not replaced.`, {
446
618
  detail: `The backend stored the new file as ${written}, so every reference still shows the old one.`,
@@ -451,6 +623,8 @@ export async function replaceAsset(path: string, file: File): Promise<boolean> {
451
623
  });
452
624
  return false;
453
625
  }
454
- notify.success(`Replaced ${name}`);
626
+ /* One toast per write. A caller with something to add (the Image editor's written size) adds it
627
+ to the message, because a toast draws its message alone: `detail` is for Problems. */
628
+ notify.success(opts.note === undefined ? `Replaced ${name}` : `Replaced ${name}: ${opts.note}`);
455
629
  return true;
456
630
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The rules a new file's name is held to, with no dialog and no write attached.
2
+ * The rules a new file's name is held to, with no dialog and no write attached, and the rules a
3
+ * rename's new name is held to ({@link renameRefusal}).
3
4
  *
4
5
  * `createFileIn` (`files/files.ts`) used to keep these inside one closure over its prompt, which
5
6
  * was right while the prompt was the only creation flow there was. Add new (`create/add-new.ts`,
@@ -154,3 +155,40 @@ export function validateNewFileName(
154
155
  const candidate = composeNewFileName(value, picked, choice);
155
156
  return taken.has(candidate.toLowerCase()) ? `${candidate} already exists in ${dir}/.` : "";
156
157
  }
158
+
159
+ /**
160
+ * The refusal a new name for the file or folder at `path` earns, or `""` when it is usable
161
+ * (studio.md §9.1.1). The first that applies is the sentence.
162
+ *
163
+ * `taken` is every name already in the entry's folder, and it is the check that matters most: a
164
+ * backend's `renameFile` is a filesystem rename, which replaces its destination in silence.
165
+ * Compared without regard to case, since a case-insensitive disk would take `Hero.jpg` for
166
+ * `hero.jpg`, and the entry's own name is never in the way, so its case can change.
167
+ *
168
+ * The other two refusals are what keep that check honest. A listing answers for one folder, and a
169
+ * path would land in another. A dev server or desktop listing leaves out every dotfile, so `.env`
170
+ * looks free whether or not there is one. An entry that is itself a dotfile is exempt: a host that
171
+ * listed it is a host whose listing can answer.
172
+ *
173
+ * @param path The entry being renamed.
174
+ * @param value What the reader typed.
175
+ * @param taken The names in the entry's folder, in any case.
176
+ */
177
+ export function renameRefusal(path: string, value: string, taken: ReadonlySet<string>): string {
178
+ const name = value.trim();
179
+ if (name === "") {
180
+ return "Enter a file name.";
181
+ }
182
+ if (/[/\\]/.test(name)) {
183
+ return "Enter a name, not a path. A rename keeps the file in its folder.";
184
+ }
185
+ const current = (path.split("/").pop() ?? path).toLowerCase();
186
+ if (name.startsWith(".") && !current.startsWith(".")) {
187
+ return "A name cannot start with a dot: hidden files are not listed, so the file could disappear.";
188
+ }
189
+ const lower = name.toLowerCase();
190
+ if (lower !== current && [...taken].some((entry) => entry.toLowerCase() === lower)) {
191
+ return `${name} already exists in this folder.`;
192
+ }
193
+ return "";
194
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The bake: a recipe applied to an image's bytes, once, at save (studio.md §9.5).
3
+ *
4
+ * The only module in the Image editor that touches pixels. It decodes the source with the
5
+ * orientation tag applied (stated explicitly, so the decode and the `<img>` the author edited over
6
+ * cannot disagree), sizes an `OffscreenCanvas` to the output, turns and flips the context, asks for
7
+ * the host's best resampling rather than its default (which is its fastest, and which visibly
8
+ * aliases a reduction of more than about a factor of two), sets the same filter string the preview
9
+ * showed, draws the stored SOURCE rectangle directly, and encodes.
10
+ *
11
+ * **It verifies before anything is written, because every relevant host failure is silent.** Past
12
+ * the maximum drawing surface a context draws and reads back zeros; `convertToBlob` asked for a
13
+ * type it cannot encode returns a PNG rather than rejecting; and a surface that produced nothing
14
+ * still produces a blob. So the output must be within the surface, the encoded type must be the one
15
+ * the path promises, and the blob must be large enough to be an image. A failed check returns a
16
+ * reason and the caller writes nothing.
17
+ *
18
+ * The two host primitives are parameters, defaulting to the globals, so the module is exercised
19
+ * against fakes under happy-dom (which has neither) with no `mock.module()` of a first-party file.
20
+ * `OffscreenCanvas` is not a DOM node, so nothing here constructs UI.
21
+ *
22
+ * @docs studio/projects/media
23
+ */
24
+
25
+ import { MAX_SURFACE_AREA, MAX_SURFACE_SIDE, takesQuality } from "./image-format";
26
+ import { cropOf, filterString, outputSize, swapsAxes } from "./image-recipe";
27
+ import type { ImageRecipe, PixelSize } from "./image-recipe";
28
+
29
+ /** The part of a 2D context a bake uses — all of it, so a fake can be honest about its shape. */
30
+ export type BakeContext = Pick<
31
+ OffscreenCanvasRenderingContext2D,
32
+ | "drawImage"
33
+ | "filter"
34
+ | "imageSmoothingEnabled"
35
+ | "imageSmoothingQuality"
36
+ | "rotate"
37
+ | "scale"
38
+ | "translate"
39
+ >;
40
+
41
+ /** The part of an `OffscreenCanvas` a bake uses. */
42
+ export interface BakeSurface {
43
+ getContext: (contextId: "2d") => BakeContext | null;
44
+ convertToBlob: (options?: ImageEncodeOptions) => Promise<Blob>;
45
+ }
46
+
47
+ /** The two host primitives a bake needs, replaceable for a test. */
48
+ export interface BakePorts {
49
+ createBitmap?: (image: Blob, options: ImageBitmapOptions) => Promise<ImageBitmap>;
50
+ Offscreen?: new (width: number, height: number) => BakeSurface;
51
+ }
52
+
53
+ /** A bake that produced bytes fit to write, or the reason it did not. */
54
+ export type BakeResult = { ok: true; blob: Blob; size: PixelSize } | { ok: false; reason: string };
55
+
56
+ /**
57
+ * The smallest encoded output accepted as an image. The smallest valid PNG a canvas writes is about
58
+ * seventy bytes and a JPEG well over a hundred, so anything under this is a surface that produced
59
+ * nothing, and it must not be written over a file that was something.
60
+ */
61
+ export const MIN_ENCODED_BYTES = 64;
62
+
63
+ /** Why `size` cannot be drawn on this host, or null when it can. */
64
+ export function surfaceRefusal(size: PixelSize): string | null {
65
+ if (
66
+ size.width > MAX_SURFACE_SIDE ||
67
+ size.height > MAX_SURFACE_SIDE ||
68
+ size.width * size.height > MAX_SURFACE_AREA
69
+ ) {
70
+ return `The result would be ${size.width} × ${size.height}, past the ${MAX_SURFACE_SIDE} × ${MAX_SURFACE_SIDE} the browser can draw. Reduce the width or crop first.`;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Bake `recipe` over `bytes`, encoded as `mime`.
77
+ *
78
+ * @param recipe - The edit, with its crop in source coordinates
79
+ * @param bytes - The file's bytes, exactly as read
80
+ * @param mime - The type the path's extension promises, which the output must be
81
+ * @param ports - The host primitives; the globals when omitted
82
+ */
83
+ export async function bakeImage(
84
+ recipe: Readonly<ImageRecipe>,
85
+ bytes: Uint8Array<ArrayBuffer>,
86
+ mime: string,
87
+ {
88
+ createBitmap = (image, options) => globalThis.createImageBitmap(image, options),
89
+ Offscreen = globalThis.OffscreenCanvas,
90
+ }: BakePorts = {},
91
+ ): Promise<BakeResult> {
92
+ const bitmap = await createBitmap(new Blob([bytes], { type: mime }), {
93
+ imageOrientation: "from-image",
94
+ });
95
+ try {
96
+ const source = { height: bitmap.height, width: bitmap.width };
97
+ const out = outputSize(recipe, source);
98
+ const tooLarge = surfaceRefusal(out);
99
+ if (tooLarge !== null) {
100
+ return { ok: false, reason: tooLarge };
101
+ }
102
+ /* A crop outside the bitmap draws nothing and encodes a blank image that passes every check
103
+ below, so a crop made against bytes that have since changed stops here. */
104
+ const stored = cropOf(recipe, source);
105
+ if (
106
+ stored.x < 0 ||
107
+ stored.y < 0 ||
108
+ stored.x + stored.width > source.width ||
109
+ stored.y + stored.height > source.height
110
+ ) {
111
+ return {
112
+ ok: false,
113
+ reason: "The crop no longer fits this image, which changed after the edit began.",
114
+ };
115
+ }
116
+ const canvas = new Offscreen(out.width, out.height);
117
+ const ctx = canvas.getContext("2d");
118
+ if (ctx === null) {
119
+ return { ok: false, reason: "The browser would not give the editor a drawing surface." };
120
+ }
121
+ ctx.imageSmoothingEnabled = true;
122
+ ctx.imageSmoothingQuality = "high";
123
+ ctx.filter = filterString(recipe) || "none";
124
+ /* Centre, mirror, then turn — applied to points in the reverse order, so a point is turned
125
+ first and mirrored in the frame the author sees, which is what the recipe's flips mean. */
126
+ ctx.translate(out.width / 2, out.height / 2);
127
+ ctx.scale(recipe.flipH ? -1 : 1, recipe.flipV ? -1 : 1);
128
+ ctx.rotate((recipe.turns * Math.PI) / 2);
129
+ const crop = cropOf(recipe, source);
130
+ // In the pre-turn frame the destination is the output with its sides swapped back.
131
+ const width = swapsAxes(recipe.turns) ? out.height : out.width;
132
+ const height = swapsAxes(recipe.turns) ? out.width : out.height;
133
+ ctx.drawImage(
134
+ bitmap,
135
+ crop.x,
136
+ crop.y,
137
+ crop.width,
138
+ crop.height,
139
+ -width / 2,
140
+ -height / 2,
141
+ width,
142
+ height,
143
+ );
144
+ const blob = await canvas.convertToBlob(
145
+ takesQuality(mime) ? { quality: recipe.quality / 100, type: mime } : { type: mime },
146
+ );
147
+ if (blob.type !== mime) {
148
+ return {
149
+ ok: false,
150
+ reason: `The browser encoded ${blob.type || "an unknown type"} when asked for ${mime}, and those bytes would not be what the file's extension promises.`,
151
+ };
152
+ }
153
+ if (blob.size < MIN_ENCODED_BYTES) {
154
+ return {
155
+ ok: false,
156
+ reason: `The browser produced ${blob.size} bytes, which is not an image. Nothing was written.`,
157
+ };
158
+ }
159
+ return { blob, ok: true, size: out };
160
+ } finally {
161
+ // The one full-resolution allocation, released as soon as the draw no longer needs it.
162
+ bitmap.close();
163
+ }
164
+ }