@jxsuite/studio 0.33.0 → 0.34.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 (85) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +10754 -11060
  4. package/dist/studio.js.map +83 -72
  5. package/package.json +7 -7
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +5 -7
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +15 -0
  58. package/src/services/monaco-setup.ts +12 -0
  59. package/src/services/render-critic.ts +9 -9
  60. package/src/settings/css-vars-editor.ts +2 -2
  61. package/src/store.ts +4 -62
  62. package/src/studio.ts +90 -40
  63. package/src/tabs/doc-op-apply.ts +89 -0
  64. package/src/tabs/patch-ops.ts +6 -2
  65. package/src/tabs/tab.ts +23 -4
  66. package/src/tabs/transact.ts +2 -74
  67. package/src/types.ts +14 -18
  68. package/src/ui/jx-theme.ts +63 -0
  69. package/src/ui/media-picker.ts +6 -4
  70. package/src/ui/spectrum.ts +5 -0
  71. package/src/utils/canvas-media.ts +0 -137
  72. package/src/utils/edit-display.ts +23 -3
  73. package/src/utils/geometry.ts +43 -0
  74. package/src/utils/link-target.ts +93 -0
  75. package/src/utils/strip-events.ts +54 -0
  76. package/src/view.ts +0 -23
  77. package/src/workspace/workspace.ts +14 -1
  78. package/src/canvas/canvas-subtree-render.ts +0 -113
  79. package/src/editor/component-inline-edit.ts +0 -349
  80. package/src/editor/content-inline-edit.ts +0 -207
  81. package/src/editor/insertion-helper.ts +0 -308
  82. package/src/panels/canvas-dnd.ts +0 -329
  83. package/src/panels/panel-events.ts +0 -306
  84. package/src/panels/preview-render.ts +0 -132
  85. package/src/panels/pseudo-preview.ts +0 -75
@@ -1,20 +1,23 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Tab bar — a per-tab contextual action bar rendered between the tab strip and the edit content.
4
- * Standardizes Back/breadcrumb navigation, media feature toggles, and mode actions (Code-mode
5
- * Export) into a single bar shared identically by every edit mode.
3
+ * Tab bar — a persistent per-tab settings bar rendered between the tab strip and the edit content.
4
+ * Standardizes Back/breadcrumb navigation, the view settings cluster (preview toggle, layout
5
+ * visibility, dynamic route-param pickers shown in the edit/design base modes), media feature
6
+ * toggles, and mode actions (Code-mode Export) into a single bar shared by every edit mode.
6
7
  *
7
8
  * Follows the same module shape as tab-strip.ts: mount(host, ctx) → effectScope/effect → render().
8
- * The bar collapses (renders `nothing`, so `#tab-bar:empty` hides the row) when there is nothing
9
- * contextual to show.
9
+ * The bar renders `nothing` (so `#tab-bar:empty` hides the row) only when no tab is active.
10
10
  */
11
11
 
12
12
  import { html, render as litRender, nothing } from "lit-html";
13
- import { updateUi } from "../store";
13
+ import { projectState, updateUi } from "../store";
14
14
  import { effect, effectScope } from "../reactivity";
15
15
  import { activeTab } from "../workspace/workspace";
16
- import { getEffectiveMedia } from "../site-context";
16
+ import { getEffectiveLayoutPath, getEffectiveMedia } from "../site-context";
17
+ import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
17
18
  import { mediaDisplayName } from "./shared";
19
+ import type { ParamValues } from "../page-params";
20
+ import type { Tab } from "../tabs/tab";
18
21
  import type { DocumentStackEntry, FunctionEditDef } from "../types";
19
22
  import type { EffectScope } from "@vue/reactivity";
20
23
  import type { TemplateResult } from "lit-html";
@@ -59,12 +62,16 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
59
62
  if (tab) {
60
63
  // Read reactive properties to establish tracking — mirrors the toolbar's subset
61
64
  void tab.doc.document;
65
+ void tab.doc.document?.$layout;
62
66
  void tab.doc.mode;
63
67
  void tab.documentPath;
64
68
  void tab.session.documentStack.length;
65
69
  void tab.session.ui.canvasMode;
66
70
  void tab.session.ui.editingFunction;
67
71
  void tab.session.ui.featureToggles;
72
+ void tab.session.ui.preview;
73
+ void tab.session.ui.previewParams;
74
+ void tab.session.ui.showLayout;
68
75
  }
