@jxsuite/studio 1.0.0 → 1.2.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 +760 -65
- package/dist/iframe-entry.js.map +16 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +71573 -50074
- package/dist/studio.js.map +123 -88
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +37 -1
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +193 -6
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +56 -3
- package/src/canvas/iframe-render.ts +53 -14
- package/src/canvas/site-style-css.ts +121 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +76 -22
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +214 -10
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/settings/css-vars-editor.ts +122 -4
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +90 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +46 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/canvas-media.ts +23 -0
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-iframe live expression evaluation (M6) — answers the parent's `evalExpr` requests against the
|
|
3
|
+
* LIVE resolved scope of the current render (buildScope's `$defs`), so previews see real repeater
|
|
4
|
+
* items, real `window#/` globals, and uncapped data values instead of the parent's serialized
|
|
5
|
+
* snapshot.
|
|
6
|
+
*
|
|
7
|
+
* Safety: every expression evaluates against a fresh plain-data clone of the scope (a per-key JSON
|
|
8
|
+
* round-trip, the serialize-scope idiom — it reads through the runtime's reactive proxies), so
|
|
9
|
+
* mutating operators write into the clone and never touch the live canvas. Named-formula callables
|
|
10
|
+
* (pure `$expression` entries with parameters) are kept BY REFERENCE so `call` nodes work; every
|
|
11
|
+
* other function (side-effecting handlers, server fns) is dropped so a preview can never run one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { evaluateExpression } from "@jxsuite/runtime/expression";
|
|
15
|
+
import { resolveRef } from "@jxsuite/runtime";
|
|
16
|
+
import { formatPreviewValue } from "../utils/preview-format";
|
|
17
|
+
|
|
18
|
+
import type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
19
|
+
import type { EvalExprResult } from "./iframe-protocol";
|
|
20
|
+
|
|
21
|
+
type Scope = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** Whether the raw (document) state entry is a pure named-formula (`$expression` + parameters). */
|
|
24
|
+
function isNamedFormulaEntry(entry: unknown): boolean {
|
|
25
|
+
return (
|
|
26
|
+
typeof entry === "object" &&
|
|
27
|
+
entry !== null &&
|
|
28
|
+
!Array.isArray(entry) &&
|
|
29
|
+
"$expression" in entry &&
|
|
30
|
+
Array.isArray((entry as { parameters?: unknown }).parameters)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clone the live `$defs` scope into a plain object safe to evaluate (and mutate) against. Data
|
|
36
|
+
* values are JSON round-tripped per key (reads through reactive proxies, unwraps top-level refs);
|
|
37
|
+
* functions survive only when their raw state entry is a pure named formula — anything else
|
|
38
|
+
* callable (event handlers, structured-body functions, server fns) is dropped so a stray `call`
|
|
39
|
+
* can't run a side effect against the live canvas.
|
|
40
|
+
*/
|
|
41
|
+
export function cloneLiveScope(defs: Scope, rawState: Record<string, unknown> | null): Scope {
|
|
42
|
+
const out: Scope = {};
|
|
43
|
+
for (const key of Object.keys(defs)) {
|
|
44
|
+
try {
|
|
45
|
+
// Reading through the reactive proxy auto-unwraps a top-level ref/computed — and can throw (a
|
|
46
|
+
// Computed getter that throws), which maps the key to null instead of aborting the clone.
|
|
47
|
+
const value = defs[key];
|
|
48
|
+
if (typeof value === "function") {
|
|
49
|
+
if (isNamedFormulaEntry(rawState?.[key])) {
|
|
50
|
+
out[key] = value; // A pure formula callable — safe (and needed) for `call` nodes.
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const json = JSON.stringify(value);
|
|
55
|
+
out[key] = json === undefined ? null : (JSON.parse(json) as unknown);
|
|
56
|
+
} catch {
|
|
57
|
+
out[key] = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bind repeater `$map` contexts for a node at `contextPath`: walk the shadow doc along the path
|
|
65
|
+
* and, at every repeater crossing (a `$prototype: "Array"` node entered through its `map`
|
|
66
|
+
* template), resolve the repeater's `items` against the scope built so far and layer the FIRST
|
|
67
|
+
* item's `$map` context (item + index 0) over it — the same shape `renderMappedArrayInto` gives
|
|
68
|
+
* rendered items, so `$map/item` / `$map/index` refs preview against real data. Nested repeaters
|
|
69
|
+
* chain naturally (an inner `items` ref may read the outer `$map/item`).
|
|
70
|
+
*/
|
|
71
|
+
export function bindRepeaterContext(
|
|
72
|
+
scope: Scope,
|
|
73
|
+
shadowDoc: unknown,
|
|
74
|
+
contextPath: (string | number)[] | null,
|
|
75
|
+
): Scope {
|
|
76
|
+
if (!contextPath || contextPath.length === 0) {
|
|
77
|
+
return scope;
|
|
78
|
+
}
|
|
79
|
+
let node: unknown = shadowDoc;
|
|
80
|
+
let bound = scope;
|
|
81
|
+
for (const seg of contextPath) {
|
|
82
|
+
if (!node || typeof node !== "object") {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const rec = node as Record<string, unknown>;
|
|
86
|
+
if (rec.$prototype === "Array" && seg === "map") {
|
|
87
|
+
// Entering the repeater's template — bind the first item's context before descending.
|
|
88
|
+
const itemsDef = rec.items;
|
|
89
|
+
const items: unknown =
|
|
90
|
+
itemsDef && typeof itemsDef === "object" && "$ref" in itemsDef
|
|
91
|
+
? resolveRef((itemsDef as { $ref: string }).$ref, bound)
|
|
92
|
+
: itemsDef;
|
|
93
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
94
|
+
let item: unknown;
|
|
95
|
+
try {
|
|
96
|
+
// Items resolved from the shadow doc (a literal array) must not be mutable through the
|
|
97
|
+
// Preview scope — clone; scope-resolved items are already clone data, so this is free.
|
|
98
|
+
item = structuredClone(items[0]);
|
|
99
|
+
} catch {
|
|
100
|
+
item = null;
|
|
101
|
+
}
|
|
102
|
+
const child = Object.create(bound) as Scope;
|
|
103
|
+
child.$map = { index: 0, item };
|
|
104
|
+
child["$map/item"] = item;
|
|
105
|
+
child["$map/index"] = 0;
|
|
106
|
+
bound = child;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
node = rec[seg as keyof typeof rec];
|
|
110
|
+
}
|
|
111
|
+
return bound;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Evaluate each requested expression against a fresh clone of the live scope (with the repeater
|
|
116
|
+
* context for `contextPath` bound), reporting every sub-node's value through the engine's trace
|
|
117
|
+
* hook as pre-formatted display strings. Exceptions are guarded per expression into `error`.
|
|
118
|
+
*/
|
|
119
|
+
export function evaluateLiveExprs(
|
|
120
|
+
exprs: { id: string; node: unknown }[],
|
|
121
|
+
defs: Scope,
|
|
122
|
+
shadowDoc: unknown,
|
|
123
|
+
contextPath: (string | number)[] | null,
|
|
124
|
+
): EvalExprResult[] {
|
|
125
|
+
const rawState =
|
|
126
|
+
shadowDoc && typeof shadowDoc === "object"
|
|
127
|
+
? ((shadowDoc as { state?: Record<string, unknown> }).state ?? null)
|
|
128
|
+
: null;
|
|
129
|
+
return exprs.map(({ id, node }) => {
|
|
130
|
+
const values = new Map<string, string>();
|
|
131
|
+
// A fresh scope per expression: a mutating operator writes into this clone only, and can never
|
|
132
|
+
// Leak into the next expression's evaluation (or the live canvas).
|
|
133
|
+
let scope: Scope;
|
|
134
|
+
try {
|
|
135
|
+
scope = bindRepeaterContext(cloneLiveScope(defs, rawState), shadowDoc, contextPath);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { error: error instanceof Error ? error.message : String(error), id, values: [] };
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (!node || typeof node !== "object" || !("operator" in node)) {
|
|
141
|
+
throw new Error("not an expression node");
|
|
142
|
+
}
|
|
143
|
+
evaluateExpression(node as ExpressionNode, scope, null, undefined, {
|
|
144
|
+
report: (path, value) => values.set(path.join("/"), formatPreviewValue(value)),
|
|
145
|
+
});
|
|
146
|
+
return { id, values: [...values.entries()] };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return {
|
|
149
|
+
error: error instanceof Error ? error.message : String(error),
|
|
150
|
+
id,
|
|
151
|
+
values: [...values.entries()],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -12,6 +12,7 @@ import { canvasBaseOrigin } from "./canvas-origin";
|
|
|
12
12
|
import { resolveCanvasDocument } from "./canvas-live-render";
|
|
13
13
|
import {
|
|
14
14
|
applyInlineCommit,
|
|
15
|
+
applyInlinePropCommit,
|
|
15
16
|
applyInlineInsert,
|
|
16
17
|
applyInlineSplit,
|
|
17
18
|
} from "../editor/inline-edit-apply";
|
|
@@ -23,7 +24,15 @@ import { clearDragGhost, moveDragGhost } from "../panels/drag-ghost";
|
|
|
23
24
|
import { applyDropInstruction } from "../panels/dnd";
|
|
24
25
|
import { rectOf } from "../utils/geometry";
|
|
25
26
|
import { effect, effectScope } from "../reactivity";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
canvasPanels,
|
|
29
|
+
canvasWrap,
|
|
30
|
+
pathsEqual,
|
|
31
|
+
projectState,
|
|
32
|
+
renderOnly,
|
|
33
|
+
updateCanvas,
|
|
34
|
+
updateUi,
|
|
35
|
+
} from "../store";
|
|
27
36
|
import { activeTab, workspace } from "../workspace/workspace";
|
|
28
37
|
import { collabState } from "../collab/collab-state";
|
|
29
38
|
import { getPlatform, hasPlatform } from "../platform";
|
|
@@ -31,6 +40,7 @@ import type {
|
|
|
31
40
|
ApplyFormatIntent,
|
|
32
41
|
CanvasMode,
|
|
33
42
|
DragSrcKind,
|
|
43
|
+
EvalExprResult,
|
|
34
44
|
IframeToParent,
|
|
35
45
|
InsertZone,
|
|
36
46
|
NodeHit,
|
|
@@ -43,13 +53,15 @@ import type { IframeChannel } from "./iframe-channel";
|
|
|
43
53
|
import type { OverlayLayer, OverlayPlacement } from "./iframe-overlay";
|
|
44
54
|
import type { SlashCommand } from "../editor/inline-edit";
|
|
45
55
|
import type { Tab } from "../tabs/tab";
|
|
46
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
56
|
+
import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
|
|
47
57
|
|
|
48
58
|
/** A rect in PARENT coordinates (overlay-local from {@link canvasRectToParent}, same field shape). */
|
|
49
59
|
type ParentRect = OverlayPlacement;
|
|
50
60
|
|
|
51
61
|
interface HostState {
|
|
52
62
|
iframe: HTMLIFrameElement;
|
|
63
|
+
/** The `.canvas-panel-canvas` element hosting the iframe + overlay (the WeakMap key). */
|
|
64
|
+
canvasEl: HTMLElement;
|
|
53
65
|
channel: IframeChannel<ParentToIframe, IframeToParent>;
|
|
54
66
|
/**
|
|
55
67
|
* The resolved canvasUrl this iframe was built with — so a host built early with the default URL
|
|
@@ -70,6 +82,8 @@ interface HostState {
|
|
|
70
82
|
presenceMeta: Map<string, { color: string; label: string }>;
|
|
71
83
|
/** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
|
|
72
84
|
editing: boolean;
|
|
85
|
+
/** The prop a live plain session edits (prop-bound text) — null for rich sessions/none. */
|
|
86
|
+
editingProp: string | null;
|
|
73
87
|
/** The latest selection snapshot from this host's iframe (active tags + caret rect + link). */
|
|
74
88
|
snapshot: SelectionSnapshot | null;
|
|
75
89
|
/** Highest snapshot `seq` seen, so stale (re-ordered) snapshots are dropped. */
|
|
@@ -176,6 +190,72 @@ export function sawIframeDragOver(seq: number): boolean {
|
|
|
176
190
|
);
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
// ─── Live expression preview (M6) ───────────────────────────────────────────────
|
|
194
|
+
// The parent asks a live iframe to evaluate expression nodes against its LIVE resolved scope and
|
|
195
|
+
// Correlates the `evalResult` reply by reqId (the measure/geometry precedent), gen-gating it so a
|
|
196
|
+
// Reply computed against a superseded render never paints badges.
|
|
197
|
+
|
|
198
|
+
/** Monotonic eval request id (shared across hosts; stale replies carry an older/unknown id). */
|
|
199
|
+
let evalReqId = 0;
|
|
200
|
+
|
|
201
|
+
/** Pending eval resolvers keyed by reqId; a timeout or stale reply resolves null. */
|
|
202
|
+
const pendingEvals = new Map<number, (results: EvalExprResult[] | null) => void>();
|
|
203
|
+
|
|
204
|
+
/** How long (ms) the parent waits for an `evalResult` before falling back to the snapshot. */
|
|
205
|
+
export const EVAL_TIMEOUT_MS = 300;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Post an `evalExpr` request to the live, ready iframe host rendering `tabId`'s document and
|
|
209
|
+
* resolve with its results. Resolves `null` when no such host exists (caller falls back to the
|
|
210
|
+
* snapshot evaluator immediately), when no reply lands within `timeoutMs`, or when the reply's gen
|
|
211
|
+
* shows it was computed against a superseded render.
|
|
212
|
+
*/
|
|
213
|
+
export function requestCanvasEval(
|
|
214
|
+
tabId: string | null,
|
|
215
|
+
exprs: { id: string; node: unknown }[],
|
|
216
|
+
contextPath: (string | number)[] | null,
|
|
217
|
+
timeoutMs: number = EVAL_TIMEOUT_MS,
|
|
218
|
+
): Promise<EvalExprResult[] | null> {
|
|
219
|
+
let target: HostState | null = null;
|
|
220
|
+
for (const host of liveHosts) {
|
|
221
|
+
if (!host.iframe.isConnected) {
|
|
222
|
+
liveHosts.delete(host);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Only a document host can answer (never a stylebook specimen catalog or a null-tab override
|
|
226
|
+
// Doc like git-diff), and only the one whose iframe DOM reflects this tab's document.
|
|
227
|
+
if (host.ready && !host.stylebook && tabId !== null && host.tabId === tabId) {
|
|
228
|
+
target = host;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!target) {
|
|
233
|
+
return Promise.resolve(null);
|
|
234
|
+
}
|
|
235
|
+
evalReqId += 1;
|
|
236
|
+
const reqId = evalReqId;
|
|
237
|
+
// Expression nodes come off the reactive document — only plain values may cross postMessage.
|
|
238
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
239
|
+
const wireExprs = JSON.parse(JSON.stringify(exprs)) as { id: string; node: JxExpressionNode }[];
|
|
240
|
+
target.channel.post({
|
|
241
|
+
contextPath: contextPath ? [...contextPath] : null,
|
|
242
|
+
exprs: wireExprs,
|
|
243
|
+
gen: target.lastRenderedGen,
|
|
244
|
+
kind: "evalExpr",
|
|
245
|
+
reqId,
|
|
246
|
+
});
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const timer = setTimeout(() => {
|
|
249
|
+
pendingEvals.delete(reqId);
|
|
250
|
+
resolve(null);
|
|
251
|
+
}, timeoutMs);
|
|
252
|
+
pendingEvals.set(reqId, (results) => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
resolve(results);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
179
259
|
/** The host backing a given canvas element (or null), for the coordinator's host resolution. */
|
|
180
260
|
export function hostForCanvas(canvasEl: HTMLElement): HostState | null {
|
|
181
261
|
return hosts.get(canvasEl) ?? null;
|
|
@@ -263,6 +343,25 @@ export function hostDragGeometry(host: HostState): {
|
|
|
263
343
|
return { rect, scale };
|
|
264
344
|
}
|
|
265
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Pin the `.canvas-panel-viewport` (the white "page" surface, the canvas element's parent) to the
|
|
348
|
+
* SCALED content height when edit-mode content zoom is active. A `transform: scale()` on the canvas
|
|
349
|
+
* element rescales its painted box but never its ancestor's auto-height, so without this write the
|
|
350
|
+
* viewport background would stay sized to the UN-zoomed iframe height. The scale is read from the
|
|
351
|
+
* canvas element's own inline transform (written only by `applyEditZoom`) — NOT from
|
|
352
|
+
* {@link hostDragGeometry}, whose empirical ratio also reflects design mode's panzoom-wrap
|
|
353
|
+
* transform, where the viewport must keep its unscaled auto height.
|
|
354
|
+
*/
|
|
355
|
+
function syncEditZoomViewportHeight(state: HostState): void {
|
|
356
|
+
const viewport = state.canvasEl.parentElement;
|
|
357
|
+
if (!viewport) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const match = /scale\(([\d.]+)\)/.exec(state.canvasEl.style.transform);
|
|
361
|
+
const scale = match ? Number(match[1]!) : 1;
|
|
362
|
+
viewport.style.height = scale === 1 ? "" : `${state.iframe.offsetHeight * scale}px`;
|
|
363
|
+
}
|
|
364
|
+
|
|
266
365
|
/** Post a parent→iframe drag message to `host`'s channel (the coordinator builds it purely). */
|
|
267
366
|
export function postDragMessage(host: HostState, msg: ParentToIframe): void {
|
|
268
367
|
if (!host.iframe.isConnected) {
|
|
@@ -662,9 +761,11 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
662
761
|
});
|
|
663
762
|
|
|
664
763
|
const state: HostState = {
|
|
764
|
+
canvasEl,
|
|
665
765
|
canvasUrl,
|
|
666
766
|
channel,
|
|
667
767
|
editing: false,
|
|
768
|
+
editingProp: null,
|
|
668
769
|
iframe,
|
|
669
770
|
insertHideTimer: null,
|
|
670
771
|
insertZone: null,
|
|
@@ -875,6 +976,18 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
875
976
|
}
|
|
876
977
|
return;
|
|
877
978
|
}
|
|
979
|
+
case "evalResult": {
|
|
980
|
+
// A live expression-preview reply (M6). Resolve the pending request by reqId (unknown ids —
|
|
981
|
+
// Already timed out or from a torn-down request — are dropped), gen-gating it so values
|
|
982
|
+
// Computed against a superseded render resolve null (the caller keeps the snapshot preview).
|
|
983
|
+
const resolve = pendingEvals.get(msg.reqId);
|
|
984
|
+
if (!resolve) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
pendingEvals.delete(msg.reqId);
|
|
988
|
+
resolve(msg.gen === state.lastRenderedGen ? msg.results : null);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
878
991
|
case "renderComplete": {
|
|
879
992
|
// Adopt the tab identity this render was mounted with. host.tabId flips ONLY here — after any
|
|
880
993
|
// Edit-session commit the iframe posted ahead of this ack on the FIFO channel — so a commit
|
|
@@ -922,6 +1035,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
922
1035
|
// Component leaves dead space below — pages keep the floor and stay tall via #jx-canvas-root).
|
|
923
1036
|
state.iframe.style.height = `${msg.height}px`;
|
|
924
1037
|
state.iframe.style.minHeight = msg.fragment ? "0px" : "480px";
|
|
1038
|
+
syncEditZoomViewportHeight(state);
|
|
925
1039
|
return;
|
|
926
1040
|
}
|
|
927
1041
|
case "dragOver": {
|
|
@@ -1025,10 +1139,12 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1025
1139
|
// Tear down any other host's editing state and make this the single active edit host.
|
|
1026
1140
|
if (activeEditHost && activeEditHost !== state) {
|
|
1027
1141
|
activeEditHost.editing = false;
|
|
1142
|
+
activeEditHost.editingProp = null;
|
|
1028
1143
|
activeEditHost.snapshot = null;
|
|
1029
1144
|
}
|
|
1030
1145
|
activeEditHost = state;
|
|
1031
1146
|
state.editing = true;
|
|
1147
|
+
state.editingProp = msg.prop ?? null;
|
|
1032
1148
|
state.snapshot = null;
|
|
1033
1149
|
state.lastSnapshotSeq = 0;
|
|
1034
1150
|
toolbarRefresh?.();
|
|
@@ -1050,6 +1166,12 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1050
1166
|
applyInlineCommit(hostTab(state), msg.path, msg.children, msg.textContent);
|
|
1051
1167
|
return;
|
|
1052
1168
|
}
|
|
1169
|
+
case "editCommitProp": {
|
|
1170
|
+
// A prop-bound plain session committed: persist into the instance's $props (same host-tab
|
|
1171
|
+
// Routing as editCommit; the unchanged-value no-op lives in the apply).
|
|
1172
|
+
applyInlinePropCommit(hostTab(state), msg.path, msg.prop, msg.value);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1053
1175
|
case "editSplit": {
|
|
1054
1176
|
// The mutation lands now (surgical patch or escalated render); re-entry on the new paragraph
|
|
1055
1177
|
// Is deferred until this host acks the DOM that contains it (see deferEnterEdit).
|
|
@@ -1109,6 +1231,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1109
1231
|
return;
|
|
1110
1232
|
}
|
|
1111
1233
|
state.editing = false;
|
|
1234
|
+
state.editingProp = null;
|
|
1112
1235
|
state.snapshot = null;
|
|
1113
1236
|
if (activeEditHost === state) {
|
|
1114
1237
|
activeEditHost = null;
|
|
@@ -1255,6 +1378,7 @@ export async function mountIframeCanvas(
|
|
|
1255
1378
|
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1256
1379
|
const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
|
|
1257
1380
|
const message: ParentToIframe = {
|
|
1381
|
+
colorScheme: activeSchemeWire(),
|
|
1258
1382
|
doc: cloneableDoc,
|
|
1259
1383
|
docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
|
|
1260
1384
|
gen,
|
|
@@ -1304,6 +1428,7 @@ export function mountStylebookCanvas(
|
|
|
1304
1428
|
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1305
1429
|
const cloneableShadow = JSON.parse(JSON.stringify(generated.doc)) as unknown;
|
|
1306
1430
|
const message: ParentToIframe = {
|
|
1431
|
+
colorScheme: activeSchemeWire(),
|
|
1307
1432
|
doc: cloneableDoc,
|
|
1308
1433
|
docBase: `${canvasBaseOrigin()}/`,
|
|
1309
1434
|
gen,
|
|
@@ -1328,6 +1453,57 @@ export function mountStylebookCanvas(
|
|
|
1328
1453
|
}
|
|
1329
1454
|
}
|
|
1330
1455
|
|
|
1456
|
+
/** The active tab's forced preview scheme as wire data (auto → null). */
|
|
1457
|
+
function activeSchemeWire(): "light" | "dark" | null {
|
|
1458
|
+
const s = activeTab.value?.session.ui.previewColorScheme;
|
|
1459
|
+
return s === "light" || s === "dark" ? s : null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Flip the color-scheme preview on every ready host (page and stylebook alike — both render
|
|
1464
|
+
* scheme-aware CSS). A pure attribute write iframe-side: no render, no patch, no gen.
|
|
1465
|
+
*/
|
|
1466
|
+
export function postColorSchemeToLiveHosts(scheme: "light" | "dark" | null): void {
|
|
1467
|
+
for (const host of liveHosts) {
|
|
1468
|
+
if (!host.iframe.isConnected) {
|
|
1469
|
+
liveHosts.delete(host);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (host.ready) {
|
|
1473
|
+
host.channel.post({ kind: "setColorScheme", scheme });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Push the project's current site style to every ready PAGE host as an in-place sheet replace (live
|
|
1480
|
+
* design-token editing — stylebook hosts pre-merge site style into the specimen doc and are
|
|
1481
|
+
* skipped). Render-free; the next full render carries the same style via its own siteStyle.
|
|
1482
|
+
*/
|
|
1483
|
+
export function postSiteStyleToLiveHosts(): void {
|
|
1484
|
+
const config = projectState?.projectConfig;
|
|
1485
|
+
if (!config) {
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
// Site config comes off a reactive store — only plain values may cross postMessage.
|
|
1489
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1490
|
+
const siteStyle = JSON.parse(JSON.stringify(config.style ?? null)) as Record<
|
|
1491
|
+
string,
|
|
1492
|
+
unknown
|
|
1493
|
+
> | null;
|
|
1494
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1495
|
+
const media = JSON.parse(JSON.stringify(config.$media ?? {})) as Record<string, string>;
|
|
1496
|
+
for (const host of liveHosts) {
|
|
1497
|
+
if (!host.iframe.isConnected) {
|
|
1498
|
+
liveHosts.delete(host);
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
if (host.ready && !host.stylebook) {
|
|
1502
|
+
host.channel.post({ kind: "siteStyleUpdate", media, siteStyle });
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1331
1507
|
/**
|
|
1332
1508
|
* Post a live style update to every ready stylebook host (gen-tagged per host so a stale update is
|
|
1333
1509
|
* dropped iframe-side; the superseding render carries the same style). Returns how many hosts
|
|
@@ -1381,11 +1557,19 @@ export function getActiveEditHost(): HostState | null {
|
|
|
1381
1557
|
}
|
|
1382
1558
|
|
|
1383
1559
|
/** The current edit session's editing flag + latest selection snapshot, for the parent toolbar. */
|
|
1384
|
-
export function getEditSnapshot(): {
|
|
1560
|
+
export function getEditSnapshot(): {
|
|
1561
|
+
editing: boolean;
|
|
1562
|
+
editingProp: string | null;
|
|
1563
|
+
snapshot: SelectionSnapshot | null;
|
|
1564
|
+
} {
|
|
1385
1565
|
if (!activeEditHost) {
|
|
1386
|
-
return { editing: false, snapshot: null };
|
|
1566
|
+
return { editing: false, editingProp: null, snapshot: null };
|
|
1387
1567
|
}
|
|
1388
|
-
return {
|
|
1568
|
+
return {
|
|
1569
|
+
editing: activeEditHost.editing,
|
|
1570
|
+
editingProp: activeEditHost.editingProp,
|
|
1571
|
+
snapshot: activeEditHost.snapshot,
|
|
1572
|
+
};
|
|
1389
1573
|
}
|
|
1390
1574
|
|
|
1391
1575
|
/** Post an `applyFormat` intent to the active edit host's iframe (no-op when none/not ready). */
|
|
@@ -1423,7 +1607,10 @@ function hostForActivePanel(): HostState | null {
|
|
|
1423
1607
|
* UNSCALED iframe-viewport px (D-2: the overlay draws them inside the scaled panzoom-wrap, so the
|
|
1424
1608
|
* browser applies the zoom there); the fixed bar gets no such free ride, so scale by the live
|
|
1425
1609
|
* empirical zoom ({@link hostDragGeometry}) and add the iframe's on-screen offset, whose GBCR
|
|
1426
|
-
* already bakes in pan + zoom + ancestor scroll.
|
|
1610
|
+
* already bakes in pan + zoom + ancestor scroll. The empirical ratio covers BOTH scale sources:
|
|
1611
|
+
* design mode's panzoom-wrap transform and edit mode's content-zoom counter-scale (where it
|
|
1612
|
+
* evaluates to exactly `editZoom` — the iframe's layout width is `renderWidth / editZoom` while its
|
|
1613
|
+
* rendered width is `renderWidth`).
|
|
1427
1614
|
*/
|
|
1428
1615
|
export function getEditBarAnchorRect(): ParentRect | null {
|
|
1429
1616
|
// The format toolbar follows the live caret/selection snapshot of the active edit session; the
|
|
@@ -33,6 +33,7 @@ import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-for
|
|
|
33
33
|
import { applyLink, insertTemplateToken, linkStateForSelection } from "../editor/inline-link";
|
|
34
34
|
import { restoreTemplateExpressions } from "../utils/edit-display";
|
|
35
35
|
import { rectOfRange } from "../utils/geometry";
|
|
36
|
+
import { getNodeAtPath } from "../state";
|
|
36
37
|
import { parseJxPath, serializeJxPath } from "./path-mapping";
|
|
37
38
|
import type { IframeChannel } from "./iframe-channel";
|
|
38
39
|
import type {
|
|
@@ -43,6 +44,7 @@ import type {
|
|
|
43
44
|
SerializableRect,
|
|
44
45
|
} from "./iframe-protocol";
|
|
45
46
|
import type { JxPath } from "../state";
|
|
47
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
46
48
|
|
|
47
49
|
/** Toolbar command → inline tag (mirrors the parent's old `cmdToTag`, now iframe-side). */
|
|
48
50
|
const CMD_TO_TAG: Record<string, string> = {
|
|
@@ -74,6 +76,43 @@ function findEditableTarget(target: EventTarget | null): { el: HTMLElement; path
|
|
|
74
76
|
return null;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Walk up from an event target to the nearest element carrying `data-jx-bound-prop` (a component-
|
|
81
|
+
* internal element whose text is an invertible prop binding — stamped by the runtime, see
|
|
82
|
+
* setStampPropBindings). Component internals are unstamped, so hitting a `data-jx-path` element
|
|
83
|
+
* first means the target sits in page-level DOM (or slotted content) — not a prop-bound target.
|
|
84
|
+
*/
|
|
85
|
+
function findPropBoundTarget(target: EventTarget | null): { el: HTMLElement; prop: string } | null {
|
|
86
|
+
let el = target instanceof Element ? target : null;
|
|
87
|
+
while (el) {
|
|
88
|
+
if (el instanceof HTMLElement) {
|
|
89
|
+
if (el.dataset.jxBoundProp) {
|
|
90
|
+
return { el, prop: el.dataset.jxBoundProp };
|
|
91
|
+
}
|
|
92
|
+
if (el.dataset.jxPath) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
el = el.parentElement;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The instance that owns a prop-bound element: its nearest custom-element ancestor (that element's
|
|
103
|
+
* connectedCallback rendered the marker with its state, so the walk can never skip the owner).
|
|
104
|
+
*/
|
|
105
|
+
function ownerInstanceOf(el: HTMLElement): HTMLElement | null {
|
|
106
|
+
let cur = el.parentElement;
|
|
107
|
+
while (cur) {
|
|
108
|
+
if (cur.tagName.includes("-")) {
|
|
109
|
+
return cur;
|
|
110
|
+
}
|
|
111
|
+
cur = cur.parentElement;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
77
116
|
/** Locate the rendered element for a document path via its stamped `data-jx-path`. */
|
|
78
117
|
function elementForPath(container: HTMLElement, path: JxPath): HTMLElement | null {
|
|
79
118
|
const serialized = serializeJxPath(path);
|
|
@@ -95,7 +134,7 @@ function toSerializableRect(rect: DOMRect): SerializableRect {
|
|
|
95
134
|
export function startIframeInlineEdit(
|
|
96
135
|
channel: IframeChannel<IframeToParent, ParentToIframe>,
|
|
97
136
|
container: HTMLElement,
|
|
98
|
-
opts?: { getMode?: () => string },
|
|
137
|
+
opts?: { getMode?: () => string; getShadowDoc?: () => JxMutableNode | null },
|
|
99
138
|
): () => void {
|
|
100
139
|
const doc = container.ownerDocument;
|
|
101
140
|
|
|
@@ -251,6 +290,56 @@ export function startIframeInlineEdit(
|
|
|
251
290
|
onSelectionChange();
|
|
252
291
|
};
|
|
253
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Whether the instance's RAW `$props[prop]` value (from the pre-edit-display shadow doc) is
|
|
295
|
+
* plain-text editable. Template-valued (`"${...}"`) and `$ref`-valued props render display sugar,
|
|
296
|
+
* not the value — committing typed text would silently destroy the binding, so they're blocked.
|
|
297
|
+
* An absent getShadowDoc is permissive (tests, same contract as getMode).
|
|
298
|
+
*/
|
|
299
|
+
const propEditableAt = (hostPath: JxPath, prop: string): boolean => {
|
|
300
|
+
const shadowDoc = opts?.getShadowDoc?.();
|
|
301
|
+
if (!shadowDoc) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const node = getNodeAtPath(shadowDoc, hostPath) as JxMutableNode | undefined;
|
|
305
|
+
const raw = (node?.$props as Record<string, unknown> | undefined)?.[prop];
|
|
306
|
+
if (raw == null) {
|
|
307
|
+
return true; // Unset — editing ADDS the prop, overriding the definition default.
|
|
308
|
+
}
|
|
309
|
+
if (typeof raw === "object") {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
return !(typeof raw === "string" && raw.includes("${"));
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Enter a plain (plaintext-only) session on a prop-bound component-internal element. The session
|
|
317
|
+
* path is the INSTANCE path — the marker element has no path of its own, and an external `$props`
|
|
318
|
+
* patch at the host path must disturb (commit + end) this session before detaching its element.
|
|
319
|
+
*/
|
|
320
|
+
const enterPropEditAt = (el: HTMLElement, hostPath: JxPath, prop: string) => {
|
|
321
|
+
channel.post({ kind: "editStart", path: hostPath, prop });
|
|
322
|
+
suspendBlurClose();
|
|
323
|
+
startEditing(
|
|
324
|
+
el,
|
|
325
|
+
hostPath,
|
|
326
|
+
{
|
|
327
|
+
onCommit: (p, _children, textContent) =>
|
|
328
|
+
channel.post({ kind: "editCommitProp", path: p, prop, value: textContent ?? "" }),
|
|
329
|
+
onEnd: () => {
|
|
330
|
+
resumeBlurClose();
|
|
331
|
+
clearHighlight();
|
|
332
|
+
lastNonEmptyRange = null;
|
|
333
|
+
channel.post({ kind: "editEnd" });
|
|
334
|
+
},
|
|
335
|
+
// Prop values are single plain strings — no split, no slash-insert.
|
|
336
|
+
onInsert: () => {},
|
|
337
|
+
onSplit: () => {},
|
|
338
|
+
},
|
|
339
|
+
{ plainText: true },
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
254
343
|
/** Apply a format/link/insert intent to the iframe's cached selection range. */
|
|
255
344
|
const applyFormatIntent = (intent: ApplyFormatIntent) => {
|
|
256
345
|
const el = getActiveElement();
|
|
@@ -290,6 +379,23 @@ export function startIframeInlineEdit(
|
|
|
290
379
|
if (!editingAllowed()) {
|
|
291
380
|
return;
|
|
292
381
|
}
|
|
382
|
+
// Prop-bound targets win: they sit in unstamped component internals, deeper than any editable
|
|
383
|
+
// Page block, and once a marker is hit the event must NOT fall through to rich editing (an
|
|
384
|
+
// Instance nested inside a page-level editable would otherwise rich-edit that whole ancestor).
|
|
385
|
+
const propHit = findPropBoundTarget(e.target);
|
|
386
|
+
if (propHit) {
|
|
387
|
+
const host = ownerInstanceOf(propHit.el);
|
|
388
|
+
const hostPathRaw = host?.dataset.jxPath;
|
|
389
|
+
if (host && hostPathRaw) {
|
|
390
|
+
const hostPath = parseJxPath(hostPathRaw);
|
|
391
|
+
if (propEditableAt(hostPath, propHit.prop)) {
|
|
392
|
+
enterPropEditAt(propHit.el, hostPath, propHit.prop);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// A path-less host is itself internal to another definition — its $props live in a document
|
|
396
|
+
// Not open in this tab, so there is no write-back target: blocked.
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
293
399
|
const hit = findEditableTarget(e.target);
|
|
294
400
|
if (hit) {
|
|
295
401
|
enterEditAt(hit.el, hit.path);
|