@jxsuite/studio 0.28.5 → 0.30.1
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.css +98 -98
- package/dist/studio.js +9368 -6586
- package/dist/studio.js.map +109 -83
- package/dist/workers/editor.worker.js +79 -79
- package/dist/workers/json.worker.js +109 -109
- package/dist/workers/ts.worker.js +82 -82
- package/package.json +11 -10
- 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 +96 -76
- 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 +68 -24
- package/src/files/fs-events.ts +162 -0
- 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 +14 -17
- 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 +43 -33
- 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 +113 -35
- 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 +88 -35
- 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 +71 -1
- 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 +14 -8
- package/src/ui/unit-selector.ts +5 -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
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Canvas patcher — applies recorded document mutations (patch ops) surgically to the live canvas
|
|
4
|
+
* DOM instead of re-rendering everything. Registered as the patch consumer for transactDoc.
|
|
5
|
+
*
|
|
6
|
+
* Safety model: classify() admits only changes it can prove are safe to patch in the current canvas
|
|
7
|
+
* state; everything else (and any exception during apply) escalates to the existing full-render
|
|
8
|
+
* path. The failure mode of any patcher bug is a full render — today's behavior — never a corrupt
|
|
9
|
+
* canvas.
|
|
10
|
+
*
|
|
11
|
+
* Patching is only attempted in design/edit canvas modes, where the canvas renders through
|
|
12
|
+
* prepareForEditMode: runtime reactive bindings are inert there, so the patcher is the only writer
|
|
13
|
+
* to the patched DOM.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
canvasPanels,
|
|
18
|
+
childIndex,
|
|
19
|
+
elToPath,
|
|
20
|
+
elToRenderScope,
|
|
21
|
+
getNodeAtPath,
|
|
22
|
+
parentElementPath,
|
|
23
|
+
} from "../store";
|
|
24
|
+
import { activeTab } from "../workspace/workspace";
|
|
25
|
+
import { view } from "../view";
|
|
26
|
+
import { toRaw } from "../reactivity";
|
|
27
|
+
import { elementStyleTags, reapplyStyle } from "@jxsuite/runtime";
|
|
28
|
+
import { getEffectiveMedia } from "../site-context";
|
|
29
|
+
import { findCanvasElement } from "./canvas-helpers";
|
|
30
|
+
import { domChildReference, renderSubtree } from "./canvas-subtree-render";
|
|
31
|
+
import { canvasPerf, perfLog, recordEscalation } from "./canvas-perf";
|
|
32
|
+
import {
|
|
33
|
+
computeEmptyPlaceholderClass,
|
|
34
|
+
EMPTY_PLACEHOLDER_CLASSES,
|
|
35
|
+
templateToEditDisplay,
|
|
36
|
+
} from "../utils/edit-display";
|
|
37
|
+
import { getActiveElement } from "../editor/inline-edit";
|
|
38
|
+
import { setPatchConsumer } from "../tabs/patch-ops";
|
|
39
|
+
|
|
40
|
+
import type { JxPatchOp } from "../tabs/patch-ops";
|
|
41
|
+
import type { Tab } from "../tabs/tab.js";
|
|
42
|
+
import type { JxPath } from "../state";
|
|
43
|
+
import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
|
|
44
|
+
import type { CanvasPanel, InlineEditDef } from "../types";
|
|
45
|
+
|
|
46
|
+
/** Render-side callbacks injected at init so this module stays free of heavy canvas imports. */
|
|
47
|
+
interface CanvasPatcherCtx {
|
|
48
|
+
getCanvasMode: () => string;
|
|
49
|
+
scheduleCanvasRender: () => void;
|
|
50
|
+
applyCanvasMediaOverrides: (canvasEl: Element, activeBreakpoints: Set<string>) => void;
|
|
51
|
+
renderOverlays: () => void;
|
|
52
|
+
updateForcedPseudoPreview: () => void;
|
|
53
|
+
enterComponentInlineEdit: (el: HTMLElement, path: JxPath) => void;
|
|
54
|
+
registerSubtreeDnD: (rootEl: HTMLElement) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let _ctx: CanvasPatcherCtx | null = null;
|
|
58
|
+
|
|
59
|
+
/** Document root references whose change was applied surgically (checked by the doc-effect). */
|
|
60
|
+
const _consumed = new WeakSet<object>();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize the canvas patcher and register it as the transactDoc patch consumer.
|
|
64
|
+
*
|
|
65
|
+
* @param {CanvasPatcherCtx} ctx
|
|
66
|
+
*/
|
|
67
|
+
export function initCanvasPatcher(ctx: CanvasPatcherCtx) {
|
|
68
|
+
_ctx = ctx;
|
|
69
|
+
setPatchConsumer({
|
|
70
|
+
apply: applyPatchBatch,
|
|
71
|
+
classify: classifyOps,
|
|
72
|
+
escalate: escalateToFullRender,
|
|
73
|
+
markConsumed: (docRef: object) => {
|
|
74
|
+
_consumed.add(toRaw(docRef) as object);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One-shot check used by the canvas doc-effect: returns true (and clears the mark) when the given
|
|
81
|
+
* document reference was already applied to the canvas surgically, so the full render can be
|
|
82
|
+
* skipped. One-shot so tab switches and repeat triggers still render fully.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} doc
|
|
85
|
+
*/
|
|
86
|
+
export function consumePatchedDocument(doc: object): boolean {
|
|
87
|
+
const raw = toRaw(doc) as object;
|
|
88
|
+
if (_consumed.has(raw)) {
|
|
89
|
+
_consumed.delete(raw);
|
|
90
|
+
canvasPerf.skippedFullRenders += 1;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Schedule the fallback full render and record why. */
|
|
97
|
+
export function escalateToFullRender(reason: string) {
|
|
98
|
+
recordEscalation(reason);
|
|
99
|
+
_ctx?.scheduleCanvasRender();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Classification ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Identity check that survives reactive proxy wrapping (activeTab.value is a proxy). */
|
|
105
|
+
function isActiveTab(tab: Tab) {
|
|
106
|
+
const active = activeTab.value;
|
|
107
|
+
return active !== null && toRaw(active as object) === toRaw(tab as unknown as object);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* $switch cases render as a substituted first-case placeholder in edit mode, so their element paths
|
|
112
|
+
* don't correspond to document paths — edits through "cases" escalate to a full render. (Non-
|
|
113
|
+
* structural edits to a repeater template — text/style/prop on a `…/map` path — DO patch, since the
|
|
114
|
+
* template renders as a single 1:1 instance inside its perimeter; structural edits inside a
|
|
115
|
+
* template escalate via {@link containerVerdict}.)
|
|
116
|
+
*/
|
|
117
|
+
function pathHasDynamicSegment(path: JxPath) {
|
|
118
|
+
return path.includes("cases");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether a children array at parentPath can be structurally patched: no $switch/template segments,
|
|
123
|
+
* no custom-element ancestors (slot redistribution breaks doc↔DOM child correspondence), no
|
|
124
|
+
* innerHTML on the parent, and a real children array.
|
|
125
|
+
*
|
|
126
|
+
* @param {Tab} tab
|
|
127
|
+
* @param {JxPath} parentPath
|
|
128
|
+
*/
|
|
129
|
+
function containerVerdict(tab: Tab, parentPath: JxPath, requireArray = true): string | null {
|
|
130
|
+
if (pathHasDynamicSegment(parentPath)) {
|
|
131
|
+
return "structure-on-cases-path";
|
|
132
|
+
}
|
|
133
|
+
// Structural edits inside a repeater template (a "map" segment) escalate: the edit-mode perimeter
|
|
134
|
+
// Holds one template instance, so changing the template's child count can't be spliced 1:1.
|
|
135
|
+
if (parentPath.includes("map")) {
|
|
136
|
+
return "structure-on-map-path";
|
|
137
|
+
}
|
|
138
|
+
const doc = tab.doc.document;
|
|
139
|
+
for (let i = 0; i <= parentPath.length; i += 2) {
|
|
140
|
+
const node = getNodeAtPath(doc, parentPath.slice(0, i));
|
|
141
|
+
if (!node) {
|
|
142
|
+
return "node-not-found";
|
|
143
|
+
}
|
|
144
|
+
if (typeof node.tagName === "string" && node.tagName.includes("-")) {
|
|
145
|
+
return "structure-in-custom-element";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const parent = getNodeAtPath(doc, parentPath);
|
|
149
|
+
if (parent.innerHTML) {
|
|
150
|
+
return "structure-with-innerhtml";
|
|
151
|
+
}
|
|
152
|
+
// Index-based splicing needs a real children array. An array (repeater) node itself has no
|
|
153
|
+
// Children array (its content is the `map` template), so inserts/moves targeting it escalate.
|
|
154
|
+
if (requireArray && !Array.isArray(parent.children)) {
|
|
155
|
+
return "structure-children-not-array";
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Verdict for ops applied as a subtree replace at `path`. */
|
|
161
|
+
function replaceVerdict(tab: Tab, path: JxPath): string | null {
|
|
162
|
+
if (path.length === 0) {
|
|
163
|
+
return "replace-root";
|
|
164
|
+
}
|
|
165
|
+
if (pathHasDynamicSegment(path)) {
|
|
166
|
+
return "replace-on-cases-path";
|
|
167
|
+
}
|
|
168
|
+
const parentPath = parentElementPath(path) as JxPath | null;
|
|
169
|
+
if (!parentPath) {
|
|
170
|
+
return "replace-no-parent";
|
|
171
|
+
}
|
|
172
|
+
const container = containerVerdict(tab, parentPath, false);
|
|
173
|
+
if (container) {
|
|
174
|
+
return container;
|
|
175
|
+
}
|
|
176
|
+
return getNodeAtPath(tab.doc.document, path) === undefined ? "node-not-found" : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const STRUCTURAL_OPS = new Set(["insert", "move", "remove", "replace", "set-attr", "set-prop"]);
|
|
180
|
+
|
|
181
|
+
/** Whether an op changes DOM structure (vs in-place style/text writes). */
|
|
182
|
+
function isStructuralOp(op: JxPatchOp) {
|
|
183
|
+
return STRUCTURAL_OPS.has(op.op) && !(op.op === "set-prop" && op.isEvent);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Per-op patchability in the current document. Returns null when patchable, else the reason.
|
|
188
|
+
*
|
|
189
|
+
* @param {Tab} tab
|
|
190
|
+
* @param {JxPatchOp} op
|
|
191
|
+
*/
|
|
192
|
+
function opVerdict(tab: Tab, op: JxPatchOp): string | null {
|
|
193
|
+
switch (op.op) {
|
|
194
|
+
case "set-style": {
|
|
195
|
+
if (pathHasDynamicSegment(op.path)) {
|
|
196
|
+
return "style-on-cases-path";
|
|
197
|
+
}
|
|
198
|
+
return getNodeAtPath(tab.doc.document, op.path) ? null : "node-not-found";
|
|
199
|
+
}
|
|
200
|
+
case "set-text": {
|
|
201
|
+
if (pathHasDynamicSegment(op.path)) {
|
|
202
|
+
return "text-on-cases-path";
|
|
203
|
+
}
|
|
204
|
+
const node = getNodeAtPath(tab.doc.document, op.path);
|
|
205
|
+
if (!node) {
|
|
206
|
+
return "node-not-found";
|
|
207
|
+
}
|
|
208
|
+
if (typeof node.tagName === "string" && node.tagName.includes("-")) {
|
|
209
|
+
return "text-on-custom-element";
|
|
210
|
+
}
|
|
211
|
+
if (node.innerHTML) {
|
|
212
|
+
return "text-with-innerhtml";
|
|
213
|
+
}
|
|
214
|
+
const kids = node.children;
|
|
215
|
+
if (Array.isArray(kids) ? kids.length > 0 : kids != null) {
|
|
216
|
+
return "text-with-children";
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
case "set-prop": {
|
|
221
|
+
// Event bindings are stripped from design/edit renders — editing them is a canvas no-op.
|
|
222
|
+
// Other property changes re-render the node's subtree in place.
|
|
223
|
+
return op.isEvent ? null : replaceVerdict(tab, op.path);
|
|
224
|
+
}
|
|
225
|
+
case "set-attr":
|
|
226
|
+
case "replace": {
|
|
227
|
+
return replaceVerdict(tab, op.path);
|
|
228
|
+
}
|
|
229
|
+
case "insert": {
|
|
230
|
+
return containerVerdict(tab, op.parentPath);
|
|
231
|
+
}
|
|
232
|
+
case "remove": {
|
|
233
|
+
const parentPath = parentElementPath(op.path) as JxPath | null;
|
|
234
|
+
return parentPath === null ? "remove-no-parent" : containerVerdict(tab, parentPath);
|
|
235
|
+
}
|
|
236
|
+
case "move": {
|
|
237
|
+
if (pathHasDynamicSegment(op.fromPath)) {
|
|
238
|
+
return "structure-on-cases-path";
|
|
239
|
+
}
|
|
240
|
+
const fromParentPath = parentElementPath(op.fromPath) as JxPath | null;
|
|
241
|
+
if (fromParentPath === null) {
|
|
242
|
+
return "move-no-parent";
|
|
243
|
+
}
|
|
244
|
+
// The from-parent's children array was already verified by the mutation itself; its
|
|
245
|
+
// Custom-element/innerHTML constraints still need checking on both ends.
|
|
246
|
+
return containerVerdict(tab, fromParentPath) ?? containerVerdict(tab, op.toParentPath);
|
|
247
|
+
}
|
|
248
|
+
default: {
|
|
249
|
+
return `${op.op}-unsupported`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Decide whether a recorded batch can be applied surgically right now.
|
|
256
|
+
*
|
|
257
|
+
* @param {Tab} tab
|
|
258
|
+
* @param {JxPatchOp[]} ops
|
|
259
|
+
*/
|
|
260
|
+
export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; reason: string } {
|
|
261
|
+
const reject = (reason: string) => {
|
|
262
|
+
recordEscalation(reason);
|
|
263
|
+
return { patchable: false, reason };
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (!isActiveTab(tab)) {
|
|
267
|
+
return reject("inactive-tab");
|
|
268
|
+
}
|
|
269
|
+
const canvasMode = _ctx ? _ctx.getCanvasMode() : "";
|
|
270
|
+
if (canvasMode !== "design" && canvasMode !== "edit") {
|
|
271
|
+
return reject(`mode-${canvasMode || "unknown"}`);
|
|
272
|
+
}
|
|
273
|
+
if (canvasPanels.length === 0) {
|
|
274
|
+
return reject("no-panels");
|
|
275
|
+
}
|
|
276
|
+
if (!canvasPanels.every((p) => p.ready && p.liveCtx)) {
|
|
277
|
+
return reject("panels-not-ready");
|
|
278
|
+
}
|
|
279
|
+
// Structural changes while an inline edit session is live would pull the DOM out from under
|
|
280
|
+
// The editor — rare (commit flows tear down first), so escalate conservatively.
|
|
281
|
+
if (view.componentInlineEdit && ops.some((op) => isStructuralOp(op))) {
|
|
282
|
+
return reject("inline-edit-active");
|
|
283
|
+
}
|
|
284
|
+
for (const op of ops) {
|
|
285
|
+
const reason = opVerdict(tab, op);
|
|
286
|
+
if (reason) {
|
|
287
|
+
return reject(reason);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { patchable: true, reason: "" };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Application ─────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Apply a classified batch to every canvas panel. Throws on any failure; transactDoc catches and
|
|
297
|
+
* escalates to a full render.
|
|
298
|
+
*
|
|
299
|
+
* @param {Tab} tab
|
|
300
|
+
* @param {JxPatchOp[]} ops
|
|
301
|
+
*/
|
|
302
|
+
export function applyPatchBatch(tab: Tab, ops: JxPatchOp[]) {
|
|
303
|
+
const doc = toRaw(tab.doc.document) as JxMutableNode;
|
|
304
|
+
const mediaQueries = getEffectiveMedia(doc.$media || {});
|
|
305
|
+
|
|
306
|
+
for (const op of ops) {
|
|
307
|
+
for (const panel of canvasPanels) {
|
|
308
|
+
applyOpToPanel(panel, doc, op, mediaQueries);
|
|
309
|
+
}
|
|
310
|
+
canvasPerf.patchedOps += 1;
|
|
311
|
+
perfLog("patched", op);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Hover targets may have moved or vanished after structural changes; the next mousemove
|
|
315
|
+
// Recomputes it. (Selection is path-adjusted by the mutators themselves.)
|
|
316
|
+
if (tab.session.hover && ops.some((op) => isStructuralOp(op))) {
|
|
317
|
+
tab.session.hover = null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_ctx?.renderOverlays();
|
|
321
|
+
_ctx?.updateForcedPseudoPreview();
|
|
322
|
+
schedulePendingInlineEditConsumption(tab);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {CanvasPanel} panel
|
|
327
|
+
* @param {JxMutableNode} doc
|
|
328
|
+
* @param {JxPatchOp} op
|
|
329
|
+
* @param {Record<string, string>} mediaQueries
|
|
330
|
+
*/
|
|
331
|
+
function applyOpToPanel(
|
|
332
|
+
panel: CanvasPanel,
|
|
333
|
+
doc: JxMutableNode,
|
|
334
|
+
op: JxPatchOp,
|
|
335
|
+
mediaQueries: Record<string, string>,
|
|
336
|
+
) {
|
|
337
|
+
switch (op.op) {
|
|
338
|
+
case "set-style": {
|
|
339
|
+
const el = requireElement(op.path, panel);
|
|
340
|
+
const node = getNodeAtPath(doc, op.path);
|
|
341
|
+
reapplyStyle(el, editModeStyle(node?.style), mediaQueries, {});
|
|
342
|
+
restoreEditModePointerEvents(el);
|
|
343
|
+
if (panel.activeBreakpoints && panel.activeBreakpoints.size > 0) {
|
|
344
|
+
_ctx?.applyCanvasMediaOverrides(panel.canvas as HTMLElement, panel.activeBreakpoints);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
case "set-text": {
|
|
349
|
+
const el = requireElement(op.path, panel);
|
|
350
|
+
// The element being inline-edited already shows the committed text — don't clobber the
|
|
351
|
+
// User's live DOM (this replaces the old destroy-and-restore full-render cycle).
|
|
352
|
+
if (isBeingInlineEdited(el)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const node = getNodeAtPath(doc, op.path);
|
|
356
|
+
el.textContent = textDisplayValue(node);
|
|
357
|
+
for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
|
|
358
|
+
el.classList.remove(cls);
|
|
359
|
+
}
|
|
360
|
+
const placeholder = node ? computeEmptyPlaceholderClass(node) : null;
|
|
361
|
+
if (placeholder) {
|
|
362
|
+
el.classList.add(placeholder);
|
|
363
|
+
}
|
|
364
|
+
restoreEditModePointerEvents(el);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
case "set-prop": {
|
|
368
|
+
if (op.isEvent) {
|
|
369
|
+
// Event bindings are stripped from design/edit renders — nothing to do.
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
replaceSubtree(panel, doc, op.path);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
case "set-attr":
|
|
376
|
+
case "replace": {
|
|
377
|
+
replaceSubtree(panel, doc, op.path);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "insert": {
|
|
381
|
+
insertChild(panel, doc, op.parentPath, op.index);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
case "remove": {
|
|
385
|
+
removeChild(panel, doc, op.path);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
case "move": {
|
|
389
|
+
moveChild(panel, doc, op.fromPath, op.toParentPath, op.toIndex);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
default: {
|
|
393
|
+
throw new Error(`unsupported-op:${(op as JxPatchOp).op}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Structural patching ─────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/** Re-render the node at path and swap it into the DOM in place. */
|
|
401
|
+
function replaceSubtree(panel: CanvasPanel, doc: JxMutableNode, path: JxPath) {
|
|
402
|
+
const oldEl = requireElement(path, panel);
|
|
403
|
+
const parentEl = oldEl.parentElement;
|
|
404
|
+
if (!parentEl) {
|
|
405
|
+
throw new Error("replace-missing-parent-element");
|
|
406
|
+
}
|
|
407
|
+
const newEl = renderSubtree(panel, doc, path, parentEl);
|
|
408
|
+
disposeSubtree(oldEl);
|
|
409
|
+
oldEl.replaceWith(newEl);
|
|
410
|
+
afterSubtreeInserted(panel, newEl);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Render the inserted node and splice it into the parent's DOM, shifting sibling paths up. */
|
|
414
|
+
function insertChild(panel: CanvasPanel, doc: JxMutableNode, parentPath: JxPath, index: number) {
|
|
415
|
+
const parentEl = requireElement(parentPath, panel);
|
|
416
|
+
const parentNode = getNodeAtPath(doc, parentPath);
|
|
417
|
+
const newEl = renderSubtree(panel, doc, [...parentPath, "children", index], parentEl);
|
|
418
|
+
// Remap existing siblings before attaching the new subtree so it isn't itself remapped.
|
|
419
|
+
remapChildPaths(parentEl, parentPath, index, 1);
|
|
420
|
+
insertAt(parentEl, newEl, domChildReference(parentEl, parentNode, index));
|
|
421
|
+
syncContainerPlaceholder(parentEl, parentNode);
|
|
422
|
+
afterSubtreeInserted(panel, newEl);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Remove the node's DOM and shift following sibling paths down. */
|
|
426
|
+
function removeChild(panel: CanvasPanel, doc: JxMutableNode, path: JxPath) {
|
|
427
|
+
const parentPath = parentElementPath(path) as JxPath;
|
|
428
|
+
const idx = childIndex(path) as number;
|
|
429
|
+
const el = requireElement(path, panel);
|
|
430
|
+
const parentEl = el.parentElement;
|
|
431
|
+
if (!parentEl) {
|
|
432
|
+
throw new Error("remove-missing-parent-element");
|
|
433
|
+
}
|
|
434
|
+
disposeSubtree(el);
|
|
435
|
+
el.remove();
|
|
436
|
+
remapChildPaths(parentEl, parentPath, idx + 1, -1);
|
|
437
|
+
syncContainerPlaceholder(parentEl, getNodeAtPath(doc, parentPath));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Move = detach + path-shift both parents + rewrite the moved subtree's path prefix + reinsert.
|
|
442
|
+
* fromPath is in pre-mutation coordinates (matching the DOM); toIndex is the post-mutation index
|
|
443
|
+
* recorded by the mutator.
|
|
444
|
+
*/
|
|
445
|
+
function moveChild(
|
|
446
|
+
panel: CanvasPanel,
|
|
447
|
+
doc: JxMutableNode,
|
|
448
|
+
fromPath: JxPath,
|
|
449
|
+
toParentPath: JxPath,
|
|
450
|
+
toIndex: number,
|
|
451
|
+
) {
|
|
452
|
+
const fromParentPath = parentElementPath(fromPath) as JxPath;
|
|
453
|
+
const fromIdx = childIndex(fromPath) as number;
|
|
454
|
+
// Resolve both elements against the pre-mutation DOM mappings before touching anything.
|
|
455
|
+
const el = requireElement(fromPath, panel);
|
|
456
|
+
const toParentEl = requireElement(toParentPath, panel);
|
|
457
|
+
const fromParentEl = el.parentElement;
|
|
458
|
+
if (!fromParentEl) {
|
|
459
|
+
throw new Error("move-missing-parent-element");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
el.remove();
|
|
463
|
+
remapChildPaths(fromParentEl, fromParentPath, fromIdx + 1, -1);
|
|
464
|
+
// The to-parent's own path may have shifted by the detach remap — read it fresh.
|
|
465
|
+
const toPrefix = (elToPath.get(toParentEl) as JxPath | undefined) ?? toParentPath;
|
|
466
|
+
remapChildPaths(toParentEl, toPrefix, toIndex, 1);
|
|
467
|
+
rewriteSubtreePathPrefix(el, fromPath, [...toPrefix, "children", toIndex]);
|
|
468
|
+
|
|
469
|
+
const toParentNode = getNodeAtPath(doc, toPrefix);
|
|
470
|
+
insertAt(toParentEl, el, domChildReference(toParentEl, toParentNode, toIndex));
|
|
471
|
+
syncContainerPlaceholder(
|
|
472
|
+
fromParentEl,
|
|
473
|
+
getNodeAtPath(doc, elToPath.get(fromParentEl) ?? fromParentPath),
|
|
474
|
+
);
|
|
475
|
+
syncContainerPlaceholder(toParentEl, toParentNode);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Insert before the reference node, or append when inserting at the end. */
|
|
479
|
+
function insertAt(parentEl: Element, node: Node, ref: ChildNode | null) {
|
|
480
|
+
if (ref) {
|
|
481
|
+
ref.before(node);
|
|
482
|
+
} else {
|
|
483
|
+
parentEl.append(node);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Shift the child-index segment of elToPath entries for every element under parentEl whose path is
|
|
489
|
+
* `[...parentPath, "children", i, ...]` with `i >= fromIndex`. Descendants of shifted siblings
|
|
490
|
+
* carry the same segment and are rewritten too.
|
|
491
|
+
*/
|
|
492
|
+
function remapChildPaths(parentEl: Element, parentPath: JxPath, fromIndex: number, delta: number) {
|
|
493
|
+
const depth = parentPath.length;
|
|
494
|
+
for (const el of parentEl.querySelectorAll("*")) {
|
|
495
|
+
const p = elToPath.get(el);
|
|
496
|
+
if (!p || p.length < depth + 2 || p[depth] !== "children") {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const idx = p[depth + 1];
|
|
500
|
+
if (typeof idx !== "number" || idx < fromIndex) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (!parentPath.every((seg, i) => p[i] === seg)) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const np = [...p];
|
|
507
|
+
np[depth + 1] = idx + delta;
|
|
508
|
+
elToPath.set(el, np);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Rewrite elToPath entries of el and its descendants, replacing oldPrefix with newPrefix. */
|
|
513
|
+
function rewriteSubtreePathPrefix(el: Element, oldPrefix: JxPath, newPrefix: JxPath) {
|
|
514
|
+
const targets = [el, ...el.querySelectorAll("*")];
|
|
515
|
+
for (const t of targets) {
|
|
516
|
+
const p = elToPath.get(t);
|
|
517
|
+
if (!p || p.length < oldPrefix.length) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (!oldPrefix.every((seg, i) => p[i] === seg)) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
elToPath.set(t, [...newPrefix, ...p.slice(oldPrefix.length)]);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Release resources owned by a removed/replaced subtree: the scoped <style> tags the runtime
|
|
529
|
+
* emitted for its elements, and the effect scopes of any surgical renders rooted inside it.
|
|
530
|
+
*/
|
|
531
|
+
function disposeSubtree(el: Element) {
|
|
532
|
+
const targets = [el, ...el.querySelectorAll("*")];
|
|
533
|
+
for (const t of targets) {
|
|
534
|
+
if (t instanceof HTMLElement) {
|
|
535
|
+
const tag = elementStyleTags.get(t);
|
|
536
|
+
if (tag) {
|
|
537
|
+
tag.remove();
|
|
538
|
+
elementStyleTags.delete(t);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const scope = elToRenderScope.get(t);
|
|
542
|
+
if (scope) {
|
|
543
|
+
scope.stop();
|
|
544
|
+
elToRenderScope.delete(t);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Keep the parent's empty-placeholder class in sync after its children changed. */
|
|
550
|
+
function syncContainerPlaceholder(parentEl: Element, parentNode: JxMutableNode | undefined) {
|
|
551
|
+
for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
|
|
552
|
+
parentEl.classList.remove(cls);
|
|
553
|
+
}
|
|
554
|
+
const placeholder = parentNode ? computeEmptyPlaceholderClass(parentNode) : null;
|
|
555
|
+
if (placeholder) {
|
|
556
|
+
parentEl.classList.add(placeholder);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Post-insertion bookkeeping for a freshly rendered subtree: DnD targets + media overrides. */
|
|
561
|
+
function afterSubtreeInserted(panel: CanvasPanel, newEl: HTMLElement | Text) {
|
|
562
|
+
if (newEl instanceof HTMLElement) {
|
|
563
|
+
_ctx?.registerSubtreeDnD(newEl);
|
|
564
|
+
}
|
|
565
|
+
if (panel.activeBreakpoints && panel.activeBreakpoints.size > 0) {
|
|
566
|
+
_ctx?.applyCanvasMediaOverrides(panel.canvas as HTMLElement, panel.activeBreakpoints);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* @param {JxPath} path
|
|
572
|
+
* @param {CanvasPanel} panel
|
|
573
|
+
*/
|
|
574
|
+
function requireElement(path: JxPath, panel: CanvasPanel): HTMLElement {
|
|
575
|
+
const el = findCanvasElement(path, panel.canvas as HTMLElement);
|
|
576
|
+
if (!el) {
|
|
577
|
+
throw new Error(`element-not-found:${path.join("/")}`);
|
|
578
|
+
}
|
|
579
|
+
return el;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Whether this element is the live target of an inline editing session. */
|
|
583
|
+
function isBeingInlineEdited(el: HTMLElement) {
|
|
584
|
+
return view.componentInlineEdit?.el === el || getActiveElement() === el;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Re-disable pointer events after a patch (full renders set this on every canvas element). */
|
|
588
|
+
function restoreEditModePointerEvents(el: HTMLElement) {
|
|
589
|
+
if (!isBeingInlineEdited(el)) {
|
|
590
|
+
el.style.pointerEvents = "none";
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Edit-mode style transform — mirrors prepareForEditMode's style rule: top-level template-string
|
|
596
|
+
* values are blanked so no reactive bindings are created for them.
|
|
597
|
+
*
|
|
598
|
+
* @param {JxStyle | undefined} style
|
|
599
|
+
*/
|
|
600
|
+
function editModeStyle(style: JxStyle | undefined): JxStyle | undefined {
|
|
601
|
+
if (!style || typeof style !== "object") {
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
const out: Record<string, unknown> = {};
|
|
605
|
+
for (const [k, v] of Object.entries(style)) {
|
|
606
|
+
out[k] = typeof v === "string" && v.includes("${") ? "" : v;
|
|
607
|
+
}
|
|
608
|
+
return out as JxStyle;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Display text for a node's textContent in design/edit mode — mirrors prepareForEditMode's
|
|
613
|
+
* template-string and $ref display rules.
|
|
614
|
+
*
|
|
615
|
+
* @param {JxMutableNode | undefined} node
|
|
616
|
+
*/
|
|
617
|
+
function textDisplayValue(node: JxMutableNode | undefined): string {
|
|
618
|
+
const v = node?.textContent as unknown;
|
|
619
|
+
if (v == null) {
|
|
620
|
+
return "";
|
|
621
|
+
}
|
|
622
|
+
if (typeof v === "string") {
|
|
623
|
+
return v.includes("${") ? templateToEditDisplay(v) : v;
|
|
624
|
+
}
|
|
625
|
+
if (typeof v === "object" && (v as Record<string, unknown>).$ref) {
|
|
626
|
+
const ref = String((v as Record<string, unknown>).$ref);
|
|
627
|
+
const label = ref.startsWith("#/state/") ? ref.slice(8) : ref;
|
|
628
|
+
return `{${label}}`;
|
|
629
|
+
}
|
|
630
|
+
return String(v);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Consume a pendingInlineEdit after patches apply. The full render that used to consume it was
|
|
635
|
+
* skipped, and some flows (splitParagraph) set it right after transactDoc returns — so consume in a
|
|
636
|
+
* microtask, after the calling handler finished.
|
|
637
|
+
*
|
|
638
|
+
* @param {Tab} tab
|
|
639
|
+
*/
|
|
640
|
+
function schedulePendingInlineEditConsumption(tab: Tab) {
|
|
641
|
+
queueMicrotask(() => {
|
|
642
|
+
if (!isActiveTab(tab) || !tab.session.ui?.pendingInlineEdit) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const { path, mediaName } = tab.session.ui.pendingInlineEdit as InlineEditDef;
|
|
646
|
+
tab.session.ui.pendingInlineEdit = null;
|
|
647
|
+
const targetPanel = canvasPanels.find((p) => p.mediaName === mediaName) || canvasPanels[0];
|
|
648
|
+
if (!targetPanel) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const el = findCanvasElement(path, targetPanel.canvas as HTMLElement);
|
|
652
|
+
if (el) {
|
|
653
|
+
_ctx?.enterComponentInlineEdit(el, path);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|