@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.
- package/dist/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +386 -328
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +18 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +33 -69
- package/src/types.ts +16 -24
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- 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 : "#
|
|
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", "#
|
|
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 "./
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
+
}
|
package/src/tabs/patch-ops.ts
CHANGED
|
@@ -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
|
-
/**
|
|
82
|
-
|
|
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
|
}
|