@jxsuite/studio 0.1.0 → 0.5.1

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 (40) hide show
  1. package/dist/studio.js +50941 -34749
  2. package/dist/studio.js.map +461 -345
  3. package/package.json +46 -35
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +133 -0
  16. package/src/panels/right-panel.js +130 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/statusbar.js +15 -1
  20. package/src/panels/toolbar.js +223 -0
  21. package/src/platforms/devserver.js +58 -16
  22. package/src/settings/collections-editor.js +428 -0
  23. package/src/settings/defs-editor.js +418 -0
  24. package/src/settings/schema-field-ui.js +329 -0
  25. package/src/state.js +99 -2
  26. package/src/store.js +112 -41
  27. package/src/studio.js +1551 -1565
  28. package/src/ui/button-group.js +91 -0
  29. package/src/ui/color-selector.js +299 -0
  30. package/src/ui/field-row.js +47 -0
  31. package/src/ui/media-picker.js +172 -0
  32. package/src/ui/panel-resize.js +96 -0
  33. package/src/ui/spectrum.js +36 -2
  34. package/src/ui/unit-selector.js +106 -0
  35. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  36. package/src/ui/widgets.js +106 -0
  37. package/src/utils/canvas-media.js +151 -0
  38. package/src/utils/inherited-style.js +54 -0
  39. package/src/utils/studio-utils.js +32 -0
  40. package/src/view.js +68 -0
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.1.0",
3
+ "version": "0.5.1",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jxsuite/jx.git",
9
+ "directory": "packages/studio"
10
+ },
6
11
  "files": [
7
12
  "*.js",
8
13
  "src",
@@ -10,45 +15,51 @@
10
15
  ],
11
16
  "type": "module",
12
17
  "exports": {
13
- ".": "./studio.js",
14
- "./platform.js": "./platform.js"
18
+ ".": "./src/studio.js",
19
+ "./platform.js": "./src/platform.js"
20
+ },
21
+ "publishConfig": {
22
+ "provenance": true
15
23
  },
16
24
  "scripts": {
17
25
  "build": "bun build ./src/studio.js --outdir dist --target browser --sourcemap=linked",
18
26
  "gen:webdata": "bun run scripts/gen-webdata.js",
19
- "upgrade": "bunx npm-check-updates -u && bun install",
20
- "test": "bun test"
27
+ "test": "bun test",
28
+ "upgrade": "bunx npm-check-updates -u && bun install"
21
29
  },