69
76
  render();
70
77
  });
@@ -143,6 +150,55 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
143
150
  `;
144
151
  }
145
152
 
153
+ // ── Right region: view settings cluster (edit/design base modes only) ──
154
+ // Gates on the BASE mode (not the effective mode): the cluster stays visible while the preview
155
+ // Toggle is on so it can be toggled back off.
156
+ const baseMode = S.ui.canvasMode;
157
+ const isPage = Boolean(
158
+ S.documentPath &&
159
+ projectState?.isSiteProject &&
160
+ (S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/")),
161
+ );
162
+ let settingsTpl: TemplateResult | typeof nothing = nothing;
163
+ if (!editing && (baseMode === "edit" || baseMode === "design")) {
164
+ const canPreview = tab.capabilities.modes.includes("preview");
165
+ const hasLayout = isPage && Boolean(getEffectiveLayoutPath(S.document?.$layout));
166
+ const pickersTpl = isPage ? paramPickersTpl(tab) : nothing;
167
+ const previewTpl = canPreview
168
+ ? html`
169
+ <sp-action-button
170
+ toggles
171
+ size="s"
172
+ title="Preview resolved values"
173
+ ?selected=${Boolean(S.ui.preview)}
174
+ @click=${() => updateUi("preview", !S.ui.preview)}
175
+ >
176
+ <sp-icon-preview slot="icon"></sp-icon-preview>
177
+ Preview
178
+ </sp-action-button>
179
+ `
180
+ : nothing;
181
+ const layoutTpl = hasLayout
182
+ ? html`
183
+ <sp-action-button
184
+ toggles
185
+ size="s"
186
+ title="Show layout elements"
187
+ ?selected=${S.ui.showLayout !== false}
188
+ @click=${() => updateUi("showLayout", S.ui.showLayout === false)}
189
+ >
190
+ Layout
191
+ </sp-action-button>
192
+ `
193
+ : nothing;
194
+ if (pickersTpl !== nothing || previewTpl !== nothing || layoutTpl !== nothing) {
195
+ settingsTpl = html`
196
+ ${pickersTpl}
197
+ <sp-action-group compact size="s">${layoutTpl} ${previewTpl}</sp-action-group>
198
+ `;
199
+ }
200
+ }
201
+
146
202
  // ── Right region: media feature toggles ──
147
203
  const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
148
204
  const togglesTpl =
@@ -183,16 +239,106 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
183
239
  `
184
240
  : nothing;
185
241
 
186
- // Collapse the bar entirely when there is nothing contextual to show.
187
- if (navTpl === nothing && togglesTpl === nothing && exportTpl === nothing) {
188
- return nothing;
189
- }
190
-
191
242
  return html`
192
243
  <div class="tab-bar">
193
244
  ${navTpl}
194
245
  <div class="tb-spacer"></div>
195
- ${togglesTpl} ${exportTpl}
246
+ ${settingsTpl} ${togglesTpl} ${exportTpl}
196
247
  </div>
197
248
  `;
198
249
  }
