@jxsuite/studio 0.32.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 (94) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +22791 -13709
  4. package/dist/studio.js.map +181 -74
  5. package/package.json +12 -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 +386 -328
  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 +18 -47
  58. package/src/services/ai-settings.ts +107 -0
  59. package/src/services/ai-system-prompt.ts +617 -0
  60. package/src/services/ai-tools.ts +854 -0
  61. package/src/services/context-manager.ts +200 -0
  62. package/src/services/document-assistant.ts +183 -0
  63. package/src/services/jx-validate.ts +65 -0
  64. package/src/services/monaco-setup.ts +12 -0
  65. package/src/services/render-critic.ts +75 -0
  66. package/src/services/token-lint.ts +140 -0
  67. package/src/services/tool-executor.ts +156 -0
  68. package/src/settings/css-vars-editor.ts +2 -2
  69. package/src/state.ts +29 -0
  70. package/src/store.ts +4 -62
  71. package/src/studio.ts +90 -40
  72. package/src/tabs/doc-op-apply.ts +89 -0
  73. package/src/tabs/patch-ops.ts +6 -2
  74. package/src/tabs/tab.ts +23 -4
  75. package/src/tabs/transact.ts +33 -69
  76. package/src/types.ts +16 -24
  77. package/src/ui/jx-theme.ts +63 -0
  78. package/src/ui/media-picker.ts +6 -4
  79. package/src/ui/spectrum.ts +5 -0
  80. package/src/utils/canvas-media.ts +0 -137
  81. package/src/utils/edit-display.ts +23 -3
  82. package/src/utils/geometry.ts +43 -0
  83. package/src/utils/link-target.ts +93 -0
  84. package/src/utils/strip-events.ts +54 -0
  85. package/src/view.ts +0 -23
  86. package/src/workspace/workspace.ts +14 -1
  87. package/src/canvas/canvas-subtree-render.ts +0 -113
  88. package/src/editor/component-inline-edit.ts +0 -349
  89. package/src/editor/content-inline-edit.ts +0 -207
  90. package/src/editor/insertion-helper.ts +0 -308
  91. package/src/panels/canvas-dnd.ts +0 -329
  92. package/src/panels/panel-events.ts +0 -306
  93. package/src/panels/preview-render.ts +0 -132
  94. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Tool-executor.js — Agentic loop driver for the document AI assistant