22
30
  "dependencies": {
23
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
24
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
25
- "@jxsuite/runtime": "0.0.1",
26
- "@spectrum-web-components/accordion": "^1.11.2",
27
- "@spectrum-web-components/action-bar": "1.11.2",
28
- "@spectrum-web-components/action-button": "^1.11.2",
29
- "@spectrum-web-components/action-group": "^1.11.2",
30
- "@spectrum-web-components/checkbox": "^1.11.2",
31
- "@spectrum-web-components/color-area": "^1.11.2",
32
- "@spectrum-web-components/color-handle": "^1.11.2",
33
- "@spectrum-web-components/color-slider": "^1.11.2",
34
- "@spectrum-web-components/combobox": "^1.11.2",
35
- "@spectrum-web-components/divider": "^1.11.2",
36
- "@spectrum-web-components/icon": "^1.11.2",
37
- "@spectrum-web-components/icons-workflow": "^1.11.2",
38
- "@spectrum-web-components/menu": "^1.11.2",
39
- "@spectrum-web-components/number-field": "^1.11.2",
40
- "@spectrum-web-components/overlay": "^1.11.2",
41
- "@spectrum-web-components/picker": "^1.11.2",
42
- "@spectrum-web-components/picker-button": "^1.11.2",
43
- "@spectrum-web-components/popover": "^1.11.2",
44
- "@spectrum-web-components/search": "^1.11.2",
45
- "@spectrum-web-components/swatch": "^1.11.2",
46
- "@spectrum-web-components/switch": "^1.11.2",
47
- "@spectrum-web-components/tabs": "^1.11.2",
48
- "@spectrum-web-components/textfield": "^1.11.2",
49
- "@spectrum-web-components/theme": "^1.11.2",
50
- "@spectrum-web-components/tooltip": "^1.11.2",
51
- "lit-html": "^3.3.2",
33
+ "@jxsuite/runtime": "workspace:*",
34
+ "@spectrum-web-components/accordion": "^1.12.0",
35
+ "@spectrum-web-components/action-bar": "1.12.0",
36
+ "@spectrum-web-components/action-button": "^1.12.0",
37
+ "@spectrum-web-components/action-group": "^1.12.0",
38
+ "@spectrum-web-components/checkbox": "^1.12.0",
39
+ "@spectrum-web-components/color-area": "^1.12.0",
40
+ "@spectrum-web-components/color-handle": "^1.12.0",
41
+ "@spectrum-web-components/color-slider": "^1.12.0",
42
+ "@spectrum-web-components/combobox": "^1.12.0",
43
+ "@spectrum-web-components/dialog": "^1.12.0",
44
+ "@spectrum-web-components/divider": "^1.12.0",
45
+ "@spectrum-web-components/icon": "^1.12.0",
46
+ "@spectrum-web-components/icons-workflow": "^1.12.0",
47
+ "@spectrum-web-components/menu": "^1.12.0",
48
+ "@spectrum-web-components/number-field": "^1.12.0",
49
+ "@spectrum-web-components/overlay": "^1.12.0",
50
+ "@spectrum-web-components/overlay-trigger": "^0.1.7",
51
+ "@spectrum-web-components/picker": "^1.12.0",
52
+ "@spectrum-web-components/picker-button": "^1.12.0",
53
+ "@spectrum-web-components/popover": "^1.12.0",
54
+ "@spectrum-web-components/search": "^1.12.0",
55
+ "@spectrum-web-components/swatch": "^1.12.0",
56
+ "@spectrum-web-components/switch": "^1.12.0",
57
+ "@spectrum-web-components/table": "^1.12.0",
58
+ "@spectrum-web-components/tabs": "^1.12.0",
59
+ "@spectrum-web-components/textfield": "^1.12.0",
60
+ "@spectrum-web-components/theme": "^1.12.0",
61
+ "@spectrum-web-components/tooltip": "^1.12.0",
62
+ "lit-html": "^3.3.3",
52
63
  "monaco-editor": "^0.55.1",
53
64
  "remark-directive": "^4.0.0",
54
65
  "remark-frontmatter": "^5.0.0",
@@ -56,12 +67,12 @@
56
67
  "remark-parse": "^11.0.0",
57
68
  "remark-stringify": "^11.0.0",
58
69
  "unified": "^11.0.5",
59
- "yaml": "^2.8.3"
70
+ "yaml": "^2.9.0"
60
71
  },
61
72
  "devDependencies": {
62
73
  "@happy-dom/global-registrator": "^20.9.0",
63
- "@webref/css": "^8.5.3",
74
+ "@webref/css": "^8.5.6",
64
75
  "@webref/elements": "^2.7.1",
65
- "@webref/idl": "^3.75.3"
76
+ "@webref/idl": "^3.78.0"
66
77
  }
67
78
  }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Manage view — project-level file browser rendered as a Spectrum table.
