@jxsuite/studio 10.2.0 → 10.4.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 (272) 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-v0pm96mr.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-g38w0d3f.js +4 -0
  8. package/dist/chunks/convert-to-repeater-r8sfxjjm.js +4 -0
  9. package/dist/chunks/editor-wvf5e6j9.js +4 -0
  10. package/dist/chunks/entry-commands-t13dyw3c.js +4 -0
  11. package/dist/chunks/extension-sections-vjzw46xb.js +4 -0
  12. package/dist/chunks/file-ops-1sghzgcr.js +4 -0
  13. package/dist/chunks/files-zrqtgt41.js +4 -0
  14. package/dist/chunks/format-host-9gy76erh.js +4 -0
  15. package/dist/chunks/fs-events-45rhg3me.js +4 -0
  16. package/dist/chunks/grid-open-t4fq6bbm.js +4 -0
  17. package/dist/chunks/{grid-view-tghcwrrt.js → grid-view-2f03wcgw.js} +16 -16
  18. package/dist/chunks/icons-gb967qc8.js +4 -0
  19. package/dist/chunks/iframe-host-9aq8ze3q.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-r4ax11e3.js +4 -0
  27. package/dist/chunks/layers-m49nmwvx.js +4 -0
  28. package/dist/chunks/layers-panel-zr92pwdp.js +4 -0
  29. package/dist/chunks/lit-html-k2mb9k0r.js +4 -0
  30. package/dist/chunks/media-library-j74rt0w4.js +4 -0
  31. package/dist/chunks/media-open-mypr0k6b.js +4 -0
  32. package/dist/chunks/media-picker-pemtbsjv.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-0hw9f835.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-vg1n880b.js +4 -0
  40. package/dist/chunks/publish-quick-4fwngwrw.js +4 -0
  41. package/dist/chunks/publish-workspace-jv4w2bx2.js +4 -0
  42. package/dist/chunks/quick-search-nh29rt8a.js +4 -0
  43. package/dist/chunks/references-vqyr5s9x.js +4 -0
  44. package/dist/chunks/right-panel-3ws9f2w0.js +4 -0
  45. package/dist/chunks/source-control-h3hcs5gy.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-9b7177jq.js → studio-0x6f3nk9.js} +4 -4
  50. package/dist/chunks/studio-1410q5dw.js +5 -0
  51. package/dist/chunks/studio-1ctnw3xv.js +5 -0
  52. package/dist/chunks/studio-1ks5d9y4.js +5 -0
  53. package/dist/chunks/{studio-awxwjawq.js → studio-1y9mab9m.js} +4 -4
  54. package/dist/chunks/studio-2e4hry72.js +6 -0
  55. package/dist/chunks/studio-2kpxm0eh.js +7 -0
  56. package/dist/chunks/studio-2pk7kc7z.js +5 -0
  57. package/dist/chunks/studio-2x972v58.js +5 -0
  58. package/dist/chunks/studio-2zz4qbv9.js +5 -0
  59. package/dist/chunks/{studio-bvs9kpst.js → studio-3na1r48j.js} +8 -8
  60. package/dist/chunks/studio-3pz4t5a2.js +5 -0
  61. package/dist/chunks/studio-3qr08sxq.js +4 -0
  62. package/dist/chunks/{studio-75kcdbsk.js → studio-45mj4jqe.js} +4 -4
  63. package/dist/chunks/studio-4bgvvzhv.js +5 -0
  64. package/dist/chunks/studio-4f7thq0p.js +5 -0
  65. package/dist/chunks/studio-4fnb5648.js +5 -0
  66. package/dist/chunks/studio-4zd6an2b.js +5 -0
  67. package/dist/chunks/{studio-9rbe2xpw.js → studio-5tet77de.js} +3 -3
  68. package/dist/chunks/studio-61rg2cz5.js +5 -0
  69. package/dist/chunks/{studio-ybnwymxw.js → studio-7rrdbvex.js} +4 -4
  70. package/dist/chunks/{studio-sxdb9h3q.js → studio-90zkgne5.js} +7 -7
  71. package/dist/chunks/studio-9c6cbq2c.js +4 -0
  72. package/dist/chunks/studio-9dq7rwc7.js +5 -0
  73. package/dist/chunks/studio-9gr7xs9r.js +6 -0
  74. package/dist/chunks/studio-a88ej93p.js +7 -0
  75. package/dist/chunks/{studio-er5f383a.js → studio-abh9kfdh.js} +4 -4
  76. package/dist/chunks/studio-acer0g8w.js +5 -0
  77. package/dist/chunks/{studio-qh8berdz.js → studio-amaz545p.js} +4 -4
  78. package/dist/chunks/studio-az3wvyh2.js +5 -0
  79. package/dist/chunks/studio-bg8pzprg.js +5 -0
  80. package/dist/chunks/{studio-399a25za.js → studio-bnc50f9h.js} +4 -4
  81. package/dist/chunks/studio-brh8d2pr.js +5 -0
  82. package/dist/chunks/{studio-646nn2ys.js → studio-c2fa783p.js} +5 -5
  83. package/dist/chunks/studio-cfnn70h1.js +5 -0
  84. package/dist/chunks/{studio-rzqaz21g.js → studio-cqxj4fcy.js} +4 -4
  85. package/dist/chunks/studio-cw11h078.js +5 -0
  86. package/dist/chunks/studio-daksw8kh.js +5 -0
  87. package/dist/chunks/studio-dn56z7sn.js +5 -0
  88. package/dist/chunks/studio-egnjpyde.js +9 -0
  89. package/dist/chunks/studio-ejrypvmq.js +5 -0
  90. package/dist/chunks/studio-eqcddvc2.js +928 -0
  91. package/dist/chunks/studio-evq47j2z.js +5 -0
  92. package/dist/chunks/{studio-r7pf0rkv.js → studio-frc16j8v.js} +7 -7
  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-gnazwtv3.js +5 -0
  96. package/dist/chunks/studio-hfqpfmpk.js +5 -0
  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-j5fcp6gj.js +5 -0
  100. package/dist/chunks/studio-jd1qb1st.js +7 -0
  101. package/dist/chunks/studio-jma2q8r9.js +5 -0
  102. package/dist/chunks/studio-k0xzrse2.js +64 -0
  103. package/dist/chunks/studio-k1xqrgm5.js +5 -0
  104. package/dist/chunks/studio-k2evngrj.js +7 -0
  105. package/dist/chunks/studio-k3we3kqb.js +5 -0
  106. package/dist/chunks/studio-knsjyx7n.js +5 -0
  107. package/dist/chunks/studio-mrjtnm62.js +5 -0
  108. package/dist/chunks/{studio-sevbc3xv.js → studio-n9qba0gx.js} +4 -4
  109. package/dist/chunks/{studio-ea74s8c1.js → studio-nvcn2ykc.js} +4 -4
  110. package/dist/chunks/studio-nwf0c32p.js +5 -0
  111. package/dist/chunks/studio-nzemfbkk.js +14 -0
  112. package/dist/chunks/{studio-43y04qck.js → studio-pj216fmn.js} +4 -4
  113. package/dist/chunks/{studio-4g8yb6jn.js → studio-pm17wdc4.js} +4 -4
  114. package/dist/chunks/studio-pwctzka3.js +16 -0
  115. package/dist/chunks/{studio-71m92v9s.js → studio-q80fg1d6.js} +7 -7
  116. package/dist/chunks/studio-q9hx2qp0.js +5 -0
  117. package/dist/chunks/studio-qrzmfaf6.js +5 -0
  118. package/dist/chunks/{studio-86r8hp7r.js → studio-r6mx7vm5.js} +5 -5
  119. package/dist/chunks/studio-rr00ex23.js +7 -0
  120. package/dist/chunks/{studio-4jhw38b2.js → studio-s24mhq0z.js} +4 -4
  121. package/dist/chunks/studio-sce30r7b.js +4 -0
  122. package/dist/chunks/studio-swedpd82.js +5 -0
  123. package/dist/chunks/studio-tmfcn347.js +6 -0
  124. package/dist/chunks/studio-tpxcryf2.js +5 -0
  125. package/dist/chunks/{studio-wc6za3zs.js → studio-v5wktj6f.js} +4 -4
  126. package/dist/chunks/studio-v7a7yb5w.js +9 -0
  127. package/dist/chunks/{studio-kg1abnz0.js → studio-vpevkfxw.js} +4 -4
  128. package/dist/chunks/{studio-881z1xrx.js → studio-wmjmsw0p.js} +4 -4
  129. package/dist/chunks/studio-wqfaqw4m.js +5 -0
  130. package/dist/chunks/{studio-hhp4x8xf.js → studio-xpx91fby.js} +5 -5
  131. package/dist/chunks/studio-xva3hzkv.js +5 -0
  132. package/dist/chunks/{studio-6hzdjfv0.js → studio-xx3r3sb0.js} +4 -4
  133. package/dist/chunks/{studio-cg1bfyhf.js → studio-z3fztxrp.js} +8 -8
  134. package/dist/chunks/studio-z9453ypn.js +6 -0
  135. package/dist/chunks/{tsMode-f54j66ym.js → tsMode-jg1dtphj.js} +7 -7
  136. package/dist/chunks/workspace-6ztqcrsh.js +4 -0
  137. package/dist/chunks/workspace-home-rse9h8a0.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 +11 -11
  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/inspector-dock.json +3 -3
  175. package/src/surfaces/media-library.json +129 -1
  176. package/src/surfaces/media-library.ts +17 -2
  177. package/src/types.ts +2 -1
  178. package/src/workspace/tab-workspace.ts +2 -1
  179. package/src/workspace/workspace-switch.ts +3 -1
  180. package/src/workspace/workspace.ts +22 -1
  181. package/dist/chunks/adapter-pp011m5x.js +0 -4
  182. package/dist/chunks/add-new-flow-ecqmzz76.js +0 -4
  183. package/dist/chunks/cf-account-picker-c07znkqt.js +0 -4
  184. package/dist/chunks/cf-settings-0v2bmqy0.js +0 -4
  185. package/dist/chunks/components-yn0dn2pq.js +0 -4
  186. package/dist/chunks/content-source-g0j41yy6.js +0 -4
  187. package/dist/chunks/convert-to-repeater-sbjk7tgn.js +0 -4
  188. package/dist/chunks/editor-xz02jeh1.js +0 -4
  189. package/dist/chunks/entry-commands-kmt02crp.js +0 -4
  190. package/dist/chunks/extension-sections-0rprf12d.js +0 -4
  191. package/dist/chunks/file-ops-mexj5qed.js +0 -4
  192. package/dist/chunks/files-b2tcppdd.js +0 -4
  193. package/dist/chunks/format-host-btsevv0c.js +0 -4
  194. package/dist/chunks/fs-events-r0apejva.js +0 -4
  195. package/dist/chunks/grid-open-n2a8xkht.js +0 -4
  196. package/dist/chunks/icons-853n6qt3.js +0 -4
  197. package/dist/chunks/iframe-host-gx0ypyh3.js +0 -4
  198. package/dist/chunks/index-bs4k9tr6.js +0 -4
  199. package/dist/chunks/jx-validate-ymf10teh.js +0 -4
  200. package/dist/chunks/layers-p9nfya38.js +0 -4
  201. package/dist/chunks/layers-panel-tt1hzhhf.js +0 -4
  202. package/dist/chunks/lit-html-x18n2sfz.js +0 -4
  203. package/dist/chunks/media-library-0n45gv8r.js +0 -4
  204. package/dist/chunks/media-picker-s09at95z.js +0 -4
  205. package/dist/chunks/monaco-buffer-een2ea0v.js +0 -4
  206. package/dist/chunks/notify-qwb8radt.js +0 -4
  207. package/dist/chunks/parse-v77xtwv7.js +0 -4
  208. package/dist/chunks/platform-8jnez0wr.js +0 -4
  209. package/dist/chunks/publish-panel-aysnj611.js +0 -4
  210. package/dist/chunks/publish-quick-kpbqs9mz.js +0 -4
  211. package/dist/chunks/publish-workspace-nd0a8q99.js +0 -4
  212. package/dist/chunks/quick-search-vg0jkhk2.js +0 -4
  213. package/dist/chunks/references-a7v6a0e2.js +0 -4
  214. package/dist/chunks/right-panel-cvk1vxx5.js +0 -4
  215. package/dist/chunks/source-control-fsqzc6he.js +0 -4
  216. package/dist/chunks/studio-05jfwbmf.js +0 -5
  217. package/dist/chunks/studio-0q5jwddh.js +0 -9
  218. package/dist/chunks/studio-0w2dr0q8.js +0 -5
  219. package/dist/chunks/studio-1tzpds97.js +0 -9
  220. package/dist/chunks/studio-216b27sv.js +0 -4
  221. package/dist/chunks/studio-2ejhqns4.js +0 -5
  222. package/dist/chunks/studio-2r90cts3.js +0 -16
  223. package/dist/chunks/studio-399xknkb.js +0 -6
  224. package/dist/chunks/studio-4ncmwqw5.js +0 -5
  225. package/dist/chunks/studio-5atwjgkj.js +0 -7
  226. package/dist/chunks/studio-5frqk7c0.js +0 -5
  227. package/dist/chunks/studio-6cwj6xrx.js +0 -5
  228. package/dist/chunks/studio-78pywnp2.js +0 -6
  229. package/dist/chunks/studio-886t6ztg.js +0 -5
  230. package/dist/chunks/studio-8wnvtr4z.js +0 -5
  231. package/dist/chunks/studio-8z66jk4z.js +0 -5
  232. package/dist/chunks/studio-b2qpt0xy.js +0 -14
  233. package/dist/chunks/studio-c71806xz.js +0 -7
  234. package/dist/chunks/studio-cbwks69x.js +0 -928
  235. package/dist/chunks/studio-cg710sk9.js +0 -5
  236. package/dist/chunks/studio-cw0wb9qk.js +0 -7
  237. package/dist/chunks/studio-cyheys7k.js +0 -5
  238. package/dist/chunks/studio-ea5m9kg5.js +0 -5
  239. package/dist/chunks/studio-efdxjwyp.js +0 -64
  240. package/dist/chunks/studio-egmw2837.js +0 -5
  241. package/dist/chunks/studio-f746039f.js +0 -5
  242. package/dist/chunks/studio-fmdymd9v.js +0 -5
  243. package/dist/chunks/studio-gcm12z00.js +0 -6
  244. package/dist/chunks/studio-gj98m8gy.js +0 -7
  245. package/dist/chunks/studio-hasbgjah.js +0 -5
  246. package/dist/chunks/studio-hz5n7x27.js +0 -5
  247. package/dist/chunks/studio-jeraxdgv.js +0 -7
  248. package/dist/chunks/studio-jvgek9xt.js +0 -5
  249. package/dist/chunks/studio-jws1a87n.js +0 -5
  250. package/dist/chunks/studio-k2sz13zg.js +0 -5
  251. package/dist/chunks/studio-k3m4jkdk.js +0 -5
  252. package/dist/chunks/studio-kg5ya5r2.js +0 -5
  253. package/dist/chunks/studio-khwbgrtg.js +0 -5
  254. package/dist/chunks/studio-mx6wzh25.js +0 -5
  255. package/dist/chunks/studio-mxs3ar9t.js +0 -5
  256. package/dist/chunks/studio-nm4szey4.js +0 -5
  257. package/dist/chunks/studio-nqxqf8p2.js +0 -4
  258. package/dist/chunks/studio-qn7z4krm.js +0 -5
  259. package/dist/chunks/studio-qyed0ah6.js +0 -5
  260. package/dist/chunks/studio-r4bcdwhq.js +0 -5
  261. package/dist/chunks/studio-rtfnhrdg.js +0 -5
  262. package/dist/chunks/studio-s5tzw84t.js +0 -5
  263. package/dist/chunks/studio-t51y8v1d.js +0 -5
  264. package/dist/chunks/studio-t98vq4r6.js +0 -5
  265. package/dist/chunks/studio-tsxpqvh0.js +0 -6
  266. package/dist/chunks/studio-v1yascv2.js +0 -5
  267. package/dist/chunks/studio-wxq6v5sv.js +0 -5
  268. package/dist/chunks/studio-y60sts62.js +0 -4
  269. package/dist/chunks/studio-zndrqawb.js +0 -5
  270. package/dist/chunks/studio-zwaxn5s2.js +0 -5
  271. package/dist/chunks/workspace-home-x1787wpv.js +0 -4
  272. package/dist/chunks/workspace-zpe1np43.js +0 -4
