@jxsuite/studio 3.0.0 → 4.0.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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
  33. package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
  34. package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
  57. package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  60. package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
  81. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  82. package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +108 -24
  86. package/dist/studio.js +2362 -1695
  87. package/package.json +16 -15
  88. package/src/browse/library-pane.ts +57 -3
  89. package/src/canvas/canvas-render.ts +23 -1
  90. package/src/canvas/iframe-patch.ts +47 -21
  91. package/src/canvas/iframe-render.ts +22 -6
  92. package/src/commands/app-commands.ts +4 -0
  93. package/src/commands/command-args.ts +29 -0
  94. package/src/commands/context.ts +15 -1
  95. package/src/commands/defaults.ts +29 -1
  96. package/src/commands/levels.ts +16 -0
  97. package/src/commands/live-context.ts +3 -0
  98. package/src/content/collection-match.ts +267 -0
  99. package/src/content/entry-commands.ts +38 -29
  100. package/src/content/entry-fields.ts +6 -0
  101. package/src/editor/shortcuts.ts +5 -0
  102. package/src/files/file-ops.ts +10 -42
  103. package/src/files/files.ts +485 -59
  104. package/src/files/fs-events.ts +9 -0
  105. package/src/files/gitignore.ts +432 -0
  106. package/src/files/serialize-document.ts +60 -0
  107. package/src/format/convert-file.ts +511 -0
  108. package/src/format/format-choices.ts +199 -0
  109. package/src/grid/grid-view.ts +16 -2
  110. package/src/media/media-open.ts +58 -0
  111. package/src/media/media-pane.ts +310 -0
  112. package/src/new-project/import-tab.ts +332 -160
  113. package/src/new-project/new-project-modal.ts +27 -34
  114. package/src/packages/ensure-deps.ts +14 -7
  115. package/src/packages/jxsuite-update.ts +11 -11
  116. package/src/page-params.ts +10 -46
  117. package/src/panels/activity-bar.ts +45 -16
  118. package/src/panels/ai-chat/attached-context.ts +6 -1
  119. package/src/panels/ai-chat/chat-view.ts +255 -13
  120. package/src/panels/ai-chat/composer.ts +21 -97
  121. package/src/panels/ai-panel.ts +116 -2
  122. package/src/panels/navigator-panels.ts +7 -6
  123. package/src/panels/settings-menu.ts +799 -0
  124. package/src/panels/toolbar.ts +135 -15
  125. package/src/platforms/cloud.ts +20 -0
  126. package/src/platforms/devserver.ts +67 -33
  127. package/src/preview/preview-overlay.ts +226 -0
  128. package/src/services/ai-ask.ts +247 -0
  129. package/src/services/ai-import-tools.ts +494 -0
  130. package/src/services/ai-project-tools.ts +20 -26
  131. package/src/services/ai-system-prompt.ts +88 -2
  132. package/src/services/ai-turn-signal.ts +70 -0
  133. package/src/services/connection.ts +74 -0
  134. package/src/services/context-manager.ts +89 -0
  135. package/src/services/document-assistant.ts +45 -4
  136. package/src/services/import-client.ts +33 -6
  137. package/src/services/import-run.ts +158 -0
  138. package/src/services/import-seed.ts +120 -0
  139. package/src/services/jx-validate.ts +58 -6
  140. package/src/services/project-adoption.ts +71 -0
  141. package/src/services/references.ts +0 -0
  142. package/src/services/settings/definitions.ts +17 -0
  143. package/src/services/tool-executor.ts +34 -2
  144. package/src/settings/contexts-section.ts +45 -3
  145. package/src/settings/dependencies-editor.ts +43 -23
  146. package/src/settings/general-settings.ts +30 -12
  147. package/src/settings/preferences-dialog.ts +18 -41
  148. package/src/settings/preferences-sections.ts +57 -0
  149. package/src/settings/settings-document.ts +65 -9
  150. package/src/studio.ts +33 -6
  151. package/src/tabs/transact.ts +42 -19
  152. package/src/types.ts +103 -5
  153. package/src/ui/ai-model-picker.ts +162 -0
  154. package/src/ui/choice-field.ts +72 -0
  155. package/src/ui/layers.ts +83 -5
  156. package/src/utils/studio-utils.ts +22 -52
  157. package/styles/forced-colors.css +7 -0
  158. package/styles/overlays.css +256 -0
  159. package/styles/panels.css +13 -1
  160. package/styles/shell.css +111 -0
  161. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  162. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  163. package/dist/chunks/files-xcybanxe.js +0 -70
  164. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  165. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  166. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  167. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  168. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  169. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  170. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  171. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  172. package/dist/chunks/studio-c31yb4t6.js +0 -55
  173. package/src/canvas/site-style-css.ts +0 -117
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Format-choices — the two derived predicates behind "which format may I create?" and "which format
3
+ * may I convert to?".
4
+ *
5
+ * Both are computed from what a format class DECLARES — `capabilities.parse`,
6
+ * `capabilities.serialize`, `format.documentKinds` — so a third format extension appears in the New
7
+ * File picker and in Convert To with no edit to this file, which is the same contract
8
+ * `format/format-host.ts` already keeps for opening and saving.
9
+ *
10
+ * Pure and synchronous over the loaded registry and `projectState`, because the Files tree's
11
+ * context menu is built during a render and cannot await anything.
12
+ */
13
+
14
+ import { collectionForFile } from "../content/collection-match";
15
+ import { documentExtensions, formatByExtension, formatForPath, getFormats } from "./format-host";
16
+ import { projectState } from "../store";
17
+
18
+ /** One row of a format picker: the extension it writes, and the name the reader sees. */
19
+ export interface FormatChoiceRow {
20
+ ext: string;
21
+ label: string;
22
+ }
23
+
24
+ /**
25
+ * The document kinds that mean "this format's `parse` returns a Jx document".
26
+ *
27
+ * There is no separate declaration for the SHAPE `parse` returns, and none is needed: the compiler
28
+ * builds its page and component globs from `documentExtensions("page")` /
29
+ * `documentExtensions("component")` and casts every `parse` result to `JxDocument`
30
+ * (`packages/compiler/src/site/pages-discovery.ts`). So declaring either kind IS the claim, already
31
+ * relied upon by the build. A `content`-only format — Csv, whose `parse` returns
32
+ * `ContentLoaderEntry[]` rows — makes no such claim and is excluded by the same test.
33
+ */
34
+ const DOCUMENT_KINDS = ["page", "component"] as const;
35
+
36
+ /**
37
+ * Where a Jx document lives.
38
+ *
39
+ * Both keys are conventions the build hard-codes rather than reads (`site-build.ts` resolves
40
+ * `pages` against the project root), so a convert may lean on them. It is deliberately
41
+ * conservative: a component kept in `shared/` is not offered a conversion, which is a smaller wrong
42
+ * than offering one for `package.json`, `tsconfig.json` or a `nav.json` that no format and no
43
+ * schema claims.
44
+ */
45
+ const DOCUMENT_ROOTS = { components: "component", pages: "page" } as const;
46
+
47
+ /** Normalise a project-relative path for prefix comparison. */
48
+ function normalize(path: string): string {
49
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
50
+ }
51
+
52
+ /** Which document root a path sits under, or null. */
53
+ function documentRootOf(path: string): "page" | "component" | null {
54
+ const lower = normalize(path).toLowerCase();
55
+ for (const [dir, kind] of Object.entries(DOCUMENT_ROOTS)) {
56
+ if (lower.startsWith(`${dir}/`)) {
57
+ return kind;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Whether a format declares at least one of the document kinds. */
64
+ function declaresDocumentKind(kinds: readonly string[]): boolean {
65
+ return DOCUMENT_KINDS.some((kind) => kinds.includes(kind));
66
+ }
67
+
68
+ /**
69
+ * Whether this path is (or is used as) a layout.
70
+ *
71
+ * Layouts are JSON at BOTH readers — `packages/compiler/src/site/layout-resolver.ts` `JSON.parse`s
72
+ * and throws, `packages/studio/src/site-context.ts` `JSON.parse`s and silently returns null — and
73
+ * there is no `"layout"` document kind for a format to declare, so this clause is hand-written and
74
+ * cannot be derived. `layouts/` is excluded by {@link DOCUMENT_ROOTS} already; what remains is a
75
+ * project whose `layout` points somewhere inside a document root.
76
+ */
77
+ function isLayoutPath(path: string): boolean {
78
+ const declared = projectState?.projectConfig?.layout;
79
+ if (typeof declared !== "string") {
80
+ return false;
81
+ }
82
+ return normalize(declared).toLowerCase() === normalize(path).toLowerCase();
83
+ }
84
+
85
+ /**
86
+ * The rows the New File format picker offers, `.json` first.
87
+ *
88
+ * A format qualifies only when SOME registered class can both `parse` and `serialize` its
89
+ * extension. Both halves are load-bearing and neither is cosmetic:
90
+ *
91
+ * - Without `parse`, opening the file that was just created throws `noFormatError`
92
+ * (`files/file-ops.ts`'s `parseSourceForPath`);
93
+ * - Without `serialize`, the first save falls through to `defaultContentFormat()` and writes ANOTHER
94
+ * format's bytes into the file (`files/file-ops.ts`'s `serializeDocument`).
95
+ *
96
+ * Looked up per `(extension, capability)` rather than per format row because the registry
97
+ * deliberately legalises a split claim — one class parsing an extension, another serializing it.
98
+ *
99
+ * @param kind - Restrict to formats declaring this document kind (the Library's New ▸ Page).
100
+ * @returns The picker's format rows, without the caller's own sentinel rows.
101
+ */
102
+ export function creationFormats(kind?: "page" | "component" | "content"): FormatChoiceRow[] {
103
+ const rows: FormatChoiceRow[] = [{ ext: ".json", label: "JSON (.json)" }];
104
+ for (const ext of documentExtensions(kind)) {
105
+ const reader = formatByExtension(ext, "parse");
106
+ if (reader && formatByExtension(ext, "serialize")) {
107
+ rows.push({ ext, label: `${reader.name} (${ext})` });
108
+ }
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ /**
114
+ * Every extension the studio recognises as a document — `.json` plus everything any registered
115
+ * format claims, whether or not it is creatable.
116
+ *
117
+ * This is the set a name is tested against, not {@link creationFormats}: a typed `notes.csv` must be
118
+ * recognised as naming a format the picker did not offer, rather than treated as an unremarkable
119
+ * suffix.
120
+ */
121
+ export function knownDocumentExtensions(): Set<string> {
122
+ return new Set([".json", ...getFormats().flatMap((format) => format.extensions)]);
123
+ }
124
+
125
+ /**
126
+ * The formats this file may be converted INTO, or an empty list when it may not be converted at
127
+ * all.
128
+ *
129
+ * Empty is the answer for, and only for:
130
+ *
131
+ * - A file outside `pages/` and `components/` — including every `.json` that is configuration rather
132
+ * than a document, and every `layouts/` file;
133
+ * - The project's declared `layout`, wherever it lives;
134
+ * - Anything inside a content collection, in EITHER direction. Converting an entry drops it out of
135
+ * its collection's discovery glob; converting a co-located file INTO the collection's format
136
+ * enlists it as an entry nobody seeded. Source and target share a directory, so one test covers
137
+ * both;
138
+ * - A source whose format cannot `parse`, or declares no document kind (Csv);
139
+ * - A source for which no OTHER format can `serialize` a document.
140
+ *
141
+ * @param path - Project-relative file path.
142
+ * @returns The target rows, or `[]`.
143
+ */
144
+ export function convertTargets(path: string | null | undefined): FormatChoiceRow[] {
145
+ if (path === null || path === undefined || path === "") {
146
+ return [];
147
+ }
148
+ const root = documentRootOf(path);
149
+ if (root === null || isLayoutPath(path) || collectionForFile(path)) {
150
+ return [];
151
+ }
152
+
153
+ const source = formatForPath(path);
154
+ const readable = source
155
+ ? Boolean(source.capabilities.parse) && declaresDocumentKind(source.documentKinds)
156
+ : normalize(path).toLowerCase().endsWith(".json");
157
+ if (!readable) {
158
+ return [];
159
+ }
160
+
161
+ const rows: FormatChoiceRow[] = [];
162
+ if (source) {
163
+ rows.push({ ext: ".json", label: "JSON (.json)" });
164
+ }
165
+ for (const ext of documentExtensions()) {
166
+ const writer = formatByExtension(ext, "serialize");
167
+ if (
168
+ !writer ||
169
+ writer.name === source?.name ||
170
+ !declaresDocumentKind(writer.documentKinds) ||
171
+ !writer.documentKinds.includes(root)
172
+ ) {
173
+ continue;
174
+ }
175
+ rows.push({ ext, label: `${writer.name} (${ext})` });
176
+ }
177
+ return rows;
178
+ }
179
+
180
+ /**
181
+ * Every extension that is a conversion target SOMEWHERE in this project — the enum behind
182
+ * `file.convertFormat`'s `format` argument.
183
+ *
184
+ * Necessarily broader than {@link convertTargets}, which is answered per file: a command argument's
185
+ * declared values cannot depend on another argument. The per-file rule still decides, and
186
+ * `convertFile` re-checks it, so this list is what may be ASKED for and not what will be accepted.
187
+ */
188
+ export function convertTargetExtensions(): string[] {
189
+ const exts = new Set<string>([".json"]);
190
+ for (const format of getFormats()) {
191
+ if (!format.capabilities.serialize || !declaresDocumentKind(format.documentKinds)) {
192
+ continue;
193
+ }
194
+ for (const ext of format.extensions) {
195
+ exts.add(ext);
196
+ }
197
+ }
198
+ return [...exts].toSorted();
199
+ }
@@ -228,13 +228,27 @@ export function createGridView(host: HTMLElement, controller: GridController): G
228
228
  * non-empty table an initial range, and that range's outline is a rendered element — so until it
229
229
  * exists the picture is still changing. An empty table is exempt, because it never gets one and
230
230
  * waiting for it would be waiting forever.
231
+ *
232
+ * The MODEL is not the picture, and that distinction is the whole bug this second clause fixes.
233
+ * `getRanges()` answers from Tabulator's range model, which is populated when the range is
234
+ * created; the outline is a `.tabulator-range` div the SelectRange module paints into
235
+ * `.tabulator-range-overlay` on a later frame. Between the two, the grid reports itself settled
236
+ * and the capture lands on a table whose selected cell has no outline — so `data-grid.png`
237
+ * alternated between an outlined "Elena M." and a bare one, 16 rewrites in ten days at a steady
238
+ * 0.089 %. Asking the DOM for a laid-out box closes the window that the model check leaves open.
231
239
  */
232
240
  const probe = (): string | null => {
233
241
  if (!built) {
234
242
  return `grid[${gridId}]: building`;
235
243
  }
236
- if (controller.effectiveRows().length > 0 && table.getRanges().length === 0) {
237
- return `grid[${gridId}]: selection range not laid out`;
244
+ if (controller.effectiveRows().length > 0) {
245
+ if (table.getRanges().length === 0) {
246
+ return `grid[${gridId}]: selection range not laid out`;
247
+ }
248
+ const outline = host.querySelector(".tabulator-range-overlay .tabulator-range");
249
+ if (!outline || rectOf(outline).width === 0) {
250
+ return `grid[${gridId}]: selection range outline not painted`;
251
+ }
238
252
  }
239
253
  return null;
240
254
  };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Open a media file in the viewer.
3
+ *
4
+ * The shape of `grid/grid-open.ts`'s `openCsvGridTab`, and for the same reason: a real file tab
5
+ * keyed by its path, with a stub document, because the tab model wants A DOCUMENT and this file is
6
+ * not one. The viewer never reads it — it reads `tab.documentPath` and shows the bytes at that path
7
+ * through an `<img>`, a `<video>` or a font specimen.
8
+ *
9
+ * `.svg` gets a `source` mode beside the viewer: it is the one media format that is also text, and
10
+ * hand-editing one is an ordinary thing to want.
11
+ *
12
+ * The modes are set HERE rather than in `tabs/tab.ts`'s `inferModes`, which is the same division
13
+ * CSV already lives under: `inferModes` answers for DOCUMENTS, from the format registry, and an
14
+ * opener answers for the files that are not documents. Every media tab is created through this
15
+ * function — `openFileInTab` routes to it, and session restore goes through `openFileInTab` — so
16
+ * there is no path by which one is built without them.
17
+ *
18
+ * @docs studio/projects/media
19
+ */
20
+
21
+ import { activateTab, openTab, workspace } from "../workspace/workspace";
22
+ import { extensionOf, isMediaFile } from "../files/media-upload";
23
+ import { MEDIA_MODE } from "./media-pane";
24
+ import type { Tab } from "../tabs/tab";
25
+
26
+ /** Placeholder document for a media tab — the viewer never reads it, and there is nothing to save. */
27
+ const MEDIA_STUB_DOCUMENT = { children: [], tagName: "div" };
28
+
29
+ /** Whether this path opens in the media viewer rather than through a document parse. */
30
+ export function isViewableMedia(path: string): boolean {
31
+ return isMediaFile(path);
32
+ }
33
+
34
+ /** The modes a media tab offers. SVG is also text, so it keeps a source alternate. */
35
+ export function mediaTabModes(path: string): string[] {
36
+ return extensionOf(path) === ".svg" ? [MEDIA_MODE, "source"] : [MEDIA_MODE];
37
+ }
38
+
39
+ /**
40
+ * Open (or activate) a media file's viewer tab.
41
+ *
42
+ * @param {string} path - Project-relative path of the file
43
+ * @returns {Tab}
44
+ */
45
+ export function openMediaTab(path: string): Tab {
46
+ const existing = workspace.tabs.get(path);
47
+ if (existing) {
48
+ activateTab(path);
49
+ return existing;
50
+ }
51
+ return openTab({
52
+ capabilities: { modes: mediaTabModes(path) },
53
+ document: structuredClone(MEDIA_STUB_DOCUMENT),
54
+ documentPath: path,
55
+ id: path,
56
+ sourceFormat: null,
57
+ });
58
+ }
@@ -0,0 +1,310 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * The Media viewer — a pane that SHOWS a file the studio has no document model for.
4
+ *
5
+ * Clicking an image in the Files tree used to end in a toast: `openFileInTab` reads every file as
6
+ * text, finds no format class and no `.json`, and throws — so a PNG produced _"No format class
7
+ * imported for public/hero.jpg — add one to project.json imports"_, advice that means nothing about
8
+ * a binary asset. Video, audio, fonts and PDFs all failed the same way, and so did every tile in
9
+ * the Library, which routes to the same function. There was no way at all to look at an asset the
10
+ * project contains, which is exactly what somebody who has just imported a site needs to do.
11
+ *
12
+ * Almost nothing here is new. `files/media-paths.ts` already computes a src the parent realm can
13
+ * load, `files/media-meta.ts` already holds size, modified time and — once an `<img>` has loaded —
14
+ * pixel dimensions, and `files/media-usage.ts` already answers "which pages use this?" honestly.
15
+ * That last one had, in `site-architecture.md` §9.4's own words, _no reader but the delete
16
+ * confirmation_: you could only learn what an image was used for by trying to remove it. This is
17
+ * the surface that query was waiting for.
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.
21
+ *
22
+ * @docs studio/projects/media
23
+ */
24
+
25
+ import { html, nothing, render as litRender } from "lit-html";
26
+ import { effect, effectScope } from "../reactivity";
27
+ import { paneRegion } from "../ui/regions";
28
+ import { mediaSiteUrl, previewFileSrc } from "../files/media-paths";
29
+ import { formatBytes, loadMediaMeta, peekMediaMeta, recordImageSize } from "../files/media-meta";
30
+ import { extensionOf, mediaKind } from "../files/media-upload";
31
+ import { loadMediaUsages, mediaUsageHeadline, peekMediaUsages } from "../files/media-usage";
32
+ import type { MediaMeta } from "../files/media-meta";
33
+ import type { UsageState } from "../services/references";
34
+ import type { Tab } from "../tabs/tab";
35
+ import type { CanvasSurface } from "../canvas/canvas-surface";
36
+
37
+ /** The `canvasMode` the media viewer draws under. */
38
+ export const MEDIA_MODE = "media";
39
+
40
+ /** Extensions the viewer renders as a font specimen rather than as a file it cannot show. */
41
+ const FONT_EXTENSIONS = new Set([".woff", ".woff2", ".ttf", ".otf"]);
42
+
43
+ /** The pangram the font specimen is set in — short, and every letter of the alphabet once. */
44
+ const SPECIMEN = "The quick brown fox jumps over the lazy dog";
45
+
46
+ /** Specimen sizes, largest first: enough to judge a display face and a body face in one look. */
47
+ const SPECIMEN_SIZES = [48, 32, 24, 18, 14];
48
+
49
+ // ─── Mounting ────────────────────────────────────────────────────────────────
50
+
51
+ interface ActiveMediaPane {
52
+ paneId: string;
53
+ tabId: string;
54
+ wrap: HTMLElement;
55
+ scope: { stop: () => void; run: <T>(fn: () => T) => T | undefined };
56
+ }
57
+
58
+ /**
59
+ * The viewer mounted in each pane, keyed by pane id.
60
+ *
61
+ * Per pane rather than a singleton, for the reason `content/entry-editor.ts` documents: `media` is
62
+ * a kind the side pane may host, so two panes can hold one at once and a shared slot would let the
63
+ * second mount stop the first one's effect scope.
64
+ */
65
+ const _active = new Map<string, ActiveMediaPane>();
66
+
67
+ function activeIn(paneId: string): ActiveMediaPane | null {
68
+ return _active.get(paneId) ?? null;
69
+ }
70
+
71
+ /** Whether this tab's viewer is already mounted in this pane and still in the document. */
72
+ export function mediaPaneMounted(paneId: string, tab: Tab): boolean {
73
+ const panel = activeIn(paneId);
74
+ return panel !== null && panel.tabId === tab.id && panel.wrap.isConnected;
75
+ }
76
+
77
+ /** Tear one pane's viewer down (mode change, tab switch, project close). Idempotent. */
78
+ export function detachMediaPane(paneId: string): void {
79
+ const panel = _active.get(paneId);
80
+ if (!panel) {
81
+ return;
82
+ }
83
+ panel.scope.stop();
84
+ _active.delete(paneId);
85
+ }
86
+
87
+ // ─── Templates ───────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * The asset itself, in whatever element can show it.
91
+ *
92
+ * A file the browser cannot render is not a failure state — it is a font, or a zip, or a format
93
+ * this build has never heard of — so the fallback says what the file IS rather than apologising.
94
+ */
95
+ function assetTpl(path: string, src: string, redraw: () => void) {
96
+ const ext = extensionOf(path);
97
+ const kind = mediaKind({ name: path });
98
+
99
+ if (FONT_EXTENSIONS.has(ext)) {
100
+ /* A font is shown by being USED. The @font-face is scoped to a generated family name so two
101
+ specimens open side by side cannot claim the same one. */
102
+ const family = `jx-specimen-${path.replaceAll(/[^a-zA-Z0-9]/g, "-")}`;
103
+ return html`
104
+ <style>
105
+ @font-face {
106
+ font-family: "${family}";
107
+ src: url("${src}");
108
+ }
109
+ </style>
110
+ <div class="media-specimen" style="font-family: '${family}', system-ui, sans-serif">
111
+ ${SPECIMEN_SIZES.map(
112
+ (size) => html`<p class="media-specimen-line" style="font-size: ${size}px">
113
+ ${SPECIMEN}
114
+ </p>`,
115
+ )}
116
+ </div>
117
+ `;
118
+ }
119
+
120
+ switch (kind) {
121
+ case "image": {
122
+ return html`<img
123
+ class="media-image"
124
+ src=${src}
125
+ alt=""
126
+ @load=${(e: Event) => {
127
+ const img = e.target as HTMLImageElement;
128
+ /* The only honest source of pixel dimensions in a browser is an image that has loaded,
129
+ and this viewer is showing one at full size anyway. `recordImageSize` reports whether
130
+ the number was new, so a repaint happens once rather than on every load event. */
131
+ if (recordImageSize(path, img.naturalWidth, img.naturalHeight)) {
132
+ redraw();
133
+ }
134
+ }}
135
+ />`;
136
+ }
137
+ case "video": {
138
+ return html`<video class="media-video" src=${src} controls></video>`;
139
+ }
140
+ case "audio": {
141
+ return html`<audio class="media-audio" src=${src} controls></audio>`;
142
+ }
143
+ default: {
144
+ return ext === ".pdf"
145
+ ? html`<embed class="media-embed" src=${src} type="application/pdf" />`
146
+ : html`<div class="media-unviewable">
147
+ <p>Jx has no viewer for <code>${ext || "this file"}</code>.</p>
148
+ <p class="media-note">The file is in the project and builds normally.</p>
149
+ </div>`;
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Kind, dimensions, size and modified time — each omitted rather than zeroed when unknown. */
155
+ function factsTpl(path: string, meta: MediaMeta | null) {
156
+ const facts: string[] = [mediaKind({ name: path })];
157
+ if (meta?.width != null && meta.height != null) {
158
+ facts.push(`${meta.width} × ${meta.height}`);
159
+ }
160
+ if (meta?.bytes != null) {
161
+ facts.push(formatBytes(meta.bytes));
162
+ }
163
+ if (meta?.modified) {
164
+ facts.push(`modified ${meta.modified.slice(0, 10)}`);
165
+ }
166
+ return html`<p class="media-facts">${facts.join(" · ")}</p>`;
167
+ }
168
+
169
+ /**
170
+ * The URL a document would reference this file by, with a copy button.
171
+ *
172
+ * The single most useful fact about an asset that is not the asset: `public/hero.jpg` is written
173
+ * `/hero.jpg`, a string that shares not one path segment with the file, and getting it wrong is the
174
+ * commonest way an image ends up missing from a page.
175
+ */
176
+ function refTpl(path: string) {
177
+ const ref = mediaSiteUrl(path);
178
+ return html`
179
+ <div class="media-ref">
180
+ <code class="media-ref-value">${ref}</code>
181
+ <sp-action-button
182
+ size="s"
183
+ quiet
184
+ title="Copy the reference"
185
+ @click=${() => {
186
+ void navigator.clipboard?.writeText(ref);
187
+ }}
188
+ >Copy</sp-action-button
189
+ >
190
+ </div>
191
+ `;
192
+ }
193
+
194
+ /** Which documents reference this file, or the honest reason there is no list. */
195
+ function usageTpl(usage: UsageState | null) {
196
+ const headline = mediaUsageHeadline(usage);
197
+ if (headline === null) {
198
+ // The host has no reference index. A zero here would be a number nobody can stand behind.
199
+ return nothing;
200
+ }
201
+ const files = usage?.status === "ready" ? usage.result.files : [];
202
+ return html`
203
+ <div class="media-usage">
204
+ <h4 class="media-usage-title">Used by</h4>
205
+ <p class="media-usage-headline">${headline}</p>
206
+ ${
207
+ files.length === 0
208
+ ? nothing
209
+ : html`<ul class="media-usage-list">
210
+ ${files.map(
211
+ (file) => html`<li>
212
+ <button
213
+ type="button"
214
+ class="media-usage-link"
215
+ @click=${() => {
216
+ void openDocument(file.path);
217
+ }}
218
+ >
219
+ ${file.path}
220
+ </button>
221
+ <span class="media-usage-count">${file.count}</span>
222
+ </li>`,
223
+ )}
224
+ </ul>`
225
+ }
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ /**
231
+ * Open a document that references this file.
232
+ *
233
+ * Dynamic, for the reason `content/entry-editor.ts` gives: `files/files.ts` reaches the platform,
234
+ * the format registry and the packages layer, and a static edge would drag all of it in here.
235
+ */
236
+ async function openDocument(path: string): Promise<void> {
237
+ const { openFileInTab } = await import("../files/files");
238
+ await openFileInTab(path);
239
+ }
240
+
241
+ // ─── Render ──────────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Mount the media viewer into the pane.
245
+ *
246
+ * The same non-iframe-editor pattern as the grid, the Library, Project Settings and the Entry form:
247
+ * it owns its own effect scope from here, so learning an image's dimensions repaints this panel and
248
+ * nothing else.
249
+ *
250
+ * @param {CanvasSurface} surface
251
+ * @param {Tab} tab
252
+ */
253
+ export function renderMediaMode(surface: CanvasSurface, tab: Tab): void {
254
+ const { paneId, wrap: canvasWrap } = surface;
255
+ if (mediaPaneMounted(paneId, tab)) {
256
+ return;
257
+ }
258
+ detachMediaPane(paneId);
259
+
260
+ const scope = effectScope();
261
+ const panel: ActiveMediaPane = { paneId, scope, tabId: tab.id, wrap: canvasWrap };
262
+ _active.set(paneId, panel);
263
+
264
+ const path = tab.documentPath ?? "";
265
+
266
+ const redraw = () => {
267
+ if (activeIn(paneId) === panel) {
268
+ draw();
269
+ }
270
+ };
271
+
272
+ const draw = () => {
273
+ const meta = peekMediaMeta(path);
274
+ const usage = peekMediaUsages(path);
275
+ /* Asked on the first paint that finds it missing, never on every paint: both loaders cache and
276
+ de-duplicate in flight, and both repaint through `redraw` when they land. */
277
+ if (meta === null) {
278
+ void loadMediaMeta(path).then(redraw);
279
+ }
280
+ if (usage === null || usage.status === "pending") {
281
+ void loadMediaUsages(path).then(redraw);
282
+ }
283
+
284
+ litRender(
285
+ html`
286
+ <div class="media-viewer" data-jx-region=${paneRegion(paneId, "media")}>
287
+ <div class="media-stage">${assetTpl(path, previewFileSrc(path), redraw)}</div>
288
+ <div class="media-details">
289
+ <h3 class="media-name">${path.split("/").pop()}</h3>
290
+ ${factsTpl(path, meta)} ${refTpl(path)} ${usageTpl(usage)}
291
+ </div>
292
+ </div>
293
+ `,
294
+ canvasWrap,
295
+ );
296
+ };
297
+
298
+ scope.run(() => {
299
+ effect(() => {
300
+ if (activeIn(paneId) !== panel) {
301
+ return;
302
+ }
303
+ /* The tab's path is the whole of what this pane draws from — the file's bytes are the
304
+ browser's problem, and its metadata repaints through `redraw`. Reading it here is what
305
+ makes a rename repaint the viewer rather than leave it pointing at a name that is gone. */
306
+ void tab.documentPath;
307
+ draw();
308
+ });
309
+ });
310
+ }