@jxsuite/studio 0.26.2 → 0.28.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 (60) hide show
  1. package/dist/studio.js +34349 -57015
  2. package/dist/studio.js.map +130 -398
  3. package/package.json +5 -11
  4. package/src/browse/browse.ts +62 -22
  5. package/src/canvas/canvas-live-render.ts +57 -55
  6. package/src/canvas/canvas-render.ts +50 -21
  7. package/src/editor/context-menu.ts +4 -1
  8. package/src/editor/convert-to-component.ts +8 -2
  9. package/src/editor/inline-edit.ts +7 -2
  10. package/src/editor/insertion-helper.ts +12 -3
  11. package/src/editor/slash-menu.ts +5 -1
  12. package/src/files/file-ops.ts +121 -102
  13. package/src/files/files.ts +53 -35
  14. package/src/format/constraints.ts +55 -0
  15. package/src/format/format-host.ts +212 -0
  16. package/src/new-project/new-project-modal.ts +18 -3
  17. package/src/panels/ai-panel.ts +3 -1
  18. package/src/panels/block-action-bar.ts +4 -2
  19. package/src/panels/canvas-dnd.ts +78 -28
  20. package/src/panels/data-explorer.ts +9 -2
  21. package/src/panels/dnd.ts +14 -3
  22. package/src/panels/editors.ts +1 -1
  23. package/src/panels/git-panel.ts +18 -8
  24. package/src/panels/head-panel.ts +12 -5
  25. package/src/panels/left-panel.ts +7 -4
  26. package/src/panels/panel-events.ts +3 -1
  27. package/src/panels/properties-panel.ts +9 -3
  28. package/src/panels/quick-search.ts +10 -10
  29. package/src/panels/right-panel.ts +6 -2
  30. package/src/panels/shared.ts +6 -1
  31. package/src/panels/signals-panel.ts +35 -10
  32. package/src/panels/style-inputs.ts +5 -1
  33. package/src/panels/style-panel.ts +6 -2
  34. package/src/panels/stylebook-layers-panel.ts +12 -3
  35. package/src/panels/stylebook-panel.ts +24 -8
  36. package/src/panels/toolbar.ts +16 -3
  37. package/src/platforms/devserver.ts +32 -3
  38. package/src/resize-edges.ts +12 -2
  39. package/src/services/cem-export.ts +9 -3
  40. package/src/settings/content-types-editor.ts +7 -2
  41. package/src/settings/defs-editor.ts +6 -1
  42. package/src/settings/general-settings.ts +3 -1
  43. package/src/settings/schema-field-ui.ts +11 -3
  44. package/src/settings/settings-modal.ts +4 -1
  45. package/src/state.ts +5 -1
  46. package/src/studio.ts +27 -16
  47. package/src/tabs/tab.ts +29 -6
  48. package/src/tabs/transact.ts +9 -2
  49. package/src/types.ts +15 -6
  50. package/src/ui/button-group.ts +6 -1
  51. package/src/ui/expression-editor.ts +14 -3
  52. package/src/ui/layers.ts +5 -1
  53. package/src/ui/media-picker.ts +192 -43
  54. package/src/ui/unit-selector.ts +6 -1
  55. package/src/utils/canvas-media.ts +1 -1
  56. package/src/utils/google-fonts.ts +4 -1
  57. package/src/utils/studio-utils.ts +7 -1
  58. package/src/view.ts +8 -2
  59. package/src/markdown/md-allowlist.ts +0 -104
  60. package/src/markdown/md-convert.ts +0 -846
package/src/studio.ts CHANGED
@@ -40,7 +40,8 @@ import {
40
40
  setStatusbarRenderer,
41
41
  mountStatusbar,
42
42
  } from "./panels/statusbar";
