@jxsuite/studio 0.28.5 → 0.29.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/studio.js +6769 -5399
- package/dist/studio.js.map +86 -79
- package/package.json +5 -4
- package/src/browse/browse-modal.ts +2 -2
- package/src/browse/browse.ts +20 -19
- package/src/canvas/canvas-diff.ts +3 -3
- package/src/canvas/canvas-helpers.ts +15 -2
- package/src/canvas/canvas-live-render.ts +168 -86
- package/src/canvas/canvas-patcher.ts +656 -0
- package/src/canvas/canvas-perf.ts +68 -0
- package/src/canvas/canvas-render.ts +135 -23
- package/src/canvas/canvas-subtree-render.ts +113 -0
- package/src/canvas/canvas-utils.ts +4 -0
- package/src/editor/component-inline-edit.ts +4 -35
- package/src/editor/context-menu.ts +92 -74
- package/src/editor/convert-to-component.ts +11 -2
- package/src/editor/convert-to-repeater.ts +4 -5
- package/src/editor/inline-edit.ts +9 -9
- package/src/editor/inline-format.ts +11 -11
- package/src/editor/merge-tags.ts +120 -0
- package/src/editor/shortcuts.ts +4 -4
- package/src/files/components.ts +37 -0
- package/src/files/file-ops.ts +28 -7
- package/src/files/files.ts +25 -21
- package/src/github/github-auth.ts +14 -2
- package/src/github/github-publish.ts +12 -2
- package/src/panels/activity-bar.ts +1 -1
- package/src/panels/ai-panel.ts +67 -39
- package/src/panels/block-action-bar.ts +72 -1
- package/src/panels/canvas-dnd.ts +55 -26
- package/src/panels/data-explorer.ts +5 -3
- package/src/panels/dnd.ts +12 -15
- package/src/panels/editors.ts +5 -23
- package/src/panels/elements-panel.ts +2 -12
- package/src/panels/events-panel.ts +1 -1
- package/src/panels/git-panel.ts +6 -6
- package/src/panels/head-panel.ts +1 -1
- package/src/panels/layers-panel.ts +177 -147
- package/src/panels/preview-render.ts +15 -0
- package/src/panels/properties-panel.ts +16 -27
- package/src/panels/quick-search.ts +2 -2
- package/src/panels/right-panel.ts +2 -6
- package/src/panels/shared.ts +3 -0
- package/src/panels/signals-panel.ts +24 -22
- package/src/panels/statusbar.ts +15 -6
- package/src/panels/style-panel.ts +3 -3
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-panel.ts +4 -4
- package/src/panels/tab-bar.ts +198 -0
- package/src/panels/tab-strip.ts +2 -2
- package/src/panels/toolbar.ts +6 -75
- package/src/platforms/devserver.ts +60 -34
- package/src/reactivity.ts +2 -0
- package/src/services/cem-export.ts +23 -2
- package/src/services/code-services.ts +16 -2
- package/src/services/monaco-setup.ts +2 -1
- package/src/settings/content-types-editor.ts +16 -16
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/settings/defs-editor.ts +14 -14
- package/src/settings/general-settings.ts +6 -6
- package/src/settings/head-editor.ts +2 -2
- package/src/site-context.ts +1 -1
- package/src/state.ts +66 -12
- package/src/store.ts +15 -3
- package/src/studio.ts +73 -33
- package/src/tabs/patch-ops.ts +143 -0
- package/src/tabs/tab.ts +15 -1
- package/src/tabs/transact.ts +454 -27
- package/src/types.ts +41 -0
- package/src/ui/color-selector.ts +7 -7
- package/src/ui/icons.ts +0 -30
- package/src/ui/media-picker.ts +2 -2
- package/src/ui/panel-resize.ts +6 -1
- package/src/ui/unit-selector.ts +2 -2
- package/src/ui/value-selector.ts +3 -3
- package/src/utils/canvas-media.ts +6 -6
- package/src/utils/edit-display.ts +125 -83
- package/src/utils/google-fonts.ts +1 -1
- package/src/utils/inherited-style.ts +3 -2
- package/src/utils/studio-utils.ts +1 -1
- package/src/view.ts +4 -1
|
@@ -31,7 +31,7 @@ export function renderGeneralSettings(container: HTMLElement) {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const onAdapterChange = (e: Event) => {
|
|
34
|
-
updateSiteConfig({
|
|
34
|
+
void updateSiteConfig({
|
|
35
35
|
build: { ...config.build, adapter: (e.target as HTMLInputElement).value },
|
|
36
36
|
});
|
|
37
37
|
};
|
|
@@ -40,7 +40,7 @@ export function renderGeneralSettings(container: HTMLElement) {
|
|
|
40
40
|
// Lazy import breaks the general-settings ↔ settings-modal module cycle
|
|
41
41
|
const { closeSettingsModal } = await import("./settings-modal");
|
|
42
42
|
closeSettingsModal();
|
|
43
|
-
openFileInTab("project.json");
|
|
43
|
+
void openFileInTab("project.json");
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
// ─── Breakpoints ($media) ───────────────────────────────────────────────────
|
|
@@ -50,7 +50,7 @@ export function renderGeneralSettings(container: HTMLElement) {
|
|
|
50
50
|
|
|
51
51
|
const onMediaValueChange = (key: string) => (e: Event) => {
|
|
52
52
|
const updated = { ...media, [key]: (e.target as HTMLInputElement).value };
|
|
53
|
-
updateSiteConfig({ $media: updated });
|
|
53
|
+
void updateSiteConfig({ $media: updated });
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
const onMediaNameChange = (oldKey: string) => (e: Event) => {
|
|
@@ -63,20 +63,20 @@ export function renderGeneralSettings(container: HTMLElement) {
|
|
|
63
63
|
for (const [k, v] of Object.entries(media)) {
|
|
64
64
|
updated[k === oldKey ? newKey : k] = v;
|
|
65
65
|
}
|
|
66
|
-
updateSiteConfig({ $media: updated });
|
|
66
|
+
void updateSiteConfig({ $media: updated });
|
|
67
67
|
renderGeneralSettings(container);
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
const onRemoveBreakpoint = (key: string) => () => {
|
|
71
71
|
const updated = { ...media };
|
|
72
72
|
delete updated[key];
|
|
73
|
-
updateSiteConfig({ $media: updated });
|
|
73
|
+
void updateSiteConfig({ $media: updated });
|
|
74
74
|
renderGeneralSettings(container);
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
const onAddBreakpoint = () => {
|
|
78
78
|
const updated = { ...media, "--new": "(max-width: 480px)" };
|
|
79
|
-
updateSiteConfig({ $media: updated });
|
|
79
|
+
void updateSiteConfig({ $media: updated });
|
|
80
80
|
renderGeneralSettings(container);
|
|
81
81
|
};
|
|
82
82
|
|
|
@@ -23,7 +23,7 @@ export function renderHeadEditor(container: HTMLElement) {
|
|
|
23
23
|
const headEntries = config.$head || ([] as JxHeadEntry[]);
|
|
24
24
|
|
|
25
25
|
const save = () => {
|
|
26
|
-
updateSiteConfig({ $head: headEntries });
|
|
26
|
+
void updateSiteConfig({ $head: headEntries });
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
const addEntry = (tag: string) => {
|
|
@@ -52,7 +52,7 @@ export function renderHeadEditor(container: HTMLElement) {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
const updateEntry = (idx: number, key: string, val: string) => {
|
|
55
|
-
const entry = headEntries[idx]
|
|
55
|
+
const entry = headEntries[idx]!;
|
|
56
56
|
if (key === "content" && (entry.tagName === "script" || entry.tagName === "style")) {
|
|
57
57
|
entry.textContent = val;
|
|
58
58
|
} else {
|
package/src/site-context.ts
CHANGED
|
@@ -170,7 +170,7 @@ export async function resolveLayoutDoc(layoutPath: string) {
|
|
|
170
170
|
try {
|
|
171
171
|
const platform = getPlatform();
|
|
172
172
|
const content = await platform.readFile(normalized);
|
|
173
|
-
const doc = JSON.parse(content);
|
|
173
|
+
const doc = JSON.parse(content) as JxMutableNode;
|
|
174
174
|
layoutCache.set(normalized, doc);
|
|
175
175
|
return structuredClone(doc);
|
|
176
176
|
} catch {
|
package/src/state.ts
CHANGED
|
@@ -84,6 +84,52 @@ export function childList(node: JxMutableNode | null | undefined): (JxMutableNod
|
|
|
84
84
|
return Array.isArray(node?.children) ? node.children : [];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a document in place to the canonical array-member form: a legacy whole-children
|
|
89
|
+
* repeater (`children: { $prototype: "Array", … }`) becomes a single member of a children array
|
|
90
|
+
* (`children: [{ $prototype: "Array", … }]`). Recurses through children, repeater templates, and
|
|
91
|
+
* `$switch` cases. Runs once when a document is loaded into a tab so every in-studio doc — and its
|
|
92
|
+
* history checkpoints — uses the member form before any mutation.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} node
|
|
95
|
+
* @returns {unknown} The same node (mutated)
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeArrayChildren(node: unknown): unknown {
|
|
98
|
+
if (!node || typeof node !== "object") {
|
|
99
|
+
return node;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(node)) {
|
|
102
|
+
for (const child of node) {
|
|
103
|
+
normalizeArrayChildren(child);
|
|
104
|
+
}
|
|
105
|
+
return node;
|
|
106
|
+
}
|
|
107
|
+
const n = node as JxMutableNode;
|
|
108
|
+
const { children } = n;
|
|
109
|
+
if (
|
|
110
|
+
children &&
|
|
111
|
+
typeof children === "object" &&
|
|
112
|
+
!Array.isArray(children) &&
|
|
113
|
+
(children as JxMutableNode).$prototype === "Array"
|
|
114
|
+
) {
|
|
115
|
+
n.children = [children as JxMutableNode];
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(n.children)) {
|
|
118
|
+
for (const child of n.children) {
|
|
119
|
+
normalizeArrayChildren(child);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (n.$prototype === "Array" && n.map && typeof n.map === "object") {
|
|
123
|
+
normalizeArrayChildren(n.map);
|
|
124
|
+
}
|
|
125
|
+
if (n.cases && typeof n.cases === "object") {
|
|
126
|
+
for (const caseDef of Object.values(n.cases)) {
|
|
127
|
+
normalizeArrayChildren(caseDef);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return node;
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
/**
|
|
88
134
|
* Return the path to the parent element (strips trailing 'children' + index).
|
|
89
135
|
*
|
|
@@ -180,6 +226,19 @@ export function flattenTree(
|
|
|
180
226
|
return [{ depth, node: doc, nodeType: "text", path }];
|
|
181
227
|
}
|
|
182
228
|
|
|
229
|
+
// Array pseudo-element (repeater): a first-class node at its own path. Emit the "map" row, then
|
|
230
|
+
// Recurse into its single template at `[...path, "map"]`. This is reached both when the array is
|
|
231
|
+
// A member of a children array (path `[…, "children", i]`) and the legacy whole-children form
|
|
232
|
+
// (path `[…, "children"]`).
|
|
233
|
+
if ((doc as JxMutableNode).$prototype === "Array") {
|
|
234
|
+
const rows: FlatRow[] = [{ depth, node: doc, nodeType: "map", path }];
|
|
235
|
+
const mapDef = (doc as JxMutableNode).map;
|
|
236
|
+
if (mapDef && typeof mapDef === "object") {
|
|
237
|
+
rows.push(...flattenTree(mapDef as JxMutableNode, [...path, "map"], depth + 1));
|
|
238
|
+
}
|
|
239
|
+
return rows;
|
|
240
|
+
}
|
|
241
|
+
|
|
183
242
|
const rows: FlatRow[] = [{ depth, node: doc, nodeType: "element", path }];
|
|
184
243
|
|
|
185
244
|
// Custom component instances without user-authored children are atomic in the layer tree
|
|
@@ -192,24 +251,15 @@ export function flattenTree(
|
|
|
192
251
|
if (Array.isArray(children)) {
|
|
193
252
|
for (let i = 0; i < children.length; i++) {
|
|
194
253
|
const childPath = [...path, "children", i];
|
|
195
|
-
rows.push(...flattenTree(children[i]
|
|
254
|
+
rows.push(...flattenTree(children[i]!, childPath, depth + 1));
|
|
196
255
|
}
|
|
197
256
|
} else if (
|
|
198
257
|
children &&
|
|
199
258
|
typeof children === "object" &&
|
|
200
259
|
(children as JxMutableNode).$prototype === "Array"
|
|
201
260
|
) {
|
|
202
|
-
//
|
|
203
|
-
rows.push(
|
|
204
|
-
depth: depth + 1,
|
|
205
|
-
node: children as JxMutableNode,
|
|
206
|
-
nodeType: "map",
|
|
207
|
-
path: [...path, "children"],
|
|
208
|
-
});
|
|
209
|
-
const mapDef = (children as JxMutableNode).map;
|
|
210
|
-
if (mapDef && typeof mapDef === "object") {
|
|
211
|
-
rows.push(...flattenTree(mapDef as JxMutableNode, [...path, "children", "map"], depth + 2));
|
|
212
|
-
}
|
|
261
|
+
// Legacy whole-children repeater: the array occupies the children slot itself.
|
|
262
|
+
rows.push(...flattenTree(children as JxMutableNode, [...path, "children"], depth + 1));
|
|
213
263
|
}
|
|
214
264
|
|
|
215
265
|
// $switch — emit each case as a virtual child
|
|
@@ -262,6 +312,10 @@ export function nodeLabel(node: JxMutableNode | null) {
|
|
|
262
312
|
if (node.$id) {
|
|
263
313
|
return node.$id;
|
|
264
314
|
}
|
|
315
|
+
if (node.tagName === "slot") {
|
|
316
|
+
const name = node.attributes?.name;
|
|
317
|
+
return typeof name === "string" && name.trim() ? `slot: ${name.trim()}` : "slot";
|
|
318
|
+
}
|
|
265
319
|
const tag = node.tagName ?? "div";
|
|
266
320
|
const suffix = node.$switch ? " ⇆" : "";
|
|
267
321
|
if (typeof node.textContent === "string" && node.textContent.length > 0) {
|
package/src/store.ts
CHANGED
|
@@ -54,6 +54,18 @@ export function initShellRefs() {
|
|
|
54
54
|
|
|
55
55
|
export const elToPath = new WeakMap<Element, JxPath>();
|
|
56
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
|
+
|
|
57
69
|
export const canvasPanels: CanvasPanel[] = [];
|
|
58
70
|
|
|
59
71
|
// ─── Shared constants ────────────────────────────────────────────────────────
|
|
@@ -97,7 +109,7 @@ export function isNestedSelector(k: string) {
|
|
|
97
109
|
|
|
98
110
|
// ─── Shared utilities ────────────────────────────────────────────────────────
|
|
99
111
|
|
|
100
|
-
const _styleDebounceTimers = new Map();
|
|
112
|
+
const _styleDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
101
113
|
|
|
102
114
|
/**
|
|
103
115
|
* @param {string} prop
|
|
@@ -139,7 +151,7 @@ export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
|
|
|
139
151
|
}
|
|
140
152
|
if (Array.isArray(node)) {
|
|
141
153
|
// Arrays of nodes round-trip element-wise; the array itself is not a node.
|
|
142
|
-
return node.map((n) => stripEventHandlers(n)) as unknown as JxMutableNode;
|
|
154
|
+
return node.map((n: JxMutableNode) => stripEventHandlers(n)) as unknown as JxMutableNode;
|
|
143
155
|
}
|
|
144
156
|
const out: Record<string, unknown> = {};
|
|
145
157
|
for (const [k, v] of Object.entries(node)) {
|
|
@@ -148,7 +160,7 @@ export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
|
|
|
148
160
|
}
|
|
149
161
|
if (k === "children") {
|
|
150
162
|
out.children = Array.isArray(v)
|
|
151
|
-
? v.map((c) => stripEventHandlers(c))
|
|
163
|
+
? v.map((c: JxMutableNode) => stripEventHandlers(c))
|
|
152
164
|
: stripEventHandlers(v as JxMutableNode);
|
|
153
165
|
} else if (k === "cases" && typeof v === "object") {
|
|
154
166
|
const cases: Record<string, unknown> = {};
|
package/src/studio.ts
CHANGED
|
@@ -29,11 +29,19 @@ import { effect } from "./reactivity";
|
|
|
29
29
|
import { view } from "./view";
|
|
30
30
|
|
|
31
31
|
import { isEditableBlock, isEditing } from "./editor/inline-edit";
|
|
32
|
-
import { initComponentInlineEdit } from "./editor/component-inline-edit";
|
|
32
|
+
import { enterComponentInlineEdit, initComponentInlineEdit } from "./editor/component-inline-edit";
|
|
33
33
|
import { enterInlineEdit } from "./editor/content-inline-edit";
|
|
34
34
|
import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
|
|
35
35
|
import { findCanvasElement, getActivePanel, initCanvasHelpers } from "./canvas/canvas-helpers";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
applyCanvasMediaOverrides,
|
|
38
|
+
initCanvasRender,
|
|
39
|
+
renderCanvas,
|
|
40
|
+
renderOverlays,
|
|
41
|
+
scheduleCanvasRender,
|
|
42
|
+
} from "./canvas/canvas-render";
|
|
43
|
+
import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patcher";
|
|
44
|
+
import { registerSubtreeDnD } from "./panels/canvas-dnd";
|
|
37
45
|
import { initCanvasLiveRender } from "./canvas/canvas-live-render";
|
|
38
46
|
import {
|
|
39
47
|
mountStatusbar,
|
|
@@ -84,6 +92,7 @@ import * as overlaysPanel from "./panels/overlays";
|
|
|
84
92
|
import * as rightPanelMod from "./panels/right-panel";
|
|
85
93
|
import * as leftPanelMod from "./panels/left-panel";
|
|
86
94
|
import * as tabStrip from "./panels/tab-strip";
|
|
95
|
+
import * as tabBar from "./panels/tab-bar";
|
|
87
96
|
import { renderStylebookOverlays } from "./panels/stylebook-panel";
|
|
88
97
|
import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd";
|
|
89
98
|
import { defaultDef } from "./panels/shared";
|
|
@@ -98,7 +107,7 @@ import { initWelcome } from "./panels/welcome-screen";
|
|
|
98
107
|
import { openNewProjectModal } from "./new-project/new-project-modal";
|
|
99
108
|
import type { DocumentStackEntry, GitDiffState } from "./types";
|
|
100
109
|
import type { JxPath } from "./state";
|
|
101
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
110
|
+
import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
|
|
102
111
|
|
|
103
112
|
void _swc;
|
|
104
113
|
|
|
@@ -133,7 +142,7 @@ async function navigateToComponent(componentPath: string) {
|
|
|
133
142
|
if (!content) {
|
|
134
143
|
return;
|
|
135
144
|
}
|
|
136
|
-
const parsed = JSON.parse(content);
|
|
145
|
+
const parsed = JSON.parse(content) as JxMutableNode;
|
|
137
146
|
const tab = activeTab.value;
|
|
138
147
|
if (!tab) {
|
|
139
148
|
return;
|
|
@@ -289,7 +298,7 @@ requestIdleCallback(() => {
|
|
|
289
298
|
const frag = document.createDocumentFragment();
|
|
290
299
|
for (const [name] of webdata.cssProps) {
|
|
291
300
|
const opt = document.createElement("option");
|
|
292
|
-
opt.value = name
|
|
301
|
+
opt.value = name!;
|
|
293
302
|
frag.append(opt);
|
|
294
303
|
}
|
|
295
304
|
dl.append(frag);
|
|
@@ -316,11 +325,8 @@ initShellRefs();
|
|
|
316
325
|
toolbarPanel.mount(toolbarEl, {
|
|
317
326
|
closeFunctionEditor: () => closeFunctionEditor(),
|
|
318
327
|
getCanvasMode,
|
|
319
|
-
navigateBack: () => navigateBack(),
|
|
320
|
-
navigateToLevel: (i: number) => navigateToLevel(i),
|
|
321
328
|
openProject: () => openProject(),
|
|
322
329
|
openRecentProject: (root: string) => openRecentProject(root),
|
|
323
|
-
parseMediaEntries,
|
|
324
330
|
renderCanvas: () => renderCanvas(),
|
|
325
331
|
safeRenderRightPanel: () => safeRenderRightPanel(),
|
|
326
332
|
saveFile: () => saveFile(),
|
|
@@ -332,6 +338,15 @@ initQuickSearch();
|
|
|
332
338
|
|
|
333
339
|
tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
|
|
334
340
|
|
|
341
|
+
tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
|
|
342
|
+
closeFunctionEditor: () => closeFunctionEditor(),
|
|
343
|
+
exportFile,
|
|
344
|
+
getCanvasMode,
|
|
345
|
+
navigateBack: () => navigateBack(),
|
|
346
|
+
navigateToLevel: (i: number) => navigateToLevel(i),
|
|
347
|
+
parseMediaEntries,
|
|
348
|
+
});
|
|
349
|
+
|
|
335
350
|
overlaysPanel.mount({
|
|
336
351
|
getCanvasMode,
|
|
337
352
|
isEditing,
|
|
@@ -366,9 +381,16 @@ initPanelEvents({
|
|
|
366
381
|
initCanvasLiveRender({
|
|
367
382
|
getCanvasMode,
|
|
368
383
|
});
|
|
384
|
+
initCanvasPatcher({
|
|
385
|
+
applyCanvasMediaOverrides,
|
|
386
|
+
enterComponentInlineEdit,
|
|
387
|
+
getCanvasMode,
|
|
388
|
+
registerSubtreeDnD,
|
|
389
|
+
renderOverlays,
|
|
390
|
+
scheduleCanvasRender,
|
|
391
|
+
updateForcedPseudoPreview,
|
|
392
|
+
});
|
|
369
393
|
initCanvasRender({
|
|
370
|
-
closeFunctionEditor: () => closeFunctionEditor(),
|
|
371
|
-
exportFile,
|
|
372
394
|
getCanvasMode,
|
|
373
395
|
get gitDiffState() {
|
|
374
396
|
return gitDiffState;
|
|
@@ -385,20 +407,32 @@ initWelcome({
|
|
|
385
407
|
openNewProject: async () => {
|
|
386
408
|
const result = await openNewProjectModal();
|
|
387
409
|
if (result) {
|
|
388
|
-
openRecentProject(result.root);
|
|
410
|
+
void openRecentProject(result.root);
|
|
389
411
|
}
|
|
390
412
|
},
|
|
391
413
|
openProject: () => openProject(),
|
|
392
414
|
openRecentProject: (root: string) => openRecentProject(root),
|
|
393
415
|
});
|
|
394
416
|
|
|
395
|
-
// Effect-driven canvas rendering
|
|
396
|
-
//
|
|
397
|
-
|
|
417
|
+
// Effect-driven canvas rendering, split into two triggers so document changes can be
|
|
418
|
+
// Distinguished from mode/UI changes:
|
|
419
|
+
// - doc-effect: tracks only the document root reference. Document mutations that were
|
|
420
|
+
// Consumed surgically by the canvas patcher skip the full render here.
|
|
421
|
+
// - ui-effect: tracks canvas mode and UI flags; always schedules a full render.
|
|
422
|
+
// Scheduling is deduped inside scheduleCanvasRender (double-RAF).
|
|
423
|
+
effect(() => {
|
|
424
|
+
const tab = activeTab.value;
|
|
425
|
+
if (tab) {
|
|
426
|
+
const doc = tab.doc.document;
|
|
427
|
+
if (doc && consumePatchedDocument(doc)) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
scheduleCanvasRender();
|
|
432
|
+
});
|
|
398
433
|
effect(() => {
|
|
399
434
|
const tab = activeTab.value;
|
|
400
435
|
if (tab) {
|
|
401
|
-
void tab.doc.document;
|
|
402
436
|
void tab.doc.mode;
|
|
403
437
|
void tab.session.ui.canvasMode;
|
|
404
438
|
void tab.session.ui.editingFunction;
|
|
@@ -408,18 +442,7 @@ effect(() => {
|
|
|
408
442
|
void tab.session.ui.stylebookFilter;
|
|
409
443
|
void tab.session.ui.stylebookCustomizedOnly;
|
|
410
444
|
}
|
|
411
|
-
|
|
412
|
-
_canvasRafId = requestAnimationFrame(() => {
|
|
413
|
-
_canvasRafId = requestAnimationFrame(() => {
|
|
414
|
-
_canvasRafId = 0;
|
|
415
|
-
try {
|
|
416
|
-
renderCanvas();
|
|
417
|
-
} catch (error) {
|
|
418
|
-
console.error("renderCanvas error:", error);
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
});
|
|
422
|
-
}
|
|
445
|
+
scheduleCanvasRender();
|
|
423
446
|
});
|
|
424
447
|
|
|
425
448
|
rightPanelMod.mount({
|
|
@@ -499,7 +522,7 @@ if (_projectParam) {
|
|
|
499
522
|
render();
|
|
500
523
|
const platform = getPlatform();
|
|
501
524
|
// oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: project probing must not block the initial render
|
|
502
|
-
(async () => {
|
|
525
|
+
void (async () => {
|
|
503
526
|
try {
|
|
504
527
|
const siteCtx = platform.resolveSiteContext
|
|
505
528
|
? await platform.resolveSiteContext(_projectParam)
|
|
@@ -592,14 +615,14 @@ if (_projectParam) {
|
|
|
592
615
|
({ frontmatter } = result);
|
|
593
616
|
parsedMode = result.mode;
|
|
594
617
|
} else {
|
|
595
|
-
parsedDoc = JSON.parse(content);
|
|
618
|
+
parsedDoc = JSON.parse(content) as JxMutableNode;
|
|
596
619
|
}
|
|
597
620
|
|
|
598
621
|
// Open in a tab
|
|
599
622
|
openTab({
|
|
600
623
|
id: fileRelPath,
|
|
601
624
|
documentPath: fileRelPath,
|
|
602
|
-
document: parsedDoc,
|
|
625
|
+
document: parsedDoc as JxMutableNode,
|
|
603
626
|
...(frontmatter != null && { frontmatter }),
|
|
604
627
|
sourceFormat: fileFormat?.name ?? null,
|
|
605
628
|
});
|
|
@@ -621,7 +644,7 @@ if (_projectParam) {
|
|
|
621
644
|
}
|
|
622
645
|
} else {
|
|
623
646
|
// Normal mode: probe for project at server root
|
|
624
|
-
loadProject();
|
|
647
|
+
void loadProject();
|
|
625
648
|
render();
|
|
626
649
|
}
|
|
627
650
|
|
|
@@ -643,9 +666,26 @@ function openProject() {
|
|
|
643
666
|
async function openRecentProject(root: string) {
|
|
644
667
|
try {
|
|
645
668
|
const platform = getPlatform();
|
|
669
|
+
|
|
670
|
+
// Multi-window (desktop): if this window already holds a project, open the chosen one in a new
|
|
671
|
+
// Window (focusing an existing window if it's already open) rather than replacing this project.
|
|
672
|
+
if (projectState && platform.openProjectInNewWindow) {
|
|
673
|
+
await platform.openProjectInNewWindow(root);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Multi-window (desktop): bind THIS window's backend to the project before reading from it. If
|
|
678
|
+
// The project is already open in another window, that window is focused and we bail here.
|
|
679
|
+
if (platform.setWindowProject) {
|
|
680
|
+
const res = await platform.setWindowProject(root);
|
|
681
|
+
if (res.deduped) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
646
686
|
platform.projectRoot = root;
|
|
647
687
|
const content = await platform.readFile("project.json");
|
|
648
|
-
const config = JSON.parse(content);
|
|
688
|
+
const config = JSON.parse(content) as ProjectConfig;
|
|
649
689
|
|
|
650
690
|
closeAllTabs();
|
|
651
691
|
|
|
@@ -654,7 +694,7 @@ async function openRecentProject(root: string) {
|
|
|
654
694
|
dirs: new Map(),
|
|
655
695
|
expanded: new Set(),
|
|
656
696
|
isSiteProject: true,
|
|
657
|
-
name: config.name || root.split("/").pop()
|
|
697
|
+
name: config.name || root.split("/").pop()!,
|
|
658
698
|
projectConfig: config,
|
|
659
699
|
projectRoot: root,
|
|
660
700
|
searchQuery: "",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch ops — typed descriptors of document mutations, recorded by the mutators in transact.ts
|
|
3
|
+
* during a transaction. The canvas patcher consumes them to update the live canvas DOM surgically
|
|
4
|
+
* instead of re-rendering everything. Ops carry paths, not values: consumers read the post-mutation
|
|
5
|
+
* document as the single source of truth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { JxPath } from "../state";
|
|
9
|
+
import type { Tab } from "./tab.js";
|
|
10
|
+
|
|
11
|
+
export type JxPatchOp =
|
|
12
|
+
/** The node's style changed (any depth: base prop, @media block, nested selector). */
|
|
13
|
+
| { op: "set-style"; path: JxPath }
|
|
14
|
+
/** The node's textContent changed. */
|
|
15
|
+
| { op: "set-text"; path: JxPath }
|
|
16
|
+
/** A non-style, non-text node property changed (key = property name). */
|
|
17
|
+
| { op: "set-prop"; path: JxPath; key: string; isEvent?: boolean }
|
|
18
|
+
/** The node's attributes[attr] changed. */
|
|
19
|
+
| { op: "set-attr"; path: JxPath; attr: string }
|
|
20
|
+
/** A node was inserted at parentPath.children[index]. */
|
|
21
|
+
| { op: "insert"; parentPath: JxPath; index: number }
|
|
22
|
+
/** The node at path was removed. */
|
|
23
|
+
| { op: "remove"; path: JxPath }
|
|
24
|
+
/** The node moved between/within children arrays (indices are post-mutation document truth). */
|
|
25
|
+
| { op: "move"; fromPath: JxPath; toParentPath: JxPath; toIndex: number }
|
|
26
|
+
/** The node at path was structurally replaced (wrap, tagName change, $props edit). */
|
|
27
|
+
| { op: "replace"; path: JxPath }
|
|
28
|
+
/** A document-level key changed (state/$media/$head/$elements/imports/$layout) — escalates. */
|
|
29
|
+
| { op: "doc-meta"; key: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Value-carrying document mutation, replayable in either direction. Mutators record a
|
|
33
|
+
* forward/inverse pair per change; history applies them for surgical undo/redo and for
|
|
34
|
+
* materializing states from checkpoints — without whole-document snapshots per edit.
|
|
35
|
+
*/
|
|
36
|
+
export type JxDocOp =
|
|
37
|
+
/** Set (or delete, when value is undefined) a key on the node at path. */
|
|
38
|
+
| { op: "set-key"; path: JxPath; key: string; value?: unknown }
|
|
39
|
+
/** Insert a node at parentPath.children[index]. */
|
|
40
|
+
| { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
|
|
41
|
+
/** Remove parentPath.children[index]. */
|
|
42
|
+
| { op: "remove-child"; parentPath: JxPath; index: number }
|
|
43
|
+
/** Replace parentPath.children[index] with node. */
|
|
44
|
+
| { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
|
|
45
|
+
/** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
|
|
46
|
+
| {
|
|
47
|
+
op: "move-child";
|
|
48
|
+
fromParentPath: JxPath;
|
|
49
|
+
fromIndex: number;
|
|
50
|
+
toParentPath: JxPath;
|
|
51
|
+
toIndex: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface JxDocOpPair {
|
|
55
|
+
forward: JxDocOp;
|
|
56
|
+
inverse: JxDocOp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Everything recorded during one transaction. */
|
|
60
|
+
export interface TransactionRecord {
|
|
61
|
+
/** Canvas patch ops (path-only) for surgical DOM updates. */
|
|
62
|
+
ops: JxPatchOp[];
|
|
63
|
+
/** Replayable forward/inverse document ops for patch-based history. */
|
|
64
|
+
docOps: JxDocOpPair[];
|
|
65
|
+
/**
|
|
66
|
+
* False when some mutation in the transaction could not produce a guaranteed-correct inverse
|
|
67
|
+
* (e.g. a move whose parents' paths interact) — history falls back to a checkpoint snapshot.
|
|
68
|
+
*/
|
|
69
|
+
invertible: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The canvas-side consumer of patch batches. Registered by the canvas patcher at studio init;
|
|
74
|
+
* absent in headless tests, where every transaction falls back to the full-render path.
|
|
75
|
+
*/
|
|
76
|
+
export interface PatchConsumer {
|
|
77
|
+
/** Decide whether this batch can be applied surgically in the current canvas state. */
|
|
78
|
+
classify: (tab: Tab, ops: JxPatchOp[]) => { patchable: boolean; reason: string };
|
|
79
|
+
/** Mark a document root reference as surgically consumed (checked by the canvas doc-effect). */
|
|
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;
|
|
83
|
+
/** Schedule a full canvas render as the fallback path, recording the reason. */
|
|
84
|
+
escalate: (reason: string) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let _consumer: PatchConsumer | null = null;
|
|
88
|
+
|
|
89
|
+
export function setPatchConsumer(consumer: PatchConsumer | null) {
|
|
90
|
+
_consumer = consumer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getPatchConsumer(): PatchConsumer | null {
|
|
94
|
+
return _consumer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Recording ───────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
let _recording: JxPatchOp[] | null = null;
|
|
100
|
+
let _docOps: JxDocOpPair[] | null = null;
|
|
101
|
+
let _invertible = true;
|
|
102
|
+
|
|
103
|
+
/** Begin recording patch ops for a transaction. */
|
|
104
|
+
export function beginRecording() {
|
|
105
|
+
_recording = [];
|
|
106
|
+
_docOps = [];
|
|
107
|
+
_invertible = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Record a patch op describing an in-place mutation. No-op outside a transaction. */
|
|
111
|
+
export function recordPatch(op: JxPatchOp) {
|
|
112
|
+
if (_recording) {
|
|
113
|
+
_recording.push(op);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Record a replayable forward/inverse document-op pair. No-op outside a transaction. */
|
|
118
|
+
export function recordDocOp(pair: JxDocOpPair) {
|
|
119
|
+
if (_docOps) {
|
|
120
|
+
_docOps.push(pair);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mark the current transaction as non-invertible — history stores a checkpoint instead. */
|
|
125
|
+
export function markNonInvertible() {
|
|
126
|
+
_invertible = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* End recording and return everything recorded. An empty ops array means the mutation ran without
|
|
131
|
+
* instrumentation (unknown change shape) — callers must treat that as not patchable.
|
|
132
|
+
*/
|
|
133
|
+
export function endRecording(): TransactionRecord {
|
|
134
|
+
const record: TransactionRecord = {
|
|
135
|
+
docOps: _docOps ?? [],
|
|
136
|
+
invertible: _invertible,
|
|
137
|
+
ops: _recording ?? [],
|
|
138
|
+
};
|
|
139
|
+
_recording = null;
|
|
140
|
+
_docOps = null;
|
|
141
|
+
_invertible = true;
|
|
142
|
+
return record;
|
|
143
|
+
}
|
package/src/tabs/tab.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
import { effectScope, reactive } from "../reactivity";
|
|
3
3
|
import { formatByName, formatForPath } from "../format/format-host";
|
|
4
|
+
import { normalizeArrayChildren } from "../state";
|
|
4
5
|
import type {
|
|
5
6
|
DocumentStackEntry,
|
|
6
7
|
FunctionEditDef,
|
|
@@ -10,6 +11,7 @@ import type {
|
|
|
10
11
|
InlineEditDef,
|
|
11
12
|
} from "../types";
|
|
12
13
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
14
|
+
import type { JxDocOp } from "./patch-ops";
|
|
13
15
|
|
|
14
16
|
export interface TabUi {
|
|
15
17
|
rightTab: string;
|
|
@@ -39,8 +41,16 @@ export interface TabUi {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
interface HistorySnapshot {
|
|
42
|
-
document
|
|
44
|
+
/** Full document at this state (checkpoint), or null when the ops describe the transition. */
|
|
45
|
+
document: Record<string, unknown> | null;
|
|
46
|
+
/** Selection after this state's transaction. */
|
|
43
47
|
selection: (string | number)[] | null;
|
|
48
|
+
/** Selection before this state's transaction (restored on surgical undo). */
|
|
49
|
+
selectionBefore?: (string | number)[] | null;
|
|
50
|
+
/** Replayable ops transforming the previous state into this one. */
|
|
51
|
+
forwardOps?: JxDocOp[] | null;
|
|
52
|
+
/** Replayable ops transforming this state back into the previous one. */
|
|
53
|
+
inverseOps?: JxDocOp[] | null;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export interface Tab {
|
|
@@ -145,6 +155,10 @@ export function createTab({
|
|
|
145
155
|
}) {
|
|
146
156
|
const scope = effectScope();
|
|
147
157
|
|
|
158
|
+
// Normalize legacy whole-children repeaters to the canonical array-member form before the doc
|
|
159
|
+
// (and its first history checkpoint) are stored.
|
|
160
|
+
normalizeArrayChildren(document);
|
|
161
|
+
|
|
148
162
|
const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
|
|
149
163
|
|
|
150
164
|
const tab = scope.run(() => ({
|