@jxsuite/studio 0.33.0 → 0.35.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 +6238 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +30972 -17222
- package/dist/studio.js.map +328 -83
- package/package.json +9 -9
- 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 +5 -1
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/page-params.ts +383 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +189 -457
- 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/git-panel.ts +16 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +21 -7
- 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 +45 -1
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +64 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +9 -9
- package/src/services/settings-store.ts +93 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/store.ts +4 -62
- package/src/studio.ts +115 -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 +2 -74
- package/src/types.ts +98 -18
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +13 -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,66 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Canvas origin derivation. The canvas iframe may load same-origin (dev server / chromium /
|
|
4
|
+
* electrobun gate-off, where the platform canvasUrl is a relative path) or cross-origin (electrobun
|
|
5
|
+
* loopback, where canvasUrl is an absolute http://127.0.0.1:<port> URL).
|
|
6
|
+
*
|
|
7
|
+
* CanvasBaseOrigin() returns the origin the canvas document lives on, so callers that build
|
|
8
|
+
* absolute asset/doc URLs target the right server. For a relative canvasUrl this resolves to
|
|
9
|
+
* location.origin — IDENTITY for the same-origin paths, so dev/chromium/electrobun-gate-off are
|
|
10
|
+
* byte-identical to before.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getPlatform, hasPlatform } from "../platform";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The origin serving the canvas document. When the platform registers an absolute canvasUrl
|
|
17
|
+
* (electrobun loopback), this is that URL's origin; otherwise it falls back to the parent's own
|
|
18
|
+
* origin (a relative canvasUrl resolves against location.href to location.origin).
|
|
19
|
+
*/
|
|
20
|
+
export function canvasBaseOrigin(): string {
|
|
21
|
+
const canvasUrl = hasPlatform() ? getPlatform().canvasUrl : undefined;
|
|
22
|
+
if (canvasUrl) {
|
|
23
|
+
return new URL(canvasUrl, location.href).origin;
|
|
24
|
+
}
|
|
25
|
+
return location.origin;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build an absolute loopback-origin src for a PROJECT asset path referenced by a PARENT-realm
|
|
30
|
+
* (shell) preview <img>. On the views:// shell a relative "/images/foo.png" resolves to
|
|
31
|
+
* views://studio/images/foo.png, which the browser fetches immediately on paint — a stray request
|
|
32
|
+
* the desktop MutationObserver only rewrites asynchronously. Rendering the src loopback-absolute up
|
|
33
|
+
* front eliminates that race for direct-parent <img> sites.
|
|
34
|
+
*
|
|
35
|
+
* Guarded: this ONLY rewrites when a real cross-origin http(s) loopback is registered (electrobun
|
|
36
|
+
* loopback, canvasUrl set to an absolute http URL) whose origin differs from location.origin.
|
|
37
|
+
* Otherwise — dev server, chromium, electrobun gate-off, and the pre-activate window — it returns
|
|
38
|
+
* the ORIGINAL relative path unchanged, keeping those cases byte-identical (an absolute
|
|
39
|
+
* ${location.origin}/... on the shell would just reintroduce views://... — the exact bug).
|
|
40
|
+
*
|
|
41
|
+
* Only for PROJECT asset srcs; NEVER call it for shell-packaged views:// assets (sp-icons, bundled
|
|
42
|
+
* chrome). Already-absolute inputs (data:/blob:/http/views://) are returned untouched, so it is
|
|
43
|
+
* safe for user-typed media values too.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} path — a project asset path (relative, e.g. "/public/logo.png" or "logo.png") or
|
|
46
|
+
* an already-absolute URL.
|
|
47
|
+
* @returns {string} The loopback-absolute src when a cross-origin loopback is active, else `path`.
|
|
48
|
+
*/
|
|
49
|
+
export function loopbackAssetSrc(path: string): string {
|
|
50
|
+
if (
|
|
51
|
+
path.startsWith("data:") ||
|
|
52
|
+
path.startsWith("blob:") ||
|
|
53
|
+
path.startsWith("http") ||
|
|
54
|
+
path.startsWith("views://")
|
|
55
|
+
) {
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
const origin = canvasBaseOrigin();
|
|
59
|
+
// Only rewrite when a real cross-origin http(s) loopback is registered. When canvasBaseOrigin()
|
|
60
|
+
// Yields location.origin (the views:// shell / dev / chromium / gate-off) an absolute rewrite
|
|
61
|
+
// Would produce views://... on the shell, so fall back to the relative path unchanged.
|
|
62
|
+
if ((origin.startsWith("http:") || origin.startsWith("https:")) && origin !== location.origin) {
|
|
63
|
+
return `${origin}/${path.replace(/^\.?\//, "")}`;
|
|
64
|
+
}
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
@@ -13,45 +13,23 @@
|
|
|
13
13
|
* to the patched DOM.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
childIndex,
|
|
19
|
-
elToPath,
|
|
20
|
-
elToRenderScope,
|
|
21
|
-
getNodeAtPath,
|
|
22
|
-
parentElementPath,
|
|
23
|
-
} from "../store";
|
|
24
|
-
import { activeTab } from "../workspace/workspace";
|
|
16
|
+
import { canvasPanels, getNodeAtPath, parentElementPath } from "../store";
|
|
17
|
+
import { isTabActive } from "../workspace/workspace";
|
|
25
18
|
import { view } from "../view";
|
|
26
19
|
import { toRaw } from "../reactivity";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
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";
|
|
20
|
+
import { postPatchToHosts } from "./iframe-host";
|
|
21
|
+
import { canvasPerf, recordEscalation } from "./canvas-perf";
|
|
38
22
|
import { setPatchConsumer } from "../tabs/patch-ops";
|
|
39
23
|
|
|
40
|
-
import type { JxPatchOp } from "../tabs/patch-ops";
|
|
24
|
+
import type { JxPatchOp, TransactionRecord } from "../tabs/patch-ops";
|
|
41
25
|
import type { Tab } from "../tabs/tab.js";
|
|
42
26
|
import type { JxPath } from "../state";
|
|
43
|
-
import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
|
|
44
|
-
import type { CanvasPanel, InlineEditDef } from "../types";
|
|
45
27
|
|
|
46
28
|
/** Render-side callbacks injected at init so this module stays free of heavy canvas imports. */
|
|
47
29
|
interface CanvasPatcherCtx {
|
|
48
30
|
getCanvasMode: () => string;
|
|
49
31
|
scheduleCanvasRender: () => void;
|
|
50
|
-
applyCanvasMediaOverrides: (canvasEl: Element, activeBreakpoints: Set<string>) => void;
|
|
51
32
|
renderOverlays: () => void;
|
|
52
|
-
updateForcedPseudoPreview: () => void;
|
|
53
|
-
enterComponentInlineEdit: (el: HTMLElement, path: JxPath) => void;
|
|
54
|
-
registerSubtreeDnD: (rootEl: HTMLElement) => void;
|
|
55
33
|
}
|
|
56
34
|
|
|
57
35
|
let _ctx: CanvasPatcherCtx | null = null;
|
|
@@ -101,12 +79,6 @@ export function escalateToFullRender(reason: string) {
|
|
|
101
79
|
|
|
102
80
|
// ─── Classification ──────────────────────────────────────────────────────────
|
|
103
81
|
|
|
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
82
|
/**
|
|
111
83
|
* $switch cases render as a substituted first-case placeholder in edit mode, so their element paths
|
|
112
84
|
* don't correspond to document paths — edits through "cases" escalate to a full render. (Non-
|
|
@@ -157,7 +129,17 @@ function containerVerdict(tab: Tab, parentPath: JxPath, requireArray = true): st
|
|
|
157
129
|
return null;
|
|
158
130
|
}
|
|
159
131
|
|
|
160
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Verdict for ops applied as a subtree replace at `path`. Unlike the structural splices
|
|
134
|
+
* (insert/remove/move), a replace locates its target DIRECTLY by its stamped `data-jx-path`
|
|
135
|
+
* (iframe-patch `requireElement`) and swaps it in place — doc↔DOM child-index correspondence is
|
|
136
|
+
* never consulted, so a custom-element ancestor (slot redistribution) cannot break it and is NOT a
|
|
137
|
+
* reason to escalate. That matters for real content: markdown class-directive pages put every
|
|
138
|
+
* editable block inside a component, and rejecting those forced a full render (reloading embedded
|
|
139
|
+
* iframes) on every text commit. Should the element be un-queryable after all (e.g. a component
|
|
140
|
+
* rendered its children into shadow DOM), the iframe throws element-not-found and the parent
|
|
141
|
+
* escalates — the same outcome as rejecting here, without the false positives.
|
|
142
|
+
*/
|
|
161
143
|
function replaceVerdict(tab: Tab, path: JxPath): string | null {
|
|
162
144
|
if (path.length === 0) {
|
|
163
145
|
return "replace-root";
|
|
@@ -169,20 +151,22 @@ function replaceVerdict(tab: Tab, path: JxPath): string | null {
|
|
|
169
151
|
if (!parentPath) {
|
|
170
152
|
return "replace-no-parent";
|
|
171
153
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
154
|
+
// A subtree re-render inside a repeater template can't be re-rendered 1:1 in the edit-mode
|
|
155
|
+
// Perimeter (mirrors containerVerdict's map rule).
|
|
156
|
+
if (parentPath.includes("map")) {
|
|
157
|
+
return "structure-on-map-path";
|
|
158
|
+
}
|
|
159
|
+
const parent = getNodeAtPath(tab.doc.document, parentPath);
|
|
160
|
+
if (!parent) {
|
|
161
|
+
return "node-not-found";
|
|
162
|
+
}
|
|
163
|
+
// An innerHTML parent renders opaque children — the target has no stamped element to swap.
|
|
164
|
+
if (parent.innerHTML) {
|
|
165
|
+
return "structure-with-innerhtml";
|
|
175
166
|
}
|
|
176
167
|
return getNodeAtPath(tab.doc.document, path) === undefined ? "node-not-found" : null;
|
|
177
168
|
}
|
|
178
169
|
|
|
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
170
|
/**
|
|
187
171
|
* Per-op patchability in the current document. Returns null when patchable, else the reason.
|
|
188
172
|
*
|
|
@@ -263,7 +247,7 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
|
|
|
263
247
|
return { patchable: false, reason };
|
|
264
248
|
};
|
|
265
249
|
|
|
266
|
-
if (!
|
|
250
|
+
if (!isTabActive(tab)) {
|
|
267
251
|
return reject("inactive-tab");
|
|
268
252
|
}
|
|
269
253
|
const canvasMode = _ctx ? _ctx.getCanvasMode() : "";
|
|
@@ -273,20 +257,39 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
|
|
|
273
257
|
if (canvasPanels.length === 0) {
|
|
274
258
|
return reject("no-panels");
|
|
275
259
|
}
|
|
276
|
-
|
|
260
|
+
// The iframe canvas keeps its render context inside the iframe, so a panel is patchable as soon as
|
|
261
|
+
// It has rendered (`ready`) — there is no parent-side `liveCtx`.
|
|
262
|
+
if (!canvasPanels.every((p) => p.ready)) {
|
|
277
263
|
return reject("panels-not-ready");
|
|
278
264
|
}
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
265
|
+
// A `set-text` on a node whose CHILDREN are replaced in the same batch is subsumed by that
|
|
266
|
+
// Subtree re-render (the iframe folds every forward op into the shadow doc first, then the
|
|
267
|
+
// Trailing children re-render draws the node from the final state — see iframe-patch.ts). This is
|
|
268
|
+
// The rich-text inline-commit shape (clear text + set children), which opVerdict alone would
|
|
269
|
+
// Reject as "text-with-children" because classification runs POST-mutation — the node already has
|
|
270
|
+
// Its new children by the time we look.
|
|
271
|
+
const subtreeReplacedPaths = new Set<string>();
|
|
272
|
+
for (const op of ops) {
|
|
273
|
+
if ((op.op === "set-prop" && op.key === "children") || op.op === "replace") {
|
|
274
|
+
subtreeReplacedPaths.add(JSON.stringify(op.path));
|
|
275
|
+
}
|
|
283
276
|
}
|
|
277
|
+
// Inline editing now lives inside the iframe canvas, which the parent patcher can't observe; the
|
|
278
|
+
// Iframe escalates to a full render itself if a posted patch can't apply mid-edit. So there is no
|
|
279
|
+
// Parent-side inline-edit guard here anymore.
|
|
284
280
|
for (const op of ops) {
|
|
281
|
+
if (op.op === "set-text" && subtreeReplacedPaths.has(JSON.stringify(op.path))) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
285
284
|
const reason = opVerdict(tab, op);
|
|
286
285
|
if (reason) {
|
|
287
286
|
return reject(reason);
|
|
288
287
|
}
|
|
289
288
|
}
|
|
289
|
+
// Classification is host-agnostic: the iframe canvas applies the SAME op set surgically as the
|
|
290
|
+
// Legacy DOM patcher (in-place style/text, structural relocation, and subtree re-renders for
|
|
291
|
+
// Insert/replace/attr/prop — see iframe-patch.ts). An op the iframe somehow can't apply throws and
|
|
292
|
+
// The parent escalates to a full render — so a stricter gate here would only forgo optimization.
|
|
290
293
|
return { patchable: true, reason: "" };
|
|
291
294
|
}
|
|
292
295
|
|
|
@@ -298,359 +301,16 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
|
|
|
298
301
|
*
|
|
299
302
|
* @param {Tab} tab
|
|
300
303
|
* @param {JxPatchOp[]} ops
|
|
304
|
+
* @param {TransactionRecord} [record] Value-carrying ops, used by the iframe host.
|
|
301
305
|
*/
|
|
302
|
-
export function applyPatchBatch(tab: Tab,
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
306
|
+
export function applyPatchBatch(tab: Tab, _ops: JxPatchOp[], record?: TransactionRecord) {
|
|
307
|
+
// The canvas renders inside the iframe — the parent has no DOM to mutate. Post the value-carrying
|
|
308
|
+
// Forward ops over the bridge for the iframe to apply against its shadow doc — only to hosts
|
|
309
|
+
// Rendering THIS tab's document (a background tab's iframe must never fold a foreign edit into
|
|
310
|
+
// Its shadow doc). Throwing when no host received it escalates to a full render (the suppressed
|
|
311
|
+
// Render then runs), so an edit is never dropped.
|
|
312
|
+
const forwardOps = (record?.docOps ?? []).map((pair) => pair.forward);
|
|
313
|
+
if (postPatchToHosts(forwardOps, view.renderGeneration, tab.id) === 0) {
|
|
314
|
+
throw new Error("no-ready-iframe-host");
|
|
603
315
|
}
|
|
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
316
|
}
|