3
+ *
4
+ * Streams a chat round, executes any tool calls the model makes (via a ToolRegistry backed by
5
+ * `transactDoc()`), feeds the results back as `tool` messages, and re-streams — up to a capped
6
+ * number of rounds (spec §10.2, ADR docs/ai-assistant-decision.md §6a).
7
+ *
8
+ * @license MIT
9
+ */
10
+
11
+ import type { createChatState } from "@jxsuite/ai/chat-state";
12
+ import type { StreamingClient } from "@jxsuite/ai/streaming-client";
13
+ import type { ToolRegistry } from "@jxsuite/ai/tools";
14
+
15
+ import type { Tab } from "../tabs/tab";
16
+ import { beginBatch, endBatch } from "../tabs/transact";
17
+
18
+ const MAX_ROUNDS = 5;
19
+
20
+ interface RunAgentLoopOptions {
21
+ chatState: ReturnType<typeof createChatState>;
22
+ streamingClient: StreamingClient;
23
+ toolRegistry: ToolRegistry;
24
+ systemPrompt: string;
25
+ signal?: AbortSignal;
26
+ getTab?: () => Tab | null;
27
+ }
28
+
29
+ /**
30
+ * Run one user turn through the agent loop: stream the model's response, execute any tool calls,
31
+ * and repeat until the model stops calling tools or the round cap is hit.
32
+ */
33
+ export async function runAgentLoop({
34
+ chatState,
35
+ streamingClient,
36
+ toolRegistry,
37
+ systemPrompt,
38
+ signal,
39
+ getTab,
40
+ }: RunAgentLoopOptions): Promise<void> {
41
+ const allErrors: string[] = [];
42
+ const appliedSummaries: string[] = [];
43
+
44
+ // Batch all tool-call mutations into a single undo step
45
+ if (getTab) {
46
+ beginBatch(getTab());
47
+ }
48
+
49
+ try {
50
+ for (let round = 1; round <= MAX_ROUNDS; round++) {
51
+ const messages = chatState.toMessagesArray();
52
+ const tools = toolRegistry.listForLLM();
53
+
54
+ const toolCalls = new Map<string, { name: string; arguments: string }>();
55
+ let stopReason = "stop";
56
+ let streamError = null;
57
+
58
+ for await (const event of streamingClient.streamChat(
59
+ messages,
60
+ tools,
61
+ systemPrompt,
62
+ signal as AbortSignal,
63
+ )) {
64
+ switch (event.type) {
65
+ case "delta": {
66
+ chatState.appendDelta(event.content);
67
+ break;
68
+ }
69
+ case "tool_call_start": {
70
+ chatState.appendToolCallStart(event.id, event.name);
71
+ toolCalls.set(event.id, { name: event.name, arguments: "" });
72
+ break;
73
+ }
74
+ case "tool_call_delta": {
75
+ chatState.appendToolCallDelta(event.id, event.args);
76
+ const tc = toolCalls.get(event.id);
77
+ if (tc) {
78
+ tc.arguments += event.args;
79
+ }
80
+ break;
81
+ }
82
+ case "tool_call_end": {
83
+ chatState.appendToolCallEnd(event.id);
84
+ break;
85
+ }
86
+ case "done": {
87
+ ({ stopReason } = event);
88
+ break;
89
+ }
90
+ case "error": {
91
+ streamError = event.message;
92
+ break;
93
+ }
94
+ default: {
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ chatState.finishStream(stopReason);
101
+
102
+ if (streamError) {
103
+ chatState.setError(streamError);
104
+ return;
105
+ }
106
+
107
+ if (stopReason !== "tool_calls" || toolCalls.size === 0) {
108
+ return;
109
+ }
110
+
111
+ for (const [id, call] of toolCalls) {
112
+ let result;
113
+ try {
114
+ const args = call.arguments ? (JSON.parse(call.arguments) as object) : {};
115
+ result = await toolRegistry.execute(call.name, args);
116
+ } catch (error) {
117
+ result = {
118
+ success: false,
119
+ error: `Failed to parse arguments: ${(error as Error).message}`,
120
+ };
121
+ }
122
+ if (!result.success && result.error) {
123
+ allErrors.push(result.error);
124
+ }
125
+ if (result.success && result.summary) {
126
+ appliedSummaries.push(result.summary);
127
+ }
128
+ chatState.appendToolResult(id, result);
129
+ chatState.pushToolResultMessage(id, JSON.stringify(result));
130
+ }
131
+
132
+ if (round < MAX_ROUNDS) {
133
+ chatState.beginAssistantTurn();
134
+ }
135
+ }
136
+
137
+ /*
138
+ * Surface the actual errors so the user knows what went wrong, not just a generic
139
+ * "I couldn't do it" message.
140
+ */
141
+ const uniqueErrors = [...new Set(allErrors)];
142
+ const applied =
143
+ appliedSummaries.length > 0
144
+ ? `\n\nChanges applied so far:\n${appliedSummaries.map((s) => `- ${s}`).join("\n")}`
145
+ : "";
146
+ const errors =
147
+ uniqueErrors.length > 0
148
+ ? `\n\nErrors encountered:\n${uniqueErrors.map((e) => `- ${e}`).join("\n")}`
149
+ : "";
150
+ chatState.setError(
151
+ `I ran out of tool-call rounds (${MAX_ROUNDS}) before finishing.${applied}${errors}\n\nYou can continue by sending another message, or try a more specific request.`,
152
+ );
153
+ } finally {
154
+ endBatch();
155
+ }
156
+ }
@@ -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/state.ts CHANGED
@@ -73,6 +73,35 @@ export function getNodeAtPath(doc: JxMutableNode, path: JxPath) {
73
73
  return node;
74
74
  }
75
75
 
76
+ /**
77
+ * Shallow-clone every node along `path` from root to target, leaving non-path subtrees as shared
78
+ * references. Returns the new root and the mutable target node at the end of the path.
79
+ *
80
+ * Used by mutation functions for structural-sharing history: the old root becomes an immutable
81
+ * snapshot (shared subtrees are never mutated), and the new root is the live document.
82
+ */
83
+ export function cloneAlongPath(
84
+ doc: JxMutableNode,
85
+ path: JxPath,
86
+ ): { root: JxMutableNode; target: JxMutableNode } {
87
+ const root = Array.isArray(doc) ? [...doc] : { ...doc };
88
+ let node: Record<string | number, unknown> = root as Record<string | number, unknown>;
89
+
90
+ for (const key of path) {
91
+ const child = node[key];
92
+ if (child == null) {
93
+ return { root: root as JxMutableNode, target: node as JxMutableNode };
94
+ }
95
+ const cloned = Array.isArray(child)
96
+ ? [...(child as unknown[])]
97
+ : { ...(child as Record<string, unknown>) };
98
+ node[key] = cloned;
99
+ node = cloned as Record<string | number, unknown>;
100
+ }
101
+
102
+ return { root: root as JxMutableNode, target: node as JxMutableNode };
103
+ }
104
+
76
105
  /**
77
106
  * The node's children when they are a static array (the edit-mode invariant); an empty array for
78
107
  * mapped-array or absent children.
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,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure document-op applier — folds a value-carrying {@link JxDocOp} into a bare (non-reactive)
3
+ * document tree. Shared by two consumers that MUST stay byte-identical or undo/redo would drift
4
+ * from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
5
+ * canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
6
+ *
7
+ * Dependency-light on purpose: it pulls only the pure path helper from `../state` and clones values
8
+ * with a local JSON round-trip, so the slim canvas-iframe bundle can import it without dragging in
9
+ * the editor's reactive store / format host.
10
+ */
11
+
12
+ import { getNodeAtPath } from "../state";
13
+ import type { JxDocOp } from "./patch-ops";
14
+ import type { JxMutableNode } from "@jxsuite/schema/types";
15
+
16
+ /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
17
+ function jsonClone<T>(value: T): T {
18
+ // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
19
+ return JSON.parse(JSON.stringify(value)) as T;
20
+ }
21
+
22
+ /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
23
+ export function cloneValue<T>(v: T): T {
24
+ return v === undefined || v === null ? v : (jsonClone(v as object) as T);
25
+ }
26
+
27
+ /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
28
+ export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
29
+ // Defense-in-depth: a path that resolves to a children array (rather than a node) would
30
+ // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
31
+ // Nothing renders. Callers must pass a node; fail loudly if they don't.
32
+ if (Array.isArray(node)) {
33
+ throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
34
+ }
35
+ if (!node.children) {
36
+ node.children = [];
37
+ }
38
+ if (!Array.isArray(node.children)) {
39
+ throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
40
+ }
41
+ return node.children;
42
+ }
43
+
44
+ /**
45
+ * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
46
+ * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
47
+ */
48
+ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
49
+ switch (op.op) {
50
+ case "set-key": {
51
+ const node = getNodeAtPath(doc, op.path);
52
+ if (!node) {
53
+ throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
54
+ }
55
+ const target = node as Record<string, unknown>;
56
+ if (op.value === undefined) {
57
+ delete target[op.key];
58
+ } else {
59
+ target[op.key] = cloneValue(op.value);
60
+ }
61
+ return;
62
+ }
63
+ case "insert-child": {
64
+ const parent = getNodeAtPath(doc, op.parentPath);
65
+ childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
66
+ return;
67
+ }
68
+ case "remove-child": {
69
+ const parent = getNodeAtPath(doc, op.parentPath);
70
+ childArray(parent).splice(op.index, 1);
71
+ return;
72
+ }
73
+ case "set-child": {
74
+ const parent = getNodeAtPath(doc, op.parentPath);
75
+ childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
76
+ return;
77
+ }
78
+ case "move-child": {
79
+ const fromParent = getNodeAtPath(doc, op.fromParentPath);
80
+ const toParent = getNodeAtPath(doc, op.toParentPath);
81
+ const [node] = childArray(fromParent).splice(op.fromIndex, 1);
82
+ childArray(toParent).splice(op.toIndex, 0, node!);
83
+ return;
84
+ }
85
+ default: {
86
+ throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
87
+ }
88
+ }
89
+ }
@@ -78,8 +78,12 @@ export interface PatchConsumer {
78
78
  classify: (tab: Tab, ops: JxPatchOp[]) => { patchable: boolean; reason: string };
79
79
  /** Mark a document root reference as surgically consumed (checked by the canvas doc-effect). */
80
80
  markConsumed: (docRef: object) => void;
81
- /** Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). */
82
- apply: (tab: Tab, ops: JxPatchOp[]) => void;
81
+ /**
82
+ * Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). `record`
83
+ * carries the value-carrying `docOps` the iframe consumer posts across the frame boundary; the
84
+ * legacy (parent-DOM) consumer ignores it and reads the post-mutation reactive doc instead.
85
+ */
86
+ apply: (tab: Tab, ops: JxPatchOp[], record?: TransactionRecord) => void;
83
87
  /** Schedule a full canvas render as the fallback path, recording the reason. */
84
88
  escalate: (reason: string) => void;
85
89
  }