@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.
- package/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
- package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
- package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
- package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
- package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
- package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
- package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +108 -24
- package/dist/studio.js +2362 -1695
- package/package.json +16 -15
- package/src/browse/library-pane.ts +57 -3
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-render.ts +22 -6
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +485 -59
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-view.ts +16 -2
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +332 -160
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +21 -97
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +20 -0
- package/src/platforms/devserver.ts +67 -33
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +45 -4
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +17 -0
- package/src/services/tool-executor.ts +34 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/settings-document.ts +65 -9
- package/src/studio.ts +33 -6
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +103 -5
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/utils/studio-utils.ts +22 -52
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/content-source-7fy9qrm0.js +0 -40
- package/dist/chunks/files-xcybanxe.js +0 -70
- package/dist/chunks/layers-panel-27essgkc.js +0 -60
- package/dist/chunks/publish-panel-09sy2bab.js +0 -22
- package/dist/chunks/quick-search-sefb61v8.js +0 -55
- package/dist/chunks/right-panel-9r32jd5j.js +0 -39
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- 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
|
+
}
|
package/src/grid/grid-view.ts
CHANGED
|
@@ -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
|
|
237
|
-
|
|
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
|
+
}
|