@@ -6,7 +6,11 @@
6
6
  * arrived, and what a script or the palette runs to ask "what is unused".
7
7
  * - `media.upload` shows the Media home and opens its Upload picker.
8
8
  * - `media.replace { path }` selects a file and opens its Replace picker: the one overwrite.
9
+ * - `media.rename { path }` renames a file, through the dialog that counts what will be rewritten.
10
+ * - `media.duplicate { path }` copies a file to a new name beside it, and selects the copy.
9
11
  * - `media.delete { path }` deletes a file, through the confirmation that states its usage.
12
+ * - `media.edit { path }` opens an image in its Image editor (§9.5), the only way that mode is
13
+ * entered by a verb.
10
14
  *
11
15
  * Every one runs the flow, never a copy of it: the surface's Upload label and Replace input are the
12
16
  * same controls these open (`HTMLInputElement.showPicker()`), and Delete is the detail pane's own.
@@ -31,13 +35,17 @@ import type { MediaBrowseRequest } from "./media-library";
31
35
  import type { CommandArgValues } from "../commands/command-args";
32
36
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
33
37
  import type * as MediaLibraryModule from "./media-library";
38
+ import type * as MediaOpenModule from "./media-open";
34
39
  import type * as WorkspaceHomeModule from "../workspace/workspace-home";