250
+
251
+ // ── Dynamic route-param pickers ──────────────────────────────────────────────
252
+ // Candidate values load asynchronously (ContentCollection resolution / data-file read); the module
253
+ // Caches the last result per (documentPath, $paths) and re-renders when it lands — the same lazy
254
+ // Fill pattern as head-panel's loadLayoutEntries. When values arrive, any param without a chosen
255
+ // Value auto-selects the first candidate (matching the compiler, whose first expanded route is the
256
+ // First path entry).
257
+
258
+ let _paramValues: ParamValues | null = null;
259
+
260
+ let _paramValuesKey: string | null = null;
261
+
262
+ /**
263
+ * @param {Tab} tab
264
+ * @returns {ParamValues | null} — null while loading (or when the doc declares no params)
265
+ */
266
+ function paramValuesFor(tab: Tab): ParamValues | null {
267
+ const pathsDef = pagePathsDef({
268
+ document: tab.doc.document,
269
+ frontmatter: tab.doc.content.frontmatter,
270
+ });
271
+ if (!pathsDef && dynamicRouteParams(tab.documentPath).length === 0) {
272
+ return null;
273
+ }
274
+ const key = `${tab.documentPath ?? ""}::${JSON.stringify(pathsDef)}`;
275
+ if (_paramValuesKey === key) {
276
+ return _paramValues;
277
+ }
278
+ _paramValuesKey = key;
279
+ _paramValues = null;
280
+ void loadParamValues(tab.documentPath, pathsDef).then((values) => {
281
+ if (_paramValuesKey !== key || activeTab.value !== tab) {
282
+ return;
283
+ }
284
+ _paramValues = values;
285
+ autoSelectParams(tab, values);
286
+ render();
287
+ });
288
+ return _paramValues;
289
+ }
290
+
291
+ /**
292
+ * @param {Tab} tab
293
+ * @param {ParamValues} values
294
+ */
295
+ function autoSelectParams(tab: Tab, values: ParamValues) {
296
+ const current = tab.session.ui.previewParams ?? {};
297
+ const additions: Record<string, string> = {};
298
+ for (const [name, list] of Object.entries(values)) {
299
+ if (!current[name] && list.length > 0) {
300
+ additions[name] = list[0]!;
301
+ }
302
+ }
303
+ if (Object.keys(additions).length > 0) {
304
+ updateUi("previewParams", { ...current, ...additions });
305
+ }
306
+ }
307
+
308
+ /**
309
+ * @param {Tab} tab
310
+ * @returns {TemplateResult | typeof nothing}
311
+ */
312
+ function paramPickersTpl(tab: Tab): TemplateResult | typeof nothing {
313
+ const values = paramValuesFor(tab);
314
+ const names = new Set(dynamicRouteParams(tab.documentPath));
315
+ for (const name of Object.keys(values ?? {})) {
316
+ names.add(name);
317
+ }
318
+ if (names.size === 0) {
319
+ return nothing;
320
+ }
321
+ const { previewParams } = tab.session.ui;
322
+ return html`
323
+ ${[...names].map(
324
+ (name) => html`
325
+ <sp-picker
326
+ size="s"
327
+ quiet
328
+ class="tab-bar-param"
329
+ label=${name}
330
+ title=${`Preview value for [${name}]`}
331
+ value=${previewParams?.[name] ?? ""}
332
+ @change=${(e: Event) => {
333
+ const { value } = e.target as HTMLInputElement;
334
+ updateUi("previewParams", { ...tab.session.ui.previewParams, [name]: value });
335
+ }}
336
+ >
337
+ ${(values?.[name] ?? []).map(
338
+ (v: string) => html`<sp-menu-item value=${v}>${v}</sp-menu-item>`,
339
+ )}
340
+ </sp-picker>
341
+ `,
342
+ )}
343
+ `;
344
+ }
@@ -305,7 +305,6 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
305
305
  const modes = [
306
306
  { iconTag: "sp-icon-edit", key: "edit", label: "Edit" },
307
307
  { iconTag: "sp-icon-artboard", key: "design", label: "Design" },
308
- { iconTag: "sp-icon-preview", key: "preview", label: "Preview" },
309
308
  { iconTag: "sp-icon-code", key: "source", label: "Code" },
310
309
  { iconTag: "sp-icon-brush", key: "stylebook", label: "Stylebook" },
311
310
  ];
@@ -333,7 +332,9 @@ function toolbarTemplate() {
333
332
  selection: tab.session.selection,
334
333
  ui: tab.session.ui,
335
334
  };
336
- const canvasMode = ctx.getCanvasMode();
335
+ // Base mode, not the effective mode: the switcher keeps Edit/Design highlighted while the
336
+ // Tab-bar preview toggle is on (preview is no longer a switchable mode).
337
+ const { canvasMode } = tab.session.ui;
337
338
 