3
+ *
4
+ * Displays pages, layouts, components, content, and media in a filterable table grid. Fills the
5
+ * center canvas area as a parallel state to Edit/Design/Preview/Code/Settings. Includes a "New +"
6
+ * button with type-aware entity creation (including collections from project.json).
7
+ */
8
+
9
+ import { html, render as litRender } from "lit-html";
10
+ import { getPlatform } from "../platform.js";
11
+ import { projectState } from "../store.js";
12
+ import { yamlDefault } from "../settings/schema-field-ui.js";
13
+ import { invalidateMediaCache } from "../ui/media-picker.js";
14
+
15
+ // ─── Category definitions ────────────────────────────────────────────────────
16
+
17
+ const CATEGORIES = [
18
+ { key: "all", label: "All" },
19
+ { key: "pages", label: "Pages", dir: "pages" },
20
+ { key: "layouts", label: "Layouts", dir: "layouts" },
21
+ { key: "components", label: "Components", dir: "components" },
22
+ { key: "content", label: "Content", dir: "content" },
23
+ { key: "media", label: "Media", dir: "public" },
24
+ ];
25
+
26
+ const MEDIA_EXTENSIONS = new Set([
27
+ ".jpg",
28
+ ".jpeg",
29
+ ".png",
30
+ ".gif",
31
+ ".svg",
32
+ ".webp",
33
+ ".avif",
34
+ ".ico",
35
+ ".mp4",
36
+ ".webm",
37
+ ".mp3",
38
+ ".wav",
39
+ ".ogg",
40
+ ".pdf",
41
+ ".woff",
42
+ ".woff2",
43
+ ".ttf",
44
+ ".otf",
45
+ ]);
46
+
47
+ // ─── Module state ────────────────────────────────────────────────────────────
48
+
49
+ let activeCategory = "all";
50
+ let searchQuery = "";
51
+ /** @type {{ name: string; path: string; type: string; category: string; ext: string }[]} */
52
+ let fileCache = [];
53
+ let loading = false;
54
+ /** Track which projectDirs were used for the last load, so we re-scan when they change. */
55
+ let lastProjectDirsKey = "";
56
+
57
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
58
+
59
+ /** @param {string} name */
60
+ function extOf(name) {
61
+ const dot = name.lastIndexOf(".");
62
+ return dot > 0 ? name.slice(dot).toLowerCase() : "";
63
+ }
64
+
65
+ /** Map a file path to a display category. Media files override by extension. */
66
+ function categoryFor(/** @type {string} */ dir, /** @type {string} */ ext) {
67
+ if (ext && MEDIA_EXTENSIONS.has(ext)) return "Media";
68
+ if (dir.startsWith("pages")) return "Pages";
69
+ if (dir.startsWith("layouts")) return "Layouts";
70
+ if (dir.startsWith("components")) return "Components";
71
+ if (dir.startsWith("content")) return "Content";
72
+ if (dir.startsWith("public")) return "Media";
73
+ if (dir.startsWith("data")) return "Content";
74
+ if (dir.startsWith("styles")) return "Components";
75
+ return "Other";
76
+ }
77
+
78
+ /**
79
+ * Recursively collect all files under a directory.
80
+ *
81
+ * @param {string} dir
82
+ * @param {ReturnType<typeof getPlatform>} platform
83
+ * @returns {Promise<
84
+ * { name: string; path: string; type: string; category: string; ext: string }[]
85
+ * >}
86
+ */
87
+ async function collectFiles(dir, platform) {
88
+ /** @type {{ name: string; path: string; type: string; category: string; ext: string }[]} */
89
+ const results = [];
90
+ try {
91
+ const entries = await platform.listDirectory(dir);
92
+ for (const entry of entries) {
93
+ if (entry.type === "directory") {
94
+ const sub = await collectFiles(entry.path, platform);
95
+ results.push(...sub);
96
+ } else {
97
+ const ext = extOf(entry.name);
98
+ results.push({
99
+ name: entry.name,
100
+ path: entry.path,
101
+ type: ext || "file",
102
+ category: categoryFor(entry.path, ext),
103
+ ext,
104
+ });
105
+ }
106
+ }
107
+ } catch {
108
+ // Directory may not exist or be inaccessible
109
+ }
110
+ return results;
111
+ }
112
+
113
+ // ─── Data loading ────────────────────────────────────────────────────────────
114
+
115
+ async function loadFiles() {
116
+ if (!projectState) return;
117
+ loading = true;
118
+ const platform = getPlatform();
119
+ const dirs = projectState.projectDirs || [];
120
+ lastProjectDirsKey = dirs.join(",");
121
+ const all = await Promise.all(dirs.map((/** @type {string} */ d) => collectFiles(d, platform)));
122
+ fileCache = all.flat();
123
+ fileCache.sort((a, b) => a.path.localeCompare(b.path));
124
+ loading = false;
125
+ }
126
+
127
+ // ─── Filtering ───────────────────────────────────────────────────────────────
128
+
129
+ function filteredFiles() {
130
+ let files = fileCache;
131
+ if (activeCategory !== "all") {
132
+ const cat = CATEGORIES.find((c) => c.key === activeCategory);
133
+ if (cat && cat.label) {
134
+ files = files.filter((f) => f.category === cat.label);
135
+ }
136
+ }
137
+ if (searchQuery) {
138
+ const q = searchQuery.toLowerCase();
139
+ files = files.filter(
140
+ (f) => f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q),
141
+ );
142
+ }
143
+ return files;
144
+ }
145
+
146
+ // ─── Entity types for "New +" button ────────────────────────────────────────
147
+
148
+ const ENTITY_TYPES = [
149
+ { key: "page", label: "Page", dir: "pages", ext: ".md" },
150
+ { key: "layout", label: "Layout", dir: "layouts", ext: ".json" },
151
+ { key: "component", label: "Component", dir: "components", ext: ".json" },
152
+ { key: "content", label: "Content", dir: "content", ext: ".md" },
153
+ ];
154
+
155
+ /**
156
+ * Build frontmatter YAML from a collection's schema properties.
157
+ *
158
+ * @param {string} collectionName
159
+ * @returns {string}
160
+ */
161
+ function buildFrontmatterYaml(collectionName) {
162
+ const config = projectState?.projectConfig;
163
+ const col = config?.collections?.[collectionName];
164
+ if (!col?.schema?.properties) return "title: Untitled\n";
165
+
166
+ let yaml = "";
167
+ for (const [field, def] of Object.entries(col.schema.properties)) {
168
+ const d = /** @type {any} */ (def);
169
+ yaml += `${field}: ${yamlDefault(d.type, d.format)}\n`;
170
+ }
171
+ return yaml || "title: Untitled\n";
172
+ }
173
+
174
+ /**
175
+ * Get collection-derived entity types from project config.
176
+ *
177
+ * @returns {{ key: string; label: string; dir: string; ext: string; collectionName: string }[]}
178
+ */
179
+ function getCollectionTypes() {
180
+ const config = projectState?.projectConfig;
181
+ if (!config?.collections) return [];
182
+ return Object.entries(config.collections).map(([name, def]) => {
183
+ const d = /** @type {any} */ (def);
184
+ const dir = d.source ? d.source.replace(/^\.\//, "").split("/")[0] : name;
185
+ return {
186
+ key: `collection:${name}`,
187
+ label: name.charAt(0).toUpperCase() + name.slice(1),
188
+ dir,
189
+ ext: ".md",
190
+ collectionName: name,
191
+ };
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Handle creation of a new entity.
197
+ *
198
+ * @param {string} typeKey
199
+ * @param {HTMLElement} container
200
+ * @param {{ openFile: (path: string) => void }} ctx
201
+ */
202
+ async function handleNewEntity(typeKey, container, ctx) {
203
+ const isCollection = typeKey.startsWith("collection:");
204
+ const collectionName = isCollection ? typeKey.slice("collection:".length) : null;
205
+ const allTypes = [...ENTITY_TYPES, ...getCollectionTypes()];
206
+ const typeInfo = allTypes.find((t) => t.key === typeKey);
207
+ if (!typeInfo) return;
208
+
209
+ const name = prompt(`${typeInfo.label} name:`, "untitled");
210
+ if (!name) return;
211
+
212
+ const slug = name
213
+ .toLowerCase()
214
+ .replace(/\s+/g, "-")
215
+ .replace(/[^a-z0-9-]/g, "");
216
+ const filePath = `${typeInfo.dir}/${slug}${typeInfo.ext}`;
217
+
218
+ let content;
219
+ if (typeInfo.ext === ".md") {
220
+ const frontmatter = collectionName ? buildFrontmatterYaml(collectionName) : "title: Untitled\n";
221
+ content = `---\n${frontmatter}---\n\n`;
222
+ } else {
223
+ content = JSON.stringify({ tagName: "div", children: [] }, null, "\t");
224
+ }
225
+
226
+ const platform = getPlatform();
227
+ await platform.writeFile(filePath, content);
228
+ invalidateBrowseCache();
229
+ ctx.openFile(filePath);
230
+ }
231
+
232
+ // ─── Upload ─────────────────────────────────────────────────────────────────
233
+
234
+ const UPLOAD_ACCEPT = [
235
+ "image/*",
236
+ "video/*",
237
+ "audio/*",
238
+ ".pdf",
239
+ ".svg",
240
+ ".woff",
241
+ ".woff2",
242
+ ".ttf",
243
+ ".otf",
244
+ ].join(",");
245
+
246
+ /**
247
+ * Handle file uploads — writes binary files to public/ directory.
248
+ *
249
+ * @param {FileList | File[]} files
250
+ * @param {HTMLElement} container
251
+ * @param {{ openFile: (path: string) => void }} ctx
252
+ */
253
+ async function handleUpload(files, container, ctx) {
254
+ const platform = getPlatform();
255
+ for (const file of files) {
256
+ const destPath = `public/${file.name}`;
257
+ await platform.uploadFile(destPath, file);
258
+ }
259
+ invalidateBrowseCache();
260
+ invalidateMediaCache();
261
+ renderBrowse(container, ctx);
262
+ }
263
+
264
+ // ─── Render ──────────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Render the Browse view into the canvas area.
268
+ *
269
+ * @param {HTMLElement} container — the #canvas-wrap element
270
+ * @param {{ openFile: (path: string) => void }} ctx — callbacks from studio.js
271
+ */
272
+ export async function renderBrowse(container, ctx) {
273
+ // Re-load when projectDirs changed (e.g. project opened after initial render)
274
+ const currentKey = (projectState?.projectDirs || []).join(",");
275
+ if ((!fileCache.length && !loading) || currentKey !== lastProjectDirsKey) {
276
+ await loadFiles();
277
+ }
278
+
279
+ const files = filteredFiles();
280
+
281
+ const collectionTypes = getCollectionTypes();
282
+
283
+ const filterBar = html`
284
+ <div class="browse-filter-bar">
285
+ <sp-action-group selects="single" size="s" compact>
286
+ ${CATEGORIES.map(
287
+ (cat) => html`
288
+ <sp-action-button
289
+ size="s"
290
+ ?selected=${activeCategory === cat.key}
291
+ @click=${() => {
292
+ activeCategory = cat.key;
293
+ renderBrowse(container, ctx);
294
+ }}
295
+ >
296
+ ${cat.label}
297
+ </sp-action-button>
298
+ `,
299
+ )}
300
+ </sp-action-group>
301
+ <sp-search
302
+ size="s"
303
+ placeholder="Filter files..."
304
+ .value=${searchQuery}
305
+ @input=${(/** @type {any} */ e) => {
306
+ searchQuery = e.target.value;
307
+ renderBrowse(container, ctx);
308
+ }}
309
+ @submit=${(/** @type {Event} */ e) => e.preventDefault()}
310
+ ></sp-search>
311
+ <overlay-trigger placement="bottom-start">
312
+ <sp-action-button size="s" slot="trigger">
313
+ <sp-icon-add slot="icon"></sp-icon-add> New
314
+ </sp-action-button>
315
+ <sp-popover slot="click-content" tip>
316
+ <sp-menu
317
+ @change=${(/** @type {any} */ e) => handleNewEntity(e.target.value, container, ctx)}
318
+ >
319
+ ${ENTITY_TYPES.map((t) => html`<sp-menu-item value=${t.key}>${t.label}</sp-menu-item>`)}
320
+ ${collectionTypes.length
321
+ ? html`<sp-menu-divider></sp-menu-divider> ${collectionTypes.map(
322
+ (t) => html`<sp-menu-item value=${t.key}>${t.label}</sp-menu-item>`,
323
+ )}`
324
+ : ""}
325
+ </sp-menu>
326
+ </sp-popover>
327
+ </overlay-trigger>
328
+ <sp-action-button
329
+ size="s"
330
+ @click=${() => {
331
+ const input = /** @type {HTMLInputElement} */ (
332
+ container.querySelector(".browse-upload-input")
333
+ );
334
+ if (input) input.click();
335
+ }}
336
+ >
337
+ <sp-icon-upload slot="icon"></sp-icon-upload> Upload
338
+ </sp-action-button>
339
+ <input
340
+ type="file"
341
+ multiple
342
+ accept=${UPLOAD_ACCEPT}
343
+ class="browse-upload-input"
344
+ style="display:none"
345
+ @change=${(/** @type {any} */ e) => {
346
+ if (e.target.files?.length) handleUpload(e.target.files, container, ctx);
347
+ e.target.value = "";
348
+ }}
349
+ />
350
+ </div>
351
+ `;
352
+
353
+ const table = html`
354
+ <sp-table size="m" quiet>
355
+ <sp-table-head>
356
+ <sp-table-head-cell>Name</sp-table-head-cell>
357
+ <sp-table-head-cell>Category</sp-table-head-cell>
358
+ <sp-table-head-cell>Type</sp-table-head-cell>
359
+ <sp-table-head-cell>Path</sp-table-head-cell>
360
+ </sp-table-head>
361
+ <sp-table-body>
362
+ ${files.length === 0
363
+ ? html`<sp-table-row
364
+ ><sp-table-cell
365
+ >${loading ? "Loading..." : "No files found"}</sp-table-cell
366
+ ></sp-table-row
367
+ >`
368
+ : files.map(
369
+ (f) => html`
370
+ <sp-table-row
371
+ value=${f.path}
372
+ class="browse-row"
373
+ @click=${() => ctx.openFile(f.path)}
374
+ >
375
+ <sp-table-cell class="browse-name-cell">${f.name}</sp-table-cell>
376
+ <sp-table-cell>${f.category}</sp-table-cell>
377
+ <sp-table-cell>${f.ext || "—"}</sp-table-cell>
378
+ <sp-table-cell class="browse-path-cell">${f.path}</sp-table-cell>
379
+ </sp-table-row>
380
+ `,
381
+ )}
382
+ </sp-table-body>
383
+ </sp-table>
384
+ `;
385
+
386
+ const tpl = html`
387
+ <div
388
+ class="browse-view"
389
+ @dragover=${(/** @type {DragEvent} */ e) => {
390
+ e.preventDefault();
391
+ /** @type {HTMLElement} */ (e.currentTarget).classList.add("browse-drop-active");
392
+ }}
393
+ @dragleave=${(/** @type {DragEvent} */ e) => {
394
+ /** @type {HTMLElement} */ (e.currentTarget).classList.remove("browse-drop-active");
395
+ }}
396
+ @drop=${(/** @type {DragEvent} */ e) => {
397
+ e.preventDefault();
398
+ /** @type {HTMLElement} */ (e.currentTarget).classList.remove("browse-drop-active");
399
+ const files = e.dataTransfer?.files;
400
+ if (files?.length) handleUpload(files, container, ctx);
401
+ }}
402
+ >
403
+ ${filterBar}
404
+ <div class="browse-table">${table}</div>
405
+ </div>
406
+ `;
407
+
408
+ litRender(tpl, container);
409
+ }
410
+
411
+ /** Force a data reload on next render (e.g., after file creation/deletion). */
412
+ export function invalidateBrowseCache() {
413
+ fileCache = [];
414
+ }
@@ -6,11 +6,14 @@ import {
6
6
  insertNode,
7
7
  removeNode,
8
8
  duplicateNode,
9
+ wrapNode,
9
10
  getNodeAtPath,
10
11
  parentElementPath,
11
12
  childIndex,
12
13
  } from "../store.js";
13
14
  import { statusMessage } from "../panels/statusbar.js";
15
+ import { convertToComponent } from "./convert-to-component.js";
16
+ import { componentRegistry } from "../files/components.js";
14
17
 
15
18
  /** @type {any} */
16
19
  let clipboard = null;
@@ -68,12 +71,18 @@ document.addEventListener("click", () => {
68
71
  ctxMenu.removeAttribute("open");
69
72
  });
70
73
 
74
+ /** Dismiss the context menu if open. */
75
+ export function dismissContextMenu() {
76
+ ctxMenu.removeAttribute("open");
77
+ }
78
+
71
79
  /**
72
80
  * @param {any} e
73
81
  * @param {any} path
74
82
  * @param {any} S
83
+ * @param {{ onEditComponent?: (path: string) => void }} [opts]
75
84
  */
76
- export function showContextMenu(e, path, S) {
85
+ export function showContextMenu(e, path, S, opts = {}) {
77
86
  e.preventDefault();
78
87
  ctxMenu.removeAttribute("open");
79
88
 
@@ -91,6 +100,44 @@ export function showContextMenu(e, path, S) {
91
100
  items.push({ label: "Cut", action: () => cutNode(S) });
92
101
  items.push({ label: "Duplicate", action: () => update(duplicateNode(S, S.selection)) });
93
102
  items.push({ label: "—" }); // separator
103
+ items.push({
104
+ label: "Insert before",
105
+ action: () => {
106
+ const pp = /** @type {any} */ (parentElementPath(path));
107
+ const idx = /** @type {number} */ (childIndex(path));
108
+ update(insertNode(S, pp, idx, { tagName: "p", children: [] }));
109
+ },
110
+ });
111
+ items.push({
112
+ label: "Insert after",
113
+ action: () => {
114
+ const pp = /** @type {any} */ (parentElementPath(path));
115
+ const idx = /** @type {number} */ (childIndex(path));
116
+ update(insertNode(S, pp, idx + 1, { tagName: "p", children: [] }));
117
+ },
118
+ });
119
+ items.push({
120
+ label: "Wrap in Div",
121
+ action: () => update(wrapNode(S, S.selection)),
122
+ });
123
+ if (node.tagName) {
124
+ const isComponent =
125
+ node.tagName.includes("-") &&
126
+ componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
127
+ if (isComponent && opts.onEditComponent) {
128
+ const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === node.tagName);
129
+ items.push({
130
+ label: "Edit Component",
131
+ action: () => opts.onEditComponent?.(comp.path),
132
+ });
133
+ } else if (!isComponent) {
134
+ items.push({
135
+ label: "Convert to Component",
136
+ action: () => convertToComponent(S),
137
+ });
138
+ }
139
+ }
140
+ items.push({ label: "—" }); // separator
94
141
  items.push({ label: "Delete", action: () => update(removeNode(S, S.selection)), danger: true });
95
142
  }
96
143
  if (clipboard) {