35
40
 
36
41
  /** The ids, which Add new and the Site panel run. */
37
42
  export const MEDIA_BROWSE = "media.browse";
38
43
  export const MEDIA_UPLOAD = "media.upload";
39
44
  export const MEDIA_REPLACE = "media.replace";
45
+ export const MEDIA_RENAME = "media.rename";
46
+ export const MEDIA_DUPLICATE = "media.duplicate";
40
47
  export const MEDIA_DELETE = "media.delete";
48
+ export const MEDIA_EDIT = "media.edit";
41
49
 
42
50
  /**
43
51
  * The flow and the home opener, each imported once. One cached promise per module: two dynamic
@@ -45,6 +53,7 @@ export const MEDIA_DELETE = "media.delete";
45
53
  */
46
54
  let _library: Promise<typeof MediaLibraryModule> | null = null;
47
55
  let _home: Promise<typeof WorkspaceHomeModule> | null = null;
56
+ let _open: Promise<typeof MediaOpenModule> | null = null;
48
57
 
49
58
  function library(): Promise<typeof MediaLibraryModule> {
50
59
  _library ??= import("./media-library");
@@ -56,6 +65,11 @@ function home(): Promise<typeof WorkspaceHomeModule> {
56
65
  return _home;
57
66
  }
58
67
 
68
+ function opener(): Promise<typeof MediaOpenModule> {
69
+ _open ??= import("./media-open");
70
+ return _open;
71
+ }
72
+
59
73
  /** A path argument that must name a media file. Refused with the sentence a person can act on. */
60
74
  function mediaPathArg(commandId: string, args: CommandArgValues, key: string): string {
61
75
  const path = stringArg(commandId, args, key).replace(/^\.\//, "");
@@ -91,7 +105,7 @@ async function showHome(): Promise<void> {
91
105
 
92
106
  const PATH_DESCRIPTION = "The media file's project path, such as public/media/hero.jpg.";
93
107
 
94
- /** The four records. */
108
+ /** The seven records. */
95
109
  export function mediaCommands(): AnyCommand[] {
96
110
  const base = {
97
111
  category: "Project",
@@ -154,6 +168,56 @@ export function mediaCommands(): AnyCommand[] {
154
168
  title: "Replace Media File…",
155
169
  undo: "none",
156
170
  },
171
+ {
172
+ ...base,
173
+ args: argsSchema(
174
+ { path: stringProperty(`The image to edit: a PNG, JPEG or WebP. ${PATH_DESCRIPTION}`) },
175
+ ["path"],
176
+ ),
177
+ icon: "crop",
178
+ id: MEDIA_EDIT,
179
+ requires: "an open project, on a backend that can read a file's raw bytes",
180
+ run: async (_ctx, args) => {
181
+ const path = mediaPathArg(MEDIA_EDIT, args, "path");
182
+ const flow = await opener();
183
+ flow.openImageEditor(path);
184
+ },
185
+ title: "Edit Image…",
186
+ undo: "none",
187
+ /* Not gated on `collab.readOnly`: that is the FOCUSED tab's session, and this record takes a
188
+ path that may be another file. The refusal asks the path's own tab, in the flow. */
189
+ when: (ctx) => ctx.project.open && ctx.capability.readFileBytes,
190
+ },
191
+ {
192
+ ...base,
193
+ args: argsSchema({ path: stringProperty(`The file to rename. ${PATH_DESCRIPTION}`) }, [
194
+ "path",
195
+ ]),
196
+ icon: "pencil-simple",
197
+ id: MEDIA_RENAME,
198
+ run: async (_ctx, args) => {
199
+ const path = mediaPathArg(MEDIA_RENAME, args, "path");
200
+ const flow = await library();
201
+ await flow.renameMediaFile(path);
202
+ },
203
+ title: "Rename Media File…",
204
+ undo: "none",
205
+ },
206
+ {
207
+ ...base,
208
+ args: argsSchema({ path: stringProperty(`The file to copy. ${PATH_DESCRIPTION}`) }, ["path"]),
209
+ icon: "copy",
210
+ id: MEDIA_DUPLICATE,
211
+ requires: "an open project, on a backend that can read a file's raw bytes",
212
+ run: async (_ctx, args) => {
213
+ const path = mediaPathArg(MEDIA_DUPLICATE, args, "path");
214
+ const flow = await library();
215
+ await flow.duplicateMediaFile(path);
216
+ },
217
+ title: "Duplicate Media File",
218
+ undo: "none",
219
+ when: (ctx) => ctx.project.open && ctx.capability.readFileBytes,
220
+ },
157
221
  {
158
222
  ...base,
159
223
  args: argsSchema({ path: stringProperty(`The file to delete. ${PATH_DESCRIPTION}`) }, [
@@ -97,7 +97,7 @@ export function passesFilter(
97
97
  * says. Not its path: every path starts `public/` or `content/`, so a one-letter search for "b" or
98
98
  * "c" kept the whole library and looked broken.
99
99
  */
100
- export function matchesQuery(file: LibraryMediaFile, query: string): boolean {
100
+ export function matchesQuery(file: Pick<LibraryMediaFile, "name">, query: string): boolean {
101
101
  const needle = query.trim().toLowerCase();
102
102
  return needle === "" || file.name.toLowerCase().includes(needle);
103
103
  }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * One flow, two modes (studio.md §22.6). In **manage** mode it is the Media home tab: filters on
6
6
  * the left, a grid of tiles in the middle, and the selected file's detail on the right, with
7
- * **Replace**, **Open in viewer** and **Delete**. In **pick** mode it is the full-size media picker
8
- * (§9.3), the same surface inside a `jx-dialog` whose footer answers Cancel or Use <file>; the
9
- * compact popover's **Browse all media…** opens it (`ui/media-picker.ts`).
7
+ * **Replace**, **Open in viewer**, **Rename**, **Duplicate** and **Delete**. In **pick** mode it is
8
+ * the full-size media picker (§9.3), the same surface inside a `jx-dialog` whose footer answers
9
+ * Cancel or Use <file>; the compact popover's **Browse all media…** opens it
10
+ * (`ui/media-picker.ts`).
10
11
  *
11
12
  * **What it lists is `ui/media-picker.ts`'s listing**, `public/**` and every collection's
12
13
  * `images/**`, so the workspace, the pickers and the Site panel cannot disagree about what exists.
@@ -27,8 +28,10 @@
27
28
  * drop, only files the listing will show are uploaded, and the first upload is selected at once, so
28
29
  * the detail pane describes it, and its tile scrolls into view, the moment the re-read listing has
29
30
  * it. **Replace** is the one overwrite (`replaceAsset`), offered only files of the replaced one's
30
- * own type; **Delete** goes through the same confirmation as every delete, which states the usage
31
- * line (`files/file-ops.ts`), and a delete that empties an upload destination (`public/media/`, a
31
+ * own type; **Rename** keeps the file's folder and format and refuses a name already taken, and
32
+ * **Duplicate** copies the bytes on disk to a new name beside it (both studio.md §9.3); **Delete**
33
+ * goes through the same confirmation as every delete, which states the usage line
34
+ * (`files/file-ops.ts`), and a delete that empties an upload destination (`public/media/`, a
32
35
  * collection's `images/`) takes the folder the upload created with it.
33
36
  *
34
37
  * **This module is the flow; `surfaces/media-library.json` is the markup.** Every decision is made
@@ -42,14 +45,17 @@ import { getPlatform } from "../platform";
42
45
  import { notify } from "../services/notify";
43
46
  import { invalidateUsages, onUsagesInvalidated, usagesSupported } from "../services/references";
44
47
  import { overlayRegion, paneRegion } from "../ui/regions";
45
- import { layerHost } from "../ui/layers";
48
+ import { layerHost, showPromptDialog } from "../ui/layers";
46
49
  import { rectOf } from "../utils/geometry";
47
50
  import { activeTab, closeTab, workspace } from "../workspace/workspace";
48
51
  import { createPreviewObserver } from "../browse/library-preview";
49
52
  import {
50
53
  DEFAULT_MEDIA_DIR,
51
54
  announceMediaChanged,
55
+ duplicateAsset,
52
56
  isUploadDestination,
57
+ joinDir,
58
+ mediaRenameRefusal,
53
59
  replaceAccept,
54
60
  replaceAsset,
55
61
  uploadAccept,
@@ -63,12 +69,19 @@ import {
63
69
  peekMediaMeta,
64
70
  recordImageSize,
65
71
  } from "../files/media-meta";
66
- import { dirName, previewFileSrc } from "../files/media-paths";
72
+ import { baseName, dirName, previewFileSrc } from "../files/media-paths";
67
73
  import { forgetVersions } from "../files/asset-versions";
68
74
  import { loadMediaUsages, peekMediaUsages } from "../files/media-usage";
69
75
  import { onProjectMediaChange, projectMediaFiles } from "../ui/media-picker";
70
76
  import { openMenu } from "../surfaces/menu";
71
- import { openMediaTab } from "./media-open";
77
+ import {
78
+ canDuplicateMedia,
79
+ canEditImage,
80
+ mediaWriteRefused,
81
+ openImageEditor,
82
+ openMediaTab,
83
+ } from "./media-open";
84
+ import { imageEditorAllowsReplace, imageFileChanged } from "./image-editor";
72
85
  import { factsLine, mediaStageFor, specimenOf, usageOf } from "./media-pane";
73
86
  import {
74
87
  MEDIA_FILTERS,
@@ -247,6 +260,11 @@ interface LibraryMount {
247
260
  * hold yet when it is selected, and which a sort can place anywhere in the grid.
248
261
  */
249
262
  reveal: string | null;
263
+ /**
264
+ * Whether {@link reveal}'s tile also takes the keyboard. A rename and a duplicate draw a NEW tile
265
+ * for the file the reader was on, and the focus they left on the old one is gone with it.
266
+ */
267
+ revealFocus: boolean;
250
268
  /**
251
269
  * Whether the keyboard is owed to this library's tab stop ({@link focusMediaHome}) and no tile
252
270
  * has been drawn to take it yet: a home mounted before its listing has arrived has no tiles.
@@ -319,6 +337,10 @@ function revealPending(mount: LibraryMount): void {
319
337
  if (tile?.isConnected) {
320
338
  mount.reveal = null;
321
339
  tile.scrollIntoView({ block: "nearest" });
340
+ if (mount.revealFocus) {
341
+ mount.revealFocus = false;
342
+ tile.focus();
343
+ }
322
344
  }
323
345
  }
324
346
 
@@ -578,6 +600,8 @@ function detailView(
578
600
  | "previewSpecimen"
579
601
  | "previewSpecimenFamily"
580
602
  | "replaceAccept"
603
+ | "editable"
604
+ | "duplicable"
581
605
  | "usageState"
582
606
  | "usageHeadline"
583
607
  | "usageFiles"
@@ -597,6 +621,8 @@ function detailView(
597
621
  previewSpecimenFamily: "",
598
622
  previewSrc: "",
599
623
  replaceAccept: "",
624
+ editable: "no",
625
+ duplicable: "no",
600
626
  selectedFile: "",
601
627
  usageFiles: [],
602
628
  usageHeadline: "",
@@ -638,6 +664,8 @@ function detailView(
638
664
  previewSpecimenFamily: specimen.specimenFamily,
639
665
  previewSrc: previewKind === "glyph" ? "" : previewFileSrc(entry.file),
640
666
  replaceAccept: replaceAccept(entry.file),
667
+ editable: canEditImage(entry.file) ? "yes" : "no",
668
+ duplicable: canDuplicateMedia() ? "yes" : "no",
641
669
  selectedFile: entry.file,
642
670
  ...usageOf(usage),
643
671
  };
@@ -778,6 +806,99 @@ async function pruneEmptiedDestination(
778
806
  }
779
807
  }
780
808
 
809
+ /**
810
+ * Rename a media file, in the dialog every rename goes through (studio.md §9.3): the references the
811
+ * refactor pass will rewrite are counted before the field opens, and the field refuses a name the
812
+ * file cannot take, a taken name above all, because the backend would overwrite it in silence.
813
+ *
814
+ * Whatever showed the file goes on showing it: the selection follows it, and an open tab keeps its
815
+ * state under the new path.
816
+ *
817
+ * @returns The new path, or `null` when nothing was renamed.
818
+ */
819
+ export async function renameMediaFile(path: string): Promise<string | null> {
820
+ if (mediaWriteRefused(path, "renamed")) {
821
+ return null;
822
+ }
823
+ const name = baseName(path);
824
+ const dir = dirName(path);
825
+ const { commitRename, listRenameSiblings, notifyMoveOutcome, renameStatus } = await filesModule();
826
+ const taken = await listRenameSiblings(path, "Media");
827
+ if (taken === null) {
828
+ return null;
829
+ }
830
+ const { renamePromptMessage } = await fileOpsModule();
831
+ const message = await renamePromptMessage(path);
832
+ const entered = await showPromptDialog("Rename", {
833
+ confirmLabel: "Rename",
834
+ select: "stem",
835
+ validate: (value) => mediaRenameRefusal(path, value, taken),
836
+ value: name,
837
+ ...(message !== undefined && { message }),
838
+ });
839
+ if (!entered || entered === name) {
840
+ return null;
841
+ }
842
+ const to = joinDir(dir, entered);
843
+ const measured = peekMediaMeta(path);
844
+ try {
845
+ const report = await commitRename(path, to);
846
+ if (homeChoice.selected === path) {
847
+ landOnNewFile(to);
848
+ }
849
+ await announceMediaChanged(dir);
850
+ /* The announcement drops every measurement, and a viewer already showing the picture will not
851
+ load it again to measure it. A rename changes no bytes, so what was true of them still is. */
852
+ if (measured?.width != null && measured.height != null) {
853
+ recordImageSize(to, measured.width, measured.height);
854
+ }
855
+ notifyMoveOutcome(renameStatus(entered, report), report, to);
856
+ return to;
857
+ } catch (error) {
858
+ notify.error(`Could not rename ${name}.`, {
859
+ detail: errorMessage(error),
860
+ path,
861
+ source: "Media",
862
+ });
863
+ return null;
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Duplicate a media file (studio.md §9.3), and select the copy in the Media home, as an upload is.
869
+ *
870
+ * @returns The copy's path, or `null` when none was made.
871
+ */
872
+ export async function duplicateMediaFile(path: string): Promise<string | null> {
873
+ if (mediaWriteRefused(path, "duplicated")) {
874
+ return null;
875
+ }
876
+ const copy = await duplicateAsset(path);
877
+ if (copy === null) {
878
+ return null;
879
+ }
880
+ landOnNewFile(copy.path);
881
+ redrawAll();
882
+ return copy.path;
883
+ }
884
+
885
+ /**
886
+ * Put the Media home's selection on a file a rename or a duplicate has just made: on screen, and
887
+ * holding the keyboard once its tile is drawn. Neither verb changes a file's kind, and a file just
888
+ * renamed or copied is as unused as it was, so the filter still keeps it; only a search the new
889
+ * name does not match has to go.
890
+ */
891
+ function landOnNewFile(file: string): void {
892
+ homeChoice.selected = file;
893
+ if (!matchesQuery({ name: baseName(file) }, homeChoice.query)) {
894
+ homeChoice.query = "";
895
+ }
896
+ for (const { mount } of _panes.values()) {
897
+ mount.reveal = file;
898
+ mount.revealFocus = true;
899
+ }
900
+ }
901
+
781
902
  function actionsFor(mount: LibraryMount) {
782
903
  return {
783
904
  activate: (file: string) => {
@@ -857,22 +978,45 @@ function actionsFor(mount: LibraryMount) {
857
978
  openMediaTab(selected);
858
979
  }
859
980
  },
981
+ editImage: () => {
982
+ const { selected } = mount.choice;
983
+ if (selected !== null) {
984
+ openImageEditor(selected);
985
+ }
986
+ },
860
987
  remove: () => {
861
988
  const { selected } = mount.choice;
862
989
  if (selected !== null) {
863
990
  void deleteMediaFile(selected);
864
991
  }
865
992
  },
993
+ rename: () => {
994
+ const { selected } = mount.choice;
995
+ if (selected !== null) {
996
+ void renameMediaFile(selected);
997
+ }
998
+ },
999
+ duplicate: () => {
1000
+ const { selected } = mount.choice;
1001
+ if (selected !== null) {
1002
+ void duplicateMediaFile(selected);
1003
+ }
1004
+ },
866
1005
  replace: (input: HTMLInputElement) => {
867
1006
  const [chosen] = input.files ?? [];
868
1007
  // Cleared first: a file input that keeps its value refuses the same file twice.
869
1008
  input.value = "";
870
1009
  const { selected } = mount.choice;
871
- if (chosen && selected !== null) {
1010
+ if (chosen && selected !== null && imageEditorAllowsReplace(selected)) {
872
1011
  /* No metadata invalidation here: `replaceAsset` announces the change, whose handler drops
873
1012
  the listing AND its metadata and re-reads them in that order. A second drop after it
874
1013
  lands would wipe the fresh facts of every other tile, and nothing would re-seed them. */
875
- void replaceAsset(selected, chosen).then(redrawAll);
1014
+ void replaceAsset(selected, chosen).then((replaced) => {
1015
+ if (replaced) {
1016
+ imageFileChanged(selected);
1017
+ }
1018
+ redrawAll();
1019
+ });
876
1020
  }
877
1021
  },
878
1022
  reset: () => {
@@ -932,10 +1076,9 @@ let _tileMenu: MenuHandle | null = null;
932
1076
  /**
933
1077
  * A right-click on a tile: select it, then offer the detail pane's actions at the pointer.
934
1078
  *
935
- * The same four verbs as the detail pane's (studio.md §22.6), in the order the pane draws them, and
936
- * each calls what the pane's control calls, so the menu cannot drift from the pane. Selecting first
937
- * is what makes the pane describe the file the menu acts on, as a right-click on a row does in the
938
- * Content workspace.
1079
+ * The same verbs as the detail pane's (studio.md §22.6), and each calls what the pane's control
1080
+ * calls, so the menu cannot drift from the pane. Selecting first is what makes the pane describe
1081
+ * the file the menu acts on, as a right-click on a row does in the Content workspace.
939
1082
  */
940
1083
  function showTileMenu(mount: LibraryMount, file: string, x: number, y: number): void {
941
1084
  select(mount, file);
@@ -969,8 +1112,24 @@ function showTileMenu(mount: LibraryMount, file: string, x: number, y: number):
969
1112
  region: "media-tile",
970
1113
  rows: [
971
1114
  row("open", "Open in Viewer", () => openMediaTab(file)),
1115
+ ...(canEditImage(file) ? [row("edit", "Edit Image", () => openImageEditor(file))] : []),
972
1116
  row("copy-url", "Copy Site URL", () => copySiteUrl(entry)),
973
- row("replace", "Replace…", () => requestMediaReplace(file), { dividerAbove: true }),
1117
+ row(
1118
+ "rename",
1119
+ "Rename…",
1120
+ () => {
1121
+ void renameMediaFile(file);
1122
+ },
1123
+ { dividerAbove: true },
1124
+ ),
1125
+ ...(canDuplicateMedia()
1126
+ ? [
1127
+ row("duplicate", "Duplicate", () => {
1128
+ void duplicateMediaFile(file);
1129
+ }),
1130
+ ]
1131
+ : []),
1132
+ row("replace", "Replace…", () => requestMediaReplace(file)),
974
1133
  row(
975
1134
  "delete",
976
1135
  "Delete",
@@ -1055,6 +1214,7 @@ function createMount(
1055
1214
  onSelect: null,
1056
1215
  paneId,
1057
1216
  reveal: null,
1217
+ revealFocus: false,
1058
1218
  focusOwed: false,
1059
1219
  region,
1060
1220
  sweepTimer: null,
@@ -18,9 +18,16 @@
18
18
  * @docs studio/projects/media
19
19
  */
20
20
 
21
+ import { isFunction } from "@jxsuite/schema/guards";
21
22
  import { activateTab, openTab, workspace } from "../workspace/workspace";
22
23
  import { extensionOf, isMediaFile } from "../files/media-upload";
24
+ import { getPlatform, hasPlatform } from "../platform";
25
+ import { notify } from "../services/notify";
26
+ import { collabReadOnly } from "../collab/collab-session";
27
+ import { toRaw } from "../reactivity";
23
28
  import { MEDIA_MODE } from "./media-pane";
29
+ import { IMAGE_MODE } from "./image-editor";
30
+ import { editRefusal, isEditableExtension } from "./image-format";
24
31
  import type { Tab } from "../tabs/tab";
25
32
 
26
33
  /** Placeholder document for a media tab — the viewer never reads it, and there is nothing to save. */
@@ -31,9 +38,21 @@ export function isViewableMedia(path: string): boolean {
31
38
  return isMediaFile(path);
32
39
  }
33
40
 
34
- /** The modes a media tab offers. SVG is also text, so it keeps a source alternate. */
41
+ /**
42
+ * The modes a media tab offers, as far as the path can say. SVG is also text, so it keeps a source
43
+ * alternate; a still raster in a format the host can encode gets the Image editor (studio.md §9.5)
44
+ * on a host that can hand Studio a file's raw bytes. A refused format is not offered rather than
45
+ * offered and then refused, and the half of that the extension cannot answer (an animated `.png` or
46
+ * `.webp`, say) is {@link withdrawRefusedEditor}'s.
47
+ */
35
48
  export function mediaTabModes(path: string): string[] {
36
- return extensionOf(path) === ".svg" ? [MEDIA_MODE, "source"] : [MEDIA_MODE];
49
+ if (extensionOf(path) === ".svg") {
50
+ return [MEDIA_MODE, "source"];
51
+ }
52
+ if (isEditableExtension(path) && hasPlatform() && isFunction(getPlatform().readFileBytes)) {
53
+ return [MEDIA_MODE, IMAGE_MODE];
54
+ }
55
+ return [MEDIA_MODE];
37
56
  }
38
57
 
39
58
  /**
@@ -57,7 +76,7 @@ export function openMediaTab(
57
76
  activateTab(path, { focus: opts.focus !== false });
58
77
  return existing;
59
78
  }
60
- return openTab({
79
+ const tab = openTab({
61
80
  capabilities: { modes: mediaTabModes(path) },
62
81
  document: structuredClone(MEDIA_STUB_DOCUMENT),
63
82
  documentPath: path,
@@ -67,4 +86,138 @@ export function openMediaTab(
67
86
  ...(opts.preview === true && { preview: true }),
68
87
  ...(opts.focus === false && { focus: false }),
69
88
  });
89
+ void withdrawRefusedEditor(tab, path);
90
+ return tab;
91
+ }
92
+
93
+ /** Why a tab's Image editor was withdrawn, so a later attempt can say so again. */
94
+ const _withdrawn = new WeakMap<Tab, string>();
95
+
96
+ /**
97
+ * Take the Image editor off a tab whose bytes refuse it: an extension is a claim about the bytes,
98
+ * and a still `.png` and an animated one share an extension (studio.md §9.5).
99
+ *
100
+ * The extension already answered for every file the mode could never write, so this reads only a
101
+ * file the path says is editable, and the viewer is drawing those same bytes anyway. A read that
102
+ * fails decides nothing: the editor reports it if the author goes there. A tab already in the mode
103
+ * goes back to the viewer and says why, because a mode that vanished in silence would look like a
104
+ * click that did nothing.
105
+ */
106
+ async function withdrawRefusedEditor(tab: Tab, path: string): Promise<void> {
107
+ if (!tab.capabilities.modes.includes(IMAGE_MODE)) {
108
+ return;
109
+ }
110
+ let bytes: Uint8Array;
111
+ try {
112
+ bytes = new Uint8Array(await getPlatform().readFileBytes!(path));
113
+ } catch {
114
+ return;
115
+ }
116
+ const refusal = editRefusal(path, bytes);
117
+ /* Through the workspace's own face of the tab: `openTab` hands back the raw object, and a write to
118
+ that changes the value without telling the Editor control. It also answers a tab the read
119
+ outlived, which has nothing left to withdraw from. */
120
+ const live = workspace.tabs.get(path);
121
+ if (refusal === null || !live) {
122
+ return;
123
+ }
124
+ _withdrawn.set(toRaw(live), refusal);
125
+ live.capabilities.modes = live.capabilities.modes.filter((mode) => mode !== IMAGE_MODE);
126
+ if (live.session.ui.canvasMode === IMAGE_MODE) {
127
+ live.session.ui.canvasMode = MEDIA_MODE;
128
+ notify.warn(refusal, { key: `image.refused:${path}`, path, source: "Image" });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Whether to offer the Image editor for a file in a list of them: what the path says, and what its
134
+ * open tab has since learned. The palette's `media.edit` cannot ask this (its context describes the
135
+ * focused tab), so it is the flow's refusal there.
136
+ */
137
+ export function canEditImage(path: string): boolean {
138
+ if (!mediaTabModes(path).includes(IMAGE_MODE)) {
139
+ return false;
140
+ }
141
+ const tab = workspace.tabs.get(path);
142
+ return !tab || (tab.capabilities.modes.includes(IMAGE_MODE) && !collabReadOnly(tab));
143
+ }
144
+
145
+ /**
146
+ * Whether this host can copy a media file: a copy needs its raw bytes (studio.md §9.3), and a
147
+ * backend that cannot read them is not offered Duplicate rather than offered it and then refused.
148
+ */
149
+ export function canDuplicateMedia(): boolean {
150
+ return hasPlatform() && isFunction(getPlatform().readFileBytes);
151
+ }
152
+
153
+ /**
154
+ * Whether a collaborator with read access to this file's session is the one asking to write it, and
155
+ * if so say so. Asks the PATH's own tab, as {@link openImageEditor} does: a record's context
156
+ * describes the focused tab, which may be another file.
157
+ *
158
+ * @param path The file.
159
+ * @param done How the sentence ends: "renamed", "duplicated".
160
+ */
161
+ export function mediaWriteRefused(path: string, done: string): boolean {
162
+ const tab = workspace.tabs.get(path);
163
+ if (!tab || !collabReadOnly(tab)) {
164
+ return false;
165
+ }
166
+ const name = path.split("/").pop() ?? path;
167
+ notify.warn(`You have read access to this session, so ${name} cannot be ${done}.`, {
168
+ key: `media.readOnly:${path}`,
169
+ path,
170
+ source: "Collaboration",
171
+ });
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * Open a media file in its Image editor (studio.md §9.5) — `media.edit`, and the Media workspace's
177
+ * Edit action. The mode is entered by choosing a subject, never by name through `canvas.setMode`.
178
+ *
179
+ * Refused, with the reason, for a file the editor cannot write (the tab would not offer the mode)
180
+ * and for a collaborator who cannot write the file: the gate asks the PATH's own tab, which is why
181
+ * it lives here rather than on the command record, whose context describes the focused tab.
182
+ *
183
+ * @returns The tab, now in the Image editor, or null when it was refused.
184
+ */
185
+ export function openImageEditor(
186
+ path: string,
187
+ opts: { paneId?: string; focus?: boolean } = {},
188
+ ): Tab | null {
189
+ const name = path.split("/").pop() ?? path;
190
+ if (!mediaTabModes(path).includes(IMAGE_MODE)) {
191
+ notify.warn(`${name} cannot be edited here.`, {
192
+ detail:
193
+ "The Image editor writes PNG, JPEG and WebP, on a backend that can hand Studio a file's raw bytes.",
194
+ key: `image.notEditable:${path}`,
195
+ path,
196
+ source: "Image",
197
+ });
198
+ return null;
199
+ }
200
+ openMediaTab(path, opts);
201
+ // Through the workspace's face of the tab, so the pane redraws (see withdrawRefusedEditor).
202
+ const tab = workspace.tabs.get(path)!;
203
+ /* Asked of the tab as it stands, after the open: a session may have attached in it, and bytes read
204
+ earlier may have withdrawn the mode. Either leaves the tab in its viewer, saying why. */
205
+ if (collabReadOnly(tab)) {
206
+ notify.warn(`You have read access to this session, so ${name} cannot be edited.`, {
207
+ key: `image.readOnly:${path}`,
208
+ path,
209
+ source: "Collaboration",
210
+ });
211
+ return null;
212
+ }
213
+ if (!tab.capabilities.modes.includes(IMAGE_MODE)) {
214
+ notify.warn(_withdrawn.get(toRaw(tab)) ?? `${name} cannot be edited here.`, {
215
+ key: `image.refused:${path}`,
216
+ path,
217
+ source: "Image",
218
+ });
219
+ return null;
220
+ }
221
+ tab.session.ui.canvasMode = IMAGE_MODE;
222
+ return tab;
70
223
  }
@@ -16,8 +16,9 @@
16
16
  * confirmation_: you could only learn what an image was used for by trying to remove it. This is
17
17
  * the surface that query was waiting for.
18
18
  *
19
- * Read-only, deliberately. Rename, delete and reveal are the file tree's, and a second set of
20
- * buttons for them here would be a second place to keep them right.
19
+ * Read-only, deliberately. Rename, duplicate, replace and delete are the Media home's and reveal is
20
+ * the file tree's, and a second set of buttons for them here would be a second place to keep them
21
+ * right.
21
22
  *
22
23
  * **This module is the flow; `surfaces/media-pane.json` is the markup.** Everything below decides
23
24
  * something — which pane holds which tab, which of the six stages a file belongs in, when the
@@ -30,6 +31,7 @@
30
31
  import { effect, effectScope } from "../reactivity";
31
32
  import { paneRegion } from "../ui/regions";
32
33
  import { localDay } from "../utils/local-day";
34
+ import { assetVersionsEpoch } from "../files/asset-versions";
33
35
  import { mediaSiteUrl, previewFileSrc } from "../files/media-paths";
34
36
  import {
35
37
  formatBytes,
@@ -208,7 +210,6 @@ export function renderMediaMode(surface: CanvasSurface, tab: Tab): void {
208
210
  detachMediaPane(paneId);
209
211
 
210
212
  const path = tab.documentPath ?? "";
211
- const src = previewFileSrc(path);
212
213
  const ref = mediaSiteUrl(path);
213
214
  const ext = extensionOf(path);
214
215
  const kind = mediaStageFor(path);
@@ -244,7 +245,9 @@ export function renderMediaMode(surface: CanvasSurface, tab: Tab): void {
244
245
  region: paneRegion(paneId, "media"),
245
246
  specimen: specimen.specimen,
246
247
  specimenFamily: specimen.specimenFamily,
247
- src,
248
+ /* Asked on every paint: bytes written in place give the URL a new repaint key (§3.4), and a
249
+ viewer holding the old URL would go on showing the picture they replaced. */
250
+ src: previewFileSrc(path),
248
251
  ...usageOf(usage),
249
252
  };
250
253
  };
@@ -285,6 +288,7 @@ export function renderMediaMode(surface: CanvasSurface, tab: Tab): void {
285
288
  browser's problem, and its metadata repaints through `redraw`. Reading it here is what
286
289
  makes a rename repaint the viewer rather than leave it pointing at a name that is gone. */
287
290
  void tab.documentPath;
291
+ void assetVersionsEpoch.value;
288
292
  draw();
289
293
  });
290
294
  });
@@ -800,7 +800,12 @@ function editorAxis(tab: Tab): Partial<PaneContextView> {
800
800
  const current = editorKindOf(tab);
801
801
  const kinds = IMPLICIT_EDITOR_KINDS.has(current)
802
802
  ? [current]
803
- : editorKindsOf(tab).filter((kind) => !IMPLICIT_EDITOR_KINDS.has(kind));
803
+ : editorKindsOf(tab).filter(
804
+ (kind) =>
805
+ !IMPLICIT_EDITOR_KINDS.has(kind) &&
806
+ // A collaborator who cannot write is not offered the editor that exists to write (§9.5).
807
+ (kind !== "image" || kind === current || !collabState(tab).readOnly),
808
+ );
804
809
  if (kinds.length < 2) {
805
810
  // One kind is not a choice. Rendering it as a segmented group would be a control that cannot move —
806
811
  // Which is the defect this axis exists to remove, in miniature.