338
339
  const modeSwitcherTpl = html`
339
340
  <sp-action-group selects="single" size="s" compact>
@@ -588,6 +588,21 @@ export function createDevServerPlatform() {
588
588
  return schema;
589
589
  },
590
590
 
591
+ // ─── Class resolution (dev-proxy) ─────────────────────────────────────
592
+
593
+ /** @param {Record<string, unknown>} body */
594
+ async resolveClass(body: Record<string, unknown>) {
595
+ const res = await fetch("/__jx_resolve__", {
596
+ body: JSON.stringify(body),
597
+ headers: { "content-type": "application/json" },
598
+ method: "POST",
599
+ });
600
+ if (!res.ok) {
601
+ throw new Error(`Class resolution failed: ${res.status}`);
602
+ }
603
+ return await readJson<unknown>(res);
604
+ },
605
+
591
606
  // ─── Git operations ──────────────────────────────────────────────────
592
607
 
593
608
  async gitStatus() {
@@ -23,6 +23,18 @@ self.MonacoEnvironment = {
23
23
  },
24
24
  };
25
25
 
26
+ /* Monaco caches glyph widths when an editor is created. JetBrains Mono is a
27
+ vendored webfont (index.html @font-face) and may finish loading after the
28
+ first editor mounts, so remeasure once all document fonts are ready to keep
29
+ cursor and selection alignment correct. */
30
+ if (typeof document !== "undefined" && document.fonts?.ready) {
31
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- fire-and-forget: awaiting fonts.ready at top level would block module evaluation (and studio startup) until webfonts load
32
+ void document.fonts.ready.then(async () => {
33
+ const monaco = await import("monaco-editor/esm/vs/editor/editor.api.js");
34
+ monaco.editor.remeasureFonts();
35
+ });
36
+ }
37
+
26
38
  // oxlint-disable-next-line typescript/no-unsafe-call, typescript/no-unsafe-member-access -- jsonDefaults is imported from Monaco's untyped ESM contribution (see @ts-expect-error above); no type declarations exist for this named export
27
39
  jsonDefaults.setDiagnosticsOptions({
28
40
  allowComments: false,
@@ -13,9 +13,8 @@
13
13
  * @license MIT
14
14
  */
15
15
 
16
- import { buildScope, renderNode, setSkipServerFunctions } from "@jxsuite/runtime";
16
+ import { buildScope, renderNode, runScoped, setSkipServerFunctions } from "@jxsuite/runtime";
17
17
  import type { JxDocument } from "@jxsuite/schema/types";
18
- import { effectScope } from "../reactivity";
19
18
 
20
19
  /**
21
20
  * Translate a render-time error into an actionable message the model can fix.
@@ -51,25 +50,26 @@ function translateRenderError(err: Error): string {
51
50
  export async function renderCheck(
52
51
  doc: JxDocument,
53
52
  ): Promise<{ ok: true } | { ok: false; error: string }> {
54
- const scope = effectScope();
53
+ let stopRender: (() => void) | null = null;
55
54
  try {
56
55
  setSkipServerFunctions(true);
57
56
 
58
57
  const state = await buildScope(doc, {});
59
58
 
60
59
  const container = document.createElement("div");
61
- scope.run(() => {
60
+ const { stop } = runScoped(() => {
62
61
  container.append(renderNode(doc, state));
63
62
  });
63
+ stopRender = stop;
64
64
 
65
65
  return { ok: true };
66
66
  } catch (error) {
67
67
  return { ok: false, error: translateRenderError(error as Error) };
68
68
  } finally {
69
- // Dispose all effects created synchronously by renderNode. Effects from the async
70
- // BuildScope (prototype/computed setup) escape the scope because Vue tracks the active
71
- // Scope synchronously they're GC-eligible once the throwaway state/container are
72
- // Unreferenced. This bounded leak is acceptable for v1.
73
- scope.stop();
69
+ // Dispose all effects created synchronously by renderNode via the RUNTIME's runScoped — scope
70
+ // Collection is per @vue/reactivity module instance, so a studio effectScope here would collect
71
+ // Nothing. Effects from the async buildScope (prototype/computed setup) still escape the scope;
72
+ // They're GC-eligible once the throwaway state/container are unreferenced. Bounded, acceptable.
73
+ stopRender?.();
74
74
  }
75
75
  }
@@ -114,7 +114,7 @@ function renderColorSection(
114
114
  <div class="css-var-swatch" style="background:${val}">
115
115
  <input
116
116
  type="color"
117
- .value=${val && String(val).startsWith("#") ? val : "#007acc"}
117
+ .value=${val && String(val).startsWith("#") ? val : "#3b82f6"}
118
118
  @input=${(e: Event) => updateVar(name, (e.target as HTMLInputElement).value)}
119
119
  />
120
120
  </div>
@@ -131,7 +131,7 @@ function renderColorSection(
131
131
  </div>
132
132
  `,