43
- import { loadMarkdown, saveFile, exportFile, serializeDocument } from "./files/file-ops";
43
+ import { parseSourceForPath, saveFile, exportFile, serializeDocument } from "./files/file-ops";
44
+ import { loadFormats, formatForPath, documentExtensions } from "./format/format-host";
44
45
  import {
45
46
  loadProject as _loadProject,
46
47
  openProject as _openProject,
@@ -168,7 +169,7 @@ async function navigateBack() {
168
169
  if (tab.doc.dirty && tab.documentPath) {
169
170
  try {
170
171
  const platform = getPlatform();
171
- await platform.writeFile(tab.documentPath, serializeDocument(tab));
172
+ await platform.writeFile(tab.documentPath, await serializeDocument(tab));
172
173
  } catch (e) {
173
174
  const err = e as Error;
174
175
  statusMessage(`Save error: ${err.message}`);
@@ -198,7 +199,7 @@ async function navigateToLevel(targetIndex: number) {
198
199
  if (tab.doc.dirty && tab.documentPath) {
199
200
  try {
200
201
  const platform = getPlatform();
201
- await platform.writeFile(tab.documentPath, serializeDocument(tab));
202
+ await platform.writeFile(tab.documentPath, await serializeDocument(tab));
202
203
  } catch (e) {
203
204
  const err = e as Error;
204
205
  statusMessage(`Save error: ${err.message}`);
@@ -222,9 +223,8 @@ async function navigateToLevel(targetIndex: number) {
222
223
  async function closeFunctionEditor() {
223
224
  const tab = activeTab.value;
224
225
  const editing =
225
- /** @type {{ type: string; defName?: string; path?: JxPath; eventKey?: string } | null} */ (
226
- tab?.session.ui.editingFunction
227
- );
226
+ /** @type {{ type: string; defName?: string; path?: JxPath; eventKey?: string } | null} */ tab
227
+ ?.session.ui.editingFunction;
228
228
  if (!editing || !tab) return;
229
229
  if (view.functionEditor) {
230
230
  const currentCode = view.functionEditor.getValue();
@@ -237,7 +237,7 @@ async function closeFunctionEditor() {
237
237
  const current = node?.[editing.eventKey as string] || {};
238
238
  transactDoc(tab, (t) =>
239
239
  mutateUpdateProperty(t, editing.path as JxPath, editing.eventKey as string, {
240
- .../** @type {object} */ (current),
240
+ .../** @type {object} */ current,
241
241
  $prototype: "Function",
242
242
  body: bodyToStore,
243
243
  }),
@@ -531,7 +531,12 @@ if (_projectParam) {
531
531
  // When opening project.json, default to home page instead
532
532
  if (fileRelPath === "project.json" || fileRelPath.endsWith("/project.json")) {
533
533
  let opened = false;
534
- for (const candidate of ["pages/index.md", "pages/index.json"]) {
534
+ await loadFormats();
535
+ const homeCandidates = [
536
+ ...documentExtensions("page").map((ext) => `pages/index${ext}`),
537
+ "pages/index.json",
538
+ ];
539
+ for (const candidate of homeCandidates) {
535
540
  try {
536
541
  await platform.readFile(candidate);
537
542
  fileRelPath = candidate;
@@ -544,12 +549,14 @@ if (_projectParam) {
544
549
 
545
550
  const content = await platform.readFile(fileRelPath);
546
551
  if (content) {
547
- let parsedDoc, frontmatter;
548
- const isMd = fileRelPath.endsWith(".md");
549
- if (isMd) {
550
- const result = await loadMarkdown(content);
552
+ let parsedDoc, frontmatter, parsedMode;
553
+ await loadFormats();
554
+ const fileFormat = formatForPath(fileRelPath);
555
+ if (fileFormat) {
556
+ const result = await parseSourceForPath(fileRelPath, content);
551
557
  parsedDoc = result.document;
552
558
  frontmatter = result.frontmatter;
559
+ parsedMode = result.mode;
553
560
  } else {
554
561
  parsedDoc = JSON.parse(content);
555
562
  }
@@ -560,10 +567,10 @@ if (_projectParam) {
560
567
  documentPath: fileRelPath,
561
568
  document: parsedDoc,
562
569
  ...(frontmatter != null && { frontmatter }),
563
- sourceFormat: isMd ? "md" : null,
570
+ sourceFormat: fileFormat?.name ?? null,
564
571
  });
565
572
 
566
- if (isMd && activeTab.value) activeTab.value.doc.mode = "content";
573
+ if (parsedMode === "content" && activeTab.value) activeTab.value.doc.mode = "content";
567
574
  if (fileRelPath === "project.json" && activeTab.value) {
568
575
  activeTab.value.session.ui.canvasMode = "stylebook";
569
576
  }
@@ -671,7 +678,11 @@ async function openRecentProject(root: string) {
671
678
  }
672
679
  }
673
680
  function renderFilesTemplate() {
674
- return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
681
+ return _renderFilesTemplate({
682
+ openProject,
683
+ openFileFromTree,
684
+ renderLeftPanel,
685
+ });
675
686
  }
676
687
  function openFileFromTree(path: string) {
677
688
  return openFileInTab(path);
@@ -718,7 +729,7 @@ function scheduleAutosave() {
718
729
  if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
719
730
  try {
720
731
  const writable = await t.fileHandle.createWritable();
721
- await writable.write(serializeDocument(t));
732
+ await writable.write(await serializeDocument(t));
722
733
  await writable.close();
723
734
  t.doc.dirty = false;
724
735
  statusMessage("Auto-saved");
package/src/tabs/tab.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /// <reference lib="dom" />
2
2
  import { reactive, effectScope } from "../reactivity";
3
+ import { formatByName, formatForPath } from "../format/format-host";
3
4
  import type {
4
5
  GitDiffState,
5
6
  InlineEditDef,
@@ -47,7 +48,11 @@ export interface Tab {
47
48
  documentPath: string | null;
48
49
  fileHandle: FileSystemFileHandle | null;
49
50
  capabilities: { modes: string[] };
50
- scope: { stop(): void; run<T>(fn: () => T): T | undefined; [k: string]: unknown };
51
+ scope: {
52
+ stop(): void;
53
+ run<T>(fn: () => T): T | undefined;
54
+ [k: string]: unknown;
55
+ };
51
56
  doc: {
52
57
  document: JxMutableNode;
53
58
  content: { frontmatter: Record<string, unknown> };
@@ -152,8 +157,7 @@ export function createTab({
152
157
  document,
153
158
  sourceFormat,
154
159
  content: { frontmatter: frontmatter || {} },
155
- mode:
156
- sourceFormat === "md" ? "content" : documentPath?.endsWith(".md") ? "content" : "component",
160
+ mode: inferDocumentMode(documentPath, sourceFormat),
157
161
  handlersSource: null,
158
162
  dirty: false,
159
163
  }),
@@ -163,7 +167,12 @@ export function createTab({
163
167
  clipboard: null,
164
168
  documentStack: [],
165
169
  ui: createDefaultUi(),
166
- canvas: { status: "idle", scope: null, error: null, pendingInlineEdit: null },
170
+ canvas: {
171
+ status: "idle",
172
+ scope: null,
173
+ error: null,
174
+ pendingInlineEdit: null,
175
+ },
167
176
  }),
168
177
  history: reactive({
169
178
  snapshots: [{ document: structuredClone(document), selection: null }],
@@ -181,11 +190,25 @@ export function createTab({
181
190
  */
182
191
  function inferModes(documentPath: string | null | undefined, sourceFormat: string | null) {
183
192
  if (documentPath === "project.json") return ["stylebook", "source"];
184
- if (sourceFormat === "md" || documentPath?.endsWith(".md"))
185
- return ["edit", "design", "preview", "source"];
193
+ const format = formatByName(sourceFormat) ?? formatForPath(documentPath);
194
+ if (format) return format.studio?.modes ?? ["edit", "design", "preview", "source"];
186
195
  return ALL_MODES;
187
196
  }
188
197
 
198
+ /**
199
+ * Document mode for a new tab: format-class documents default to their $studio.documentMode
200
+ * (content unless promoted to component); JSON is component.
201
+ *
202
+ * @param {string | null | undefined} documentPath
203
+ * @param {string | null} sourceFormat
204
+ * @returns {string}
205
+ */
206
+ function inferDocumentMode(documentPath: string | null | undefined, sourceFormat: string | null) {
207
+ const format = formatByName(sourceFormat) ?? formatForPath(documentPath);
208
+ if (format) return format.studio?.documentMode?.default ?? "content";
209
+ return "component";
210
+ }
211
+
189
212
  /**
190
213
  * Dispose a tab — stops its effectScope, killing all effects created within it.
191
214
  *
@@ -100,6 +100,7 @@ export function mutateInsertNode(
100
100
  nodeDef: JxMutableNode,
101
101
  ) {
102
102
  const parent = getNodeAtPath(tab.doc.document, parentPath);
103
+ if (!parent) return;
103
104
  if (!parent.children) parent.children = [];
104
105
  parent.children.splice(index, 0, structuredClone(nodeDef));
105
106
  }
@@ -144,7 +145,10 @@ export function mutateWrapNode(tab: Tab, path: JxPath, wrapperTag: string = "div
144
145
  if (!node) return;
145
146
  const elemPath = parentElementPath(path) as JxPath;
146
147
  const idx = childIndex(path) as number;
147
- const wrapper = { tagName: wrapperTag, children: [structuredClone(toRaw(node))] };
148
+ const wrapper = {
149
+ tagName: wrapperTag,
150
+ children: [structuredClone(toRaw(node))],
151
+ };
148
152
  (getNodeAtPath(tab.doc.document, elemPath).children as JxMutableNode[]).splice(idx, 1, wrapper);
149
153
  tab.session.selection = [...elemPath, "children", idx];
150
154
  }
@@ -159,9 +163,12 @@ export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath,
159
163
  const doc = tab.doc.document;
160
164
  const fromParentPath = parentElementPath(fromPath) as JxPath;
161
165
  const fromIdx = childIndex(fromPath) as number;
166
+ if (!fromParentPath || typeof fromIdx !== "number") return;
162
167
  const fromParent = getNodeAtPath(doc, fromParentPath);
163
- const [node] = (fromParent.children as JxMutableNode[]).splice(fromIdx, 1);
164
168
  const toParent = getNodeAtPath(doc, toParentPath);
169
+ if (!fromParent || !Array.isArray(fromParent.children) || !toParent) return;
170
+ const [node] = (fromParent.children as JxMutableNode[]).splice(fromIdx, 1);
171
+ if (node === undefined) return;
165
172
  if (!toParent.children) toParent.children = [];
166
173
  let adjustedIndex = toIndex;
167
174
  if (fromParent === toParent && fromIdx < toIndex) adjustedIndex--;
package/src/types.ts CHANGED
@@ -67,7 +67,11 @@ export interface StudioPlatform {
67
67
  } | null>;
68
68
  probeRootProject(): Promise<{
69
69
  meta: { root: string; name: string };
70
- info: { isSiteProject: boolean; projectConfig?: ProjectConfig | null; directories?: string[] };
70
+ info: {
71
+ isSiteProject: boolean;
72
+ projectConfig?: ProjectConfig | null;
73
+ directories?: string[];
74
+ };
71
75
  } | null>;
72
76
  listDirectory(dir: string): Promise<DirEntry[]>;
73
77
  readFile(path: string): Promise<string>;
@@ -81,11 +85,17 @@ export interface StudioPlatform {
81
85
  removePackage(name: string): Promise<unknown>;
82
86
  listPackages(): Promise<PackageInfo[]>;
83
87
  codeService(action: string, payload: unknown): Promise<CodeServiceResult | null>;
84
- resolveSiteContext(
85
- filePath: string,
86
- ): Promise<{ sitePath: string | null; projectConfig?: ProjectConfig; fileRelPath?: string }>;
88
+ resolveSiteContext(filePath: string): Promise<{
89
+ sitePath: string | null;
90
+ projectConfig?: ProjectConfig;
91
+ fileRelPath?: string;
92
+ }>;
87
93
  locateFile(name: string): Promise<string | null>;
88
- searchFiles(query: string): Promise<DirEntry[]>;
94
+ searchFiles(query: string, extensions?: string[]): Promise<DirEntry[]>;
95
+ /** List the project's registered format classes (auto-discovered from imports). */
96
+ listFormats?(): Promise<unknown[]>;
97
+ /** Invoke a format capability (parse/serialize) — { format, action, source?, doc?, options? }. */
98
+ formatAction?(payload: Record<string, unknown>): Promise<unknown>;
89
99
  fetchPluginSchema(src: string, prototype?: string, base?: string): Promise<unknown>;
90
100
  gitStatus(): Promise<GitStatusResult>;
91
101
  gitBranches(): Promise<GitBranchesResult>;
@@ -156,7 +166,6 @@ export interface GitDiffState {
156
166
  filePath: string;
157
167
  originalContent: string;
158
168
  currentContent: string;
159
- isMarkdown: boolean;
160
169
  fileStatus: string;
161
170
  originalDoc?: unknown;
162
171
  currentDoc?: unknown;
@@ -40,7 +40,12 @@ export function renderButtonGroup(
40
40
  const extraSelected = hasExtra && extra.includes(String(value));
41
41
 
42
42
  return html`
43
- <div class=${classMap({ "button-group-combo": true, "has-overflow": hasExtra })}>
43
+ <div
44
+ class=${classMap({
45
+ "button-group-combo": true,
46
+ "has-overflow": hasExtra,
47
+ })}
48
+ >
44
49
  <sp-action-group size="s" compact>
45
50
  ${values.map(
46
51
  (v: string) => html`
@@ -31,7 +31,10 @@ const OPERATOR_GROUPS = [
31
31
  { label: "Arithmetic", ops: ["+", "-", "*", "/", "%"] },
32
32
  { label: "Comparison", ops: ["===", "!==", "<", "<=", ">", ">="] },
33
33
  { label: "Logical", ops: ["&&", "||"] },
34
- { label: "Array methods", ops: ["push", "pop", "shift", "unshift", "splice"] },
34
+ {
35
+ label: "Array methods",
36
+ ops: ["push", "pop", "shift", "unshift", "splice"],
37
+ },
35
38
  { label: "Aggregate", ops: ["reduce", "map", "filter"] },
36
39
  ];
37
40
 
@@ -304,7 +307,12 @@ function renderLiteralEditor(operand: any, onChange: (newVal: any) => void) {
304
307
  function renderOperandEditor(
305
308
  operand: unknown,
306
309
  onChange: (newOperand: unknown) => void,
307
- opts: { stateDefs: string[]; allowEventRef: boolean; depth: number; mustBeRef?: boolean },
310
+ opts: {
311
+ stateDefs: string[];
312
+ allowEventRef: boolean;
313
+ depth: number;
314
+ mustBeRef?: boolean;
315
+ },
308
316
  ): import("lit-html").TemplateResult {
309
317
  if (opts.mustBeRef) {
310
318
  const refVal = ((operand as Record<string, unknown> | null)?.$ref as string) ?? "";
@@ -337,7 +345,10 @@ function renderOperandEditor(
337
345
  (r) => onChange({ $ref: r }),
338
346
  opts,
339
347
  )
340
- : renderExpressionEditor(operand, onChange, { ...opts, depth: opts.depth + 1 })}
348
+ : renderExpressionEditor(operand, onChange, {
349
+ ...opts,
350
+ depth: opts.depth + 1,
351
+ })}
341
352
  </div>
342
353
  `;
343
354
  }
package/src/ui/layers.ts CHANGED
@@ -48,7 +48,11 @@ export function showDialog<T>(
48
48
  export function showConfirmDialog(
49
49
  headline: string,
50
50
  message: string | import("lit-html").TemplateResult,
51
- opts: { confirmLabel?: string; cancelLabel?: string; destructive?: boolean } = {},
51
+ opts: {
52
+ confirmLabel?: string;
53
+ cancelLabel?: string;
54
+ destructive?: boolean;
55
+ } = {},
52
56
  ) {
53
57
  const { confirmLabel = "Confirm", cancelLabel = "Cancel", destructive = false } = opts;
54
58
  return showDialog(
@@ -6,10 +6,12 @@
6
6
  * files from the project's public/ directory, with thumbnail previews for images.
7
7
  */
8
8
 
9
- import { html, nothing } from "lit-html";
9
+ import { html, render as litRender, nothing } from "lit-html";
10
10
  import { live } from "lit-html/directives/live.js";
11
+ import { ref } from "lit-html/directives/ref.js";
11
12
  import { getPlatform } from "../platform";
12
13
  import { debouncedStyleCommit } from "../store";
14
+ import { getLayerSlot } from "./layers";
13
15
 
14
16
  // ─── Media file cache ────────────────────────────────────────────────────────
15
17
 
@@ -92,6 +94,184 @@ export function invalidateMediaCache() {
92
94
  mediaCacheLoaded = false;
93
95
  }
94
96
 
97
+ // ─── Popover state ───────────────────────────────────────────────────────────
98
+
99
+ /** @type {((val: string) => void) | null} */
100
+ let _popoverOnCommit: ((val: string) => void) | null = null;
101
+
102
+ /** @type {HTMLElement | null} */
103
+ let _popoverAnchorEl: HTMLElement | null = null;
104
+
105
+ /** @type {HTMLInputElement | null} */
106
+ let _popoverFilterEl: HTMLInputElement | null = null;
107
+
108
+ let _popoverFilter = "";
109
+
110
+ function dismissMediaPickerPopover() {
111
+ _popoverFilter = "";
112
+ _popoverOnCommit = null;
113
+ _popoverAnchorEl = null;
114
+ _popoverFilterEl = null;
115
+ document.removeEventListener("keydown", onPopoverKeydown, true);
116
+ document.removeEventListener("mousedown", onPopoverOutsideClick, true);
117
+ litRender(nothing, getLayerSlot("popover", "media-picker"));
118
+ }
119
+
120
+ /** @param {KeyboardEvent} e */
121
+ function onPopoverKeydown(e: KeyboardEvent) {
122
+ if (e.key === "Escape") {
123
+ dismissMediaPickerPopover();
124
+ e.preventDefault();
125
+ e.stopPropagation();
126
+ }
127
+ }
128
+
129
+ /** @param {MouseEvent} e */
130
+ function onPopoverOutsideClick(e: MouseEvent) {
131
+ const host = getLayerSlot("popover", "media-picker");
132
+ if (!host.contains(e.target as Node)) {
133
+ dismissMediaPickerPopover();
134
+ }
135
+ }
136
+
137
+ function renderMediaPickerPopover() {
138
+ const host = getLayerSlot("popover", "media-picker");
139
+ const rect = _popoverAnchorEl?.getBoundingClientRect();
140
+ if (!rect) return;
141
+
142
+ const query = _popoverFilter.toLowerCase();
143
+ const filtered = query
144
+ ? mediaCache.filter(
145
+ (m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
146
+ )
147
+ : mediaCache;
148
+ const options = filtered.slice(0, 50);
149
+
150
+ // Compute initial position below the anchor
151
+ let left = rect.left;
152
+ let top = rect.bottom + 4;
153
+
154
+ // Estimate popover dimensions for viewport clamping before first paint
155
+ const estimatedWidth = 280;
156
+ const estimatedHeight = Math.min(options.length * 36 + 48, 360);
157
+
158
+ if (left + estimatedWidth > window.innerWidth - 8) {
159
+ left = Math.max(8, window.innerWidth - estimatedWidth - 8);
160
+ }
161
+ if (top + estimatedHeight > window.innerHeight - 8) {
162
+ top = Math.max(8, rect.top - estimatedHeight - 4);
163
+ }
164
+
165
+ let _popoverEl: HTMLElement | null = null;
166
+
167
+ litRender(
168
+ html`
169
+ <sp-popover
170
+ open
171
+ ${ref((el) => {
172
+ _popoverEl = (el as HTMLElement | undefined) || null;
173
+ })}
174
+ style="position:fixed;left:${left}px;top:${top}px;z-index:30;max-height:360px;overflow-y:auto;min-width:240px"
175
+ >
176
+ <input
177
+ class="media-picker-filter"
178
+ type="text"
179
+ placeholder="Search images…"
180
+ autocomplete="off"
181
+ style="display:block;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-bottom:1px solid var(--border, #444);outline:none;font-size:13px;background:transparent;color:inherit"
182
+ ${ref((el) => {
183
+ _popoverFilterEl = (el as HTMLInputElement | null) || null;
184
+ })}
185
+ @input=${(e: Event) => {
186
+ _popoverFilter = (e.target as HTMLInputElement).value;
187
+ renderMediaPickerPopover();
188
+ }}
189
+ @click=${(e: MouseEvent) => e.stopPropagation()}
190
+ />
191
+ <sp-menu
192
+ style="min-width:220px"
193
+ @change=${(e: Event) => {
194
+ _popoverOnCommit?.((e.target as HTMLInputElement).value);
195
+ dismissMediaPickerPopover();
196
+ }}
197
+ >
198
+ ${options.length
199
+ ? options.map(
200
+ (m) => html`
201
+ <sp-menu-item value=${m.path}>
202
+ ${m.isImage
203
+ ? html`<img
204
+ slot="icon"
205
+ src=${m.path}
206
+ alt=""
207
+ style="width:24px;height:24px;object-fit:cover;border-radius:2px"
208
+ />`
209
+ : nothing}
210
+ ${m.name}
211
+ </sp-menu-item>
212
+ `,
213
+ )
214
+ : html`<sp-menu-item disabled>No matches</sp-menu-item>`}
215
+ ${filtered.length > 50
216
+ ? html`<sp-menu-item disabled>…${filtered.length - 50} more</sp-menu-item>`
217
+ : nothing}
218
+ </sp-menu>
219
+ </sp-popover>
220
+ `,
221
+ host,
222
+ );
223
+
224
+ // Fine-tune position after render using actual measured dimensions
225
+ requestAnimationFrame(() => {
226
+ if (_popoverEl) {
227
+ const popoverRect = _popoverEl.getBoundingClientRect();
228
+ let adjLeft = popoverRect.left;
229
+ let adjTop = popoverRect.top;
230
+ let needsAdjust = false;
231
+
232
+ if (popoverRect.right > window.innerWidth - 4) {
233
+ adjLeft = Math.max(4, window.innerWidth - popoverRect.width - 4);
234
+ needsAdjust = true;
235
+ }
236
+ if (popoverRect.bottom > window.innerHeight - 4) {
237
+ adjTop = Math.max(4, window.innerHeight - popoverRect.height - 4);
238
+ needsAdjust = true;
239
+ }
240
+ if (popoverRect.left < 4) {
241
+ adjLeft = 4;
242
+ needsAdjust = true;
243
+ }
244
+ if (popoverRect.top < 4) {
245
+ adjTop = 4;
246
+ needsAdjust = true;
247
+ }
248
+
249
+ if (needsAdjust) {
250
+ _popoverEl.style.left = `${adjLeft}px`;
251
+ _popoverEl.style.top = `${adjTop}px`;
252
+ }
253
+ }
254
+
255
+ if (_popoverFilterEl) _popoverFilterEl.focus();
256
+ });
257
+ }
258
+
259
+ /**
260
+ * @param {HTMLElement} anchorEl
261
+ * @param {(val: string) => void} onCommit
262
+ */
263
+ function showMediaPickerPopover(anchorEl: HTMLElement, onCommit: (val: string) => void) {
264
+ dismissMediaPickerPopover();
265
+ _popoverOnCommit = onCommit;
266
+ _popoverAnchorEl = anchorEl;
267
+ _popoverFilter = "";
268
+ renderMediaPickerPopover();
269
+ document.addEventListener("keydown", onPopoverKeydown, true);
270
+ requestAnimationFrame(() => {
271
+ document.addEventListener("mousedown", onPopoverOutsideClick, true);
272
+ });
273
+ }
274
+
95
275
  // ─── Render ──────────────────────────────────────────────────────────────────
96
276
 
97
277
  /**
@@ -111,17 +291,6 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
111
291
  currentValue.slice(currentValue.lastIndexOf(".")).toLowerCase(),
112
292
  );
113
293
 
114
- // Filter media options based on current input
115
- const query = currentValue.toLowerCase();
116
- const filtered = query
117
- ? mediaCache.filter(
118
- (m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
119
- )
120
- : mediaCache;
121
-
122
- // Limit displayed options
123
- const options = filtered.slice(0, 20);
124
-
125
294
  return html`
126
295
  <div class="media-picker">
127
296
  ${isImage && currentValue
@@ -138,37 +307,17 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
138
307
  ></sp-textfield>
139
308
  ${mediaCache.length > 0
140
309
  ? html`
141
- <overlay-trigger placement="bottom-end" triggered-by="click">
142
- <sp-action-button size="xs" quiet slot="trigger" title="Browse media">
143
- <sp-icon-image slot="icon"></sp-icon-image>
144
- </sp-action-button>
145
- <sp-popover slot="click-content" class="media-picker-popover">
146
- <sp-menu
147
- @change=${(e: Event) => {
148
- onCommit((e.target as HTMLInputElement).value);
149
- }}
150
- >
151
- ${options.map(
152
- (m) => html`
153
- <sp-menu-item value=${m.path}>
154
- ${m.isImage
155
- ? html`<img
156
- slot="icon"
157
- src=${m.path}
158
- alt=""
159
- style="width:24px;height:24px;object-fit:cover;border-radius:2px"
160
- />`
161
- : nothing}
162
- ${m.name}
163
- </sp-menu-item>
164
- `,
165
- )}
166
- ${filtered.length > 20
167
- ? html`<sp-menu-item disabled>...${filtered.length - 20} more</sp-menu-item>`
168
- : nothing}
169
- </sp-menu>
170
- </sp-popover>
171
- </overlay-trigger>
310
+ <sp-action-button
311
+ size="xs"
312
+ quiet
313
+ title="Browse media"
314
+ @click=${(e: MouseEvent) => {
315
+ loadMediaCache();
316
+ showMediaPickerPopover(e.currentTarget as HTMLElement, onCommit);
317
+ }}
318
+ >
319
+ <sp-icon-image slot="icon"></sp-icon-image>
320
+ </sp-action-button>
172
321
  `
173
322
  : nothing}
174
323
  </div>
@@ -66,7 +66,12 @@ export function renderUnitSelector(
66
66
 
67
67
  return html`
68
68
  <div class="style-input-number-unit">
69
- <div class=${classMap({ "input-group": true, "is-expression": isExpression })}>
69
+ <div
70
+ class=${classMap({
71
+ "input-group": true,
72
+ "is-expression": isExpression,
73
+ })}
74
+ >
70
75
  <sp-textfield
71
76
  size="s"
72
77
  placeholder=${numericPlaceholder}
@@ -83,7 +83,7 @@ export function applyCanvasStyle(
83
83
  const mediaName = key.slice(1);
84
84
  if (mediaName === "--") continue;
85
85
  if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
86
- for (const [prop, v] of Object.entries(/** @type {Record<string, unknown>} */ (val))) {
86
+ for (const [prop, v] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
87
87
  if (typeof v === "string" || typeof v === "number") {
88
88
  try {
89
89
  if (prop.startsWith("--")) el.style.setProperty(prop, String(v));
@@ -73,7 +73,10 @@ export function ensureGoogleFontPreconnects(head: JxHeadEntry[]) {
73
73
  e?.attributes?.href === origin,
74
74
  );
75
75
  if (!exists) {
76
- const attrs: Record<string, string | boolean> = { rel: "preconnect", href: origin };
76
+ const attrs: Record<string, string | boolean> = {
77
+ rel: "preconnect",
78
+ href: origin,
79
+ };
77
80
  if (origin === "https://fonts.gstatic.com") attrs.crossorigin = "";
78
81
  head.push({ tagName: "link", attributes: attrs });
79
82
  }
@@ -4,6 +4,7 @@
4
4
  * These are all side-effect-free functions used by style/properties/events panels.
5
5
  */
6
6
 
7
+ import { formatByName, defaultContentFormat } from "../format/format-host";
7
8
  import type { ProjectConfig, ContentTypeDef } from "@jxsuite/schema/types";
8
9
 
9
10
  /**
@@ -155,7 +156,12 @@ export function findContentTypeSchema(
155
156
  return { name, schema: def.schema };
156
157
  }
157
158
  } else {
158
- const ext = `.${def.format || "md"}`;
159
+ const ext =
160
+ def.format === "json"
161
+ ? ".json"
162
+ : (formatByName(def.format)?.extensions[0] ??
163
+ defaultContentFormat()?.extensions[0] ??
164
+ ".json");
159
165
  if (documentPath.startsWith(src + "/") && documentPath.endsWith(ext)) {
160
166
  return { name, schema: def.schema };
161
167
  }
package/src/view.ts CHANGED
@@ -16,7 +16,9 @@ interface ViewState {
16
16
  panY: number;
17
17
  prevCanvasMode: string | null;
18
18
  monacoEditor:
19
- | (import("monaco-editor").editor.IStandaloneCodeEditor & { _ignoreNextChange?: boolean })
19
+ | (import("monaco-editor").editor.IStandaloneCodeEditor & {
20
+ _ignoreNextChange?: boolean;
21
+ })
20
22
  | null;
21
23
  functionEditor:
22
24
  | (import("monaco-editor").editor.IStandaloneCodeEditor & {
@@ -42,7 +44,11 @@ interface ViewState {
42
44
  forcedAttrEl: HTMLElement | null;
43
45
  elementsCollapsed: Set<string>;
44
46
  elementsFilter: string;
45
- lastDragInput: { clientX: number; clientY: number; [k: string]: unknown } | null;
47
+ lastDragInput: {
48
+ clientX: number;
49
+ clientY: number;
50
+ [k: string]: unknown;
51
+ } | null;
46
52
  _currentDropTargetRow: HTMLElement | null;
47
53
  layerDragSourceHeight: number;
48
54
  savedRange: Range | null;