133
133
  )}
134
- ${renderAddRow("--color-", "Primary Blue", "#007acc", addVar)}
134
+ ${renderAddRow("--color-", "Primary Blue", "#3b82f6", addVar)}
135
135
  </div>
136
136
  `;
137
137
  }
package/src/store.ts CHANGED
@@ -10,10 +10,9 @@
10
10
  // ─── Re-exports from state.js ────────────────────────────────────────────────
11
11
 
12
12
  import { activeTab } from "./workspace/workspace";
13
- import { isEventBinding } from "@jxsuite/schema/guards";
14
13
  import type { JxPath } from "./state";
15
14
  import type { JxMutableNode } from "@jxsuite/schema/types";
16
- import type { CanvasPanel } from "./panels/canvas-dnd.js";
15
+ import type { CanvasPanel } from "./types";
17
16
 
18
17
  export {
19
18
  createState,
@@ -52,20 +51,6 @@ export function initShellRefs() {
52
51
 
53
52
  // ─── Shared containers (mutated in place by owner modules) ───────────────────
54
53
 
55
- export const elToPath = new WeakMap<Element, JxPath>();
56
-
57
- /**
58
- * Canvas element → the runtime scope its children render with. Captured during live renders so the
59
- * canvas patcher can re-render a subtree in isolation with the same prototype-chained scope.
60
- */
61
- export const elToScope = new WeakMap<Element, Record<string, unknown>>();
62
-
63
- /**
64
- * Subtree root element → the effect scope created for its surgical render. Stopped when the subtree
65
- * is replaced or removed so render-time reactive effects don't accumulate.
66
- */
67
- export const elToRenderScope = new WeakMap<Element, { stop: () => void }>();
68
-
69
54
  export const canvasPanels: CanvasPanel[] = [];
70
55
 
71
56
  // ─── Shared constants ────────────────────────────────────────────────────────
@@ -139,52 +124,9 @@ export function cancelStyleDebounce(prop: string) {
139
124
  _styleDebounceTimers.delete(prop);
140
125
  }
141
126
 
142
- /**
143
- * Strip all on* event handler properties from a Jx document tree (deep clone).
144
- *
145
- * @param {JxMutableNode} node
146
- * @returns {JxMutableNode}
147
- */
148
- export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
149
- if (!node || typeof node !== "object") {
150
- return node;
151
- }
152
- if (Array.isArray(node)) {
153
- // Arrays of nodes round-trip element-wise; the array itself is not a node.
154
- return node.map((n: JxMutableNode) => stripEventHandlers(n)) as unknown as JxMutableNode;
155
- }
156
- const out: Record<string, unknown> = {};
157
- for (const [k, v] of Object.entries(node)) {
158
- if (k.startsWith("on") && isEventBinding(v)) {
159
- continue;
160
- }
161
- if (k === "children") {
162
- out.children = Array.isArray(v)
163
- ? v.map((c: JxMutableNode) => stripEventHandlers(c))
164
- : stripEventHandlers(v as JxMutableNode);
165
- } else if (k === "cases" && typeof v === "object") {
166
- const cases: Record<string, unknown> = {};
167
- for (const [ck, cv] of Object.entries(v as Record<string, unknown>)) {
168
- cases[ck] = stripEventHandlers(cv as JxMutableNode);
169
- }
170
- out.cases = cases;
171
- } else if (k === "state" && typeof v === "object" && v !== null) {
172
- const state: Record<string, unknown> = {};
173
- for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
174
- if (sv && typeof sv === "object" && (sv as Record<string, unknown>).timing === "server") {
175
- continue;
176
- }
177
- state[sk] = sv;
178
- }
179
- out.state = state;
180
- } else if (k === "style" || k === "attributes" || k === "$media") {
181
- out[k] = v;
182
- } else {
183
- out[k] = v;
184
- }
185
- }
186
- return out;
187
- }
127
+ // `stripEventHandlers` moved to ./utils/strip-events (dependency-light, shared with the iframe
128
+ // Subtree renderer); re-exported here so existing `from "../store"` imports keep working.
129
+ export { stripEventHandlers } from "./utils/strip-events";
188
130
 
189
131
  // ─── Render orchestration ────────────────────────────────────────────────────
190
132
 
package/src/studio.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  requireProjectState,
20
20
  setProjectState,
21
21
  toolbarEl,
22
+ updateSession,
22
23
  updateUi,
23
24
  } from "./store";
24
25
 
@@ -28,20 +29,28 @@ import { effect } from "./reactivity";
28
29
 
29
30
  import { view } from "./view";
30
31
 
31
- import { isEditableBlock, isEditing } from "./editor/inline-edit";
32
- import { enterComponentInlineEdit, initComponentInlineEdit } from "./editor/component-inline-edit";
33
- import { enterInlineEdit } from "./editor/content-inline-edit";
32
+ import { isEditing } from "./editor/inline-edit";
34
33
  import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
35
- import { findCanvasElement, getActivePanel, initCanvasHelpers } from "./canvas/canvas-helpers";
36
34
  import {
37
- applyCanvasMediaOverrides,
38
35
  initCanvasRender,
39
36
  renderCanvas,
40
37
  renderOverlays,
41
38
  scheduleCanvasRender,
42
39
  } from "./canvas/canvas-render";
43
40
  import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patcher";
44
- import { registerSubtreeDnD } from "./panels/canvas-dnd";
41
+ import {
42
+ commitActiveEditSession,
43
+ getEditSnapshot,
44
+ setCanvasContextMenuHandler,
45
+ setCanvasSlashHandler,
46
+ setIframePatchEscalation,
47
+ setInsertZoneClickHandler,
48
+ setStylebookHitHandler,
49
+ setToolbarRefresh,
50
+ } from "./canvas/iframe-host";
51
+ import { runInsertZoneAction } from "./editor/insert-zone-action";
52
+ import { canvasSlashHandler } from "./editor/canvas-slash-bridge";
53
+ import { makeCanvasContextMenuHandler } from "./editor/canvas-context-menu";
45
54
  import { initCanvasLiveRender } from "./canvas/canvas-live-render";
46
55
  import {
47
56
  mountStatusbar,
@@ -50,7 +59,12 @@ import {
50
59
  statusMessage,
51
60
  } from "./panels/statusbar";
52
61
  import { exportFile, parseSourceForPath, saveFile, serializeDocument } from "./files/file-ops";
53
- import { documentExtensions, formatForPath, loadFormats } from "./format/format-host";
62
+ import {
63
+ documentExtensions,
64
+ formatForPath,
65
+ loadFormats,
66
+ refreshFormats,
67
+ } from "./format/format-host";
54
68
  import {
55
69
  loadProject as _loadProject,
56
70
  openProject as _openProject,
@@ -97,14 +111,17 @@ import * as rightPanelMod from "./panels/right-panel";
97
111
  import * as leftPanelMod from "./panels/left-panel";
98
112
  import * as tabStrip from "./panels/tab-strip";
99
113
  import * as tabBar from "./panels/tab-bar";
100
- import { renderStylebookOverlays } from "./panels/stylebook-panel";
114
+ import { selectStylebookTag } from "./panels/stylebook-panel";
101
115
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd";
116
+ import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
102
117
  import { defaultDef } from "./panels/shared";
103
118
  import { registerFunctionCompletions } from "./panels/editors";
104
- import { renderBlockActionBar, initBlockActionBar } from "./panels/block-action-bar";
119
+ import {
120
+ initBlockActionBar,
121
+ isEditChromeTarget,
122
+ renderBlockActionBar,
123
+ } from "./panels/block-action-bar";
105
124
  import { initCssData } from "./panels/style-utils";
106
- import { updateForcedPseudoPreview } from "./panels/pseudo-preview";
107
- import { initPanelEvents } from "./panels/panel-events";
108
125
  import { initQuickSearch } from "./panels/quick-search";
109
126
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
110
127
  import { initWelcome } from "./panels/welcome-screen";
@@ -119,8 +136,14 @@ void _swc;
119
136
  // These mutable variables are local to studio.js for now. As sections are extracted
120
137
  // Into their own modules, they will migrate to ctx in store.js.
121
138
 
139
+ // Effective canvas mode: the per-tab preview toggle composes with an edit/design base mode and
140
+ // Presents as "preview" to every downstream gate (doc resolution, iframe flags, interaction
141
+ // Surfaces). Consumers needing the base mode (toolbar switcher selection, canvas host layout)
142
+ // Read tab.session.ui.canvasMode directly.
122
143
  function getCanvasMode() {
123
- return activeTab.value?.session.ui.canvasMode ?? "design";
144
+ const ui = activeTab.value?.session.ui;
145
+ const base = ui?.canvasMode ?? "design";
146
+ return ui?.preview && (base === "edit" || base === "design") ? "preview" : base;
124
147
  }
125
148
 
126
149
  /** @param {string} mode */
@@ -361,39 +384,58 @@ initBlockActionBar({
361
384
  getCanvasMode,
362
385
  navigateToComponent,
363
386
  });
364
-
365
- initComponentInlineEdit({ findCanvasElement });
366
- initCanvasHelpers({
367
- getCanvasMode,
368
- getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
387
+ // The iframe's re-emitted selection snapshot drives the parent format toolbar refresh (4b-2).
388
+ setToolbarRefresh(renderBlockActionBar);
389
+ // The cross-origin insertion "+" click runs the parent-realm slash-menu → mutateInsertNode flow.
390
+ setInsertZoneClickHandler(runInsertZoneAction);
391
+ // The in-iframe "/" trigger drives the parent-realm Spectrum slash menu across the bridge.
392
+ setCanvasSlashHandler(canvasSlashHandler);
393
+ // Canvas right-clicks show the parent-realm Jx element context menu across the bridge.
394
+ setCanvasContextMenuHandler(makeCanvasContextMenuHandler({ navigateToComponent }));
395
+ // Stylebook hits decode to a TAG in the host and route here (null = clicked chrome/empty space).
396
+ setStylebookHitHandler((tag, media) => {
397
+ if (tag) {
398
+ selectStylebookTag(tag, media);
399
+ } else {
400
+ updateSession({ ui: { activeSelector: null, stylebookSelection: null } });
401
+ }
369
402
  });
403
+ // Commit-on-parent-click: a pointerdown in PARENT chrome outside the edit-session chrome (format
404
+ // Toolbar / link popover / slash menu) ends the live inline-edit session — the iframe can't observe
405
+ // Parent-realm pointer events (layers panel, tab strip, right panel…). Pointerdowns over the canvas
406
+ // Land inside the cross-origin iframe and never reach this listener, so it can't double-fire with
407
+ // The iframe's own click-away commit.
408
+ document.addEventListener(
409
+ "pointerdown",
410
+ (e) => {
411
+ if (getEditSnapshot().editing && !isEditChromeTarget(e.target)) {
412
+ commitActiveEditSession();
413
+ }
414
+ },
415
+ true,
416
+ );
417
+
370
418
  initCanvasUtils({
371
419
  getCanvasMode,
372
420
  getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
373
- renderStylebookOverlays,
374
421
  setZoomDirect: (zoom) => {
375
422
  if (activeTab.value) {
376
423
  activeTab.value.session.ui.zoom = zoom;
377
424
  }
378
425
  },
379
426
  });
380
- initPanelEvents({
381
- enterInlineEdit,
382
- getCanvasMode,
383
- navigateToComponent,
384
- });
385
427
  initCanvasLiveRender({
386
428
  getCanvasMode,
387
429
  });
388
430
  initCanvasPatcher({
389
- applyCanvasMediaOverrides,
390
- enterComponentInlineEdit,
391
431
  getCanvasMode,
392
- registerSubtreeDnD,
393
432
  renderOverlays,
394
433
  scheduleCanvasRender,
395
- updateForcedPseudoPreview,
396
434
  });
435
+ // When the iframe canvas can't apply a posted patch surgically, fall back to a full render.
436
+ setIframePatchEscalation(scheduleCanvasRender);
437
+ // One global coordinator monitor drives cross-frame palette→canvas drops (Phase 4c).
438
+ registerCanvasDndBridge();
397
439
  initCanvasRender({
398
440
  getCanvasMode,
399
441
  get gitDiffState() {
@@ -441,7 +483,10 @@ effect(() => {
441
483
  void tab.session.ui.canvasMode;
442
484
  void tab.session.ui.editingFunction;
443
485
  void tab.session.ui.featureToggles;
486
+ void tab.session.ui.preview;
487
+ void tab.session.ui.previewParams;
444
488
  void tab.session.ui.settingsTab;
489
+ void tab.session.ui.showLayout;
445
490
  void tab.session.ui.stylebookTab;
446
491
  void tab.session.ui.stylebookFilter;
447
492
  void tab.session.ui.stylebookCustomizedOnly;
@@ -453,7 +498,6 @@ rightPanelMod.mount({
453
498
  getCanvasMode,
454
499
  navigateToComponent,
455
500
  renderCanvas: () => renderCanvas(),
456
- updateForcedPseudoPreview,
457
501
  });
458
502
 
459
503
  leftPanelMod.mount({
@@ -520,6 +564,19 @@ function ensureFsSync() {
520
564
  const _urlParams = new URLSearchParams(location.search);
521
565
  const _projectParam = _urlParams.get("project") || _urlParams.get("open");
522
566
 
567
+ if (!_projectParam) {
568
+ // Electrobun (and other non-?project= hosts) load their project over RPC, so the ?project= branch
569
+ // Below — which is the only place that calls platform.activate() — is skipped. Kick off activate()
570
+ // Here UNCONDITIONALLY so this window's loopback canvasUrl is fetched on boot (the canvas iframe
571
+ // Needs it); a render() once it resolves lets ensureHost swap an early default iframe for the
572
+ // Loopback one (see iframe-host ensureHost's canvasUrl-changed rebuild).
573
+ const _bootPlatform = getPlatform();
574
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- fire-and-forget: must not block the initial render
575
+ void _bootPlatform.activate?.()?.then(() => {
576
+ render();
577
+ });
578
+ }
579
+
523
580
  if (_projectParam) {
524
581
  // ?project= mode: skip normal loadProject, set up site context from the path
525
582
  const isAbsPath =
@@ -708,6 +765,11 @@ async function openRecentProject(root: string) {
708
765
  }
709
766
 
710
767
  platform.projectRoot = root;
768
+ // The format registry is cached per project — the previous root's registry (often empty on a
769
+ // Fresh desktop launch) must not answer for this project, or non-JSON documents fail with
770
+ // "No format class imported" until a reload. Mirrors openProject in files.ts.
771
+ refreshFormats();
772
+ void loadFormats();
711
773
  const content = await platform.readFile("project.json");
712
774
  const config = JSON.parse(content) as ProjectConfig;
713
775
 
@@ -779,18 +841,6 @@ function openFileFromTree(path: string) {
779
841
  initShortcuts(() => ({
780
842
  applyTransform,
781
843
  canvasMode: getCanvasMode(),
782
- componentInlineEdit: view.componentInlineEdit,
783
- enterEditOnPath(path) {
784
- requestAnimationFrame(() => {
785
- const activePanel = getActivePanel();
786
- if (activePanel) {
787
- const el = findCanvasElement(path, activePanel.canvas);
788
- if (el && isEditableBlock(el)) {
789
- enterInlineEdit(el, path);
790
- }
791
- }
792
- });
793
- },
794
844
  openProject,
795
845
  panX: view.panX,
796
846
  panY: view.panY,