@jxsuite/studio 1.0.0 → 1.1.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 +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- 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 +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -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 +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- 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/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -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/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -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";
|
|
@@ -31,6 +32,7 @@ import type {
|
|
|
31
32
|
ApplyFormatIntent,
|
|
32
33
|
CanvasMode,
|
|
33
34
|
DragSrcKind,
|
|
35
|
+
EvalExprResult,
|
|
34
36
|
IframeToParent,
|
|
35
37
|
InsertZone,
|
|
36
38
|
NodeHit,
|
|
@@ -43,13 +45,15 @@ import type { IframeChannel } from "./iframe-channel";
|
|
|
43
45
|
import type { OverlayLayer, OverlayPlacement } from "./iframe-overlay";
|
|
44
46
|
import type { SlashCommand } from "../editor/inline-edit";
|
|
45
47
|
import type { Tab } from "../tabs/tab";
|
|
46
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
48
|
+
import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
|
|
47
49
|
|
|
48
50
|
/** A rect in PARENT coordinates (overlay-local from {@link canvasRectToParent}, same field shape). */
|
|
49
51
|
type ParentRect = OverlayPlacement;
|
|
50
52
|
|
|
51
53
|
interface HostState {
|
|
52
54
|
iframe: HTMLIFrameElement;
|
|
55
|
+
/** The `.canvas-panel-canvas` element hosting the iframe + overlay (the WeakMap key). */
|
|
56
|
+
canvasEl: HTMLElement;
|
|
53
57
|
channel: IframeChannel<ParentToIframe, IframeToParent>;
|
|
54
58
|
/**
|
|
55
59
|
* The resolved canvasUrl this iframe was built with — so a host built early with the default URL
|
|
@@ -70,6 +74,8 @@ interface HostState {
|
|
|
70
74
|
presenceMeta: Map<string, { color: string; label: string }>;
|
|
71
75
|
/** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
|
|
72
76
|
editing: boolean;
|
|
77
|
+
/** The prop a live plain session edits (prop-bound text) — null for rich sessions/none. */
|
|
78
|
+
editingProp: string | null;
|
|
73
79
|
/** The latest selection snapshot from this host's iframe (active tags + caret rect + link). */
|
|
74
80
|
snapshot: SelectionSnapshot | null;
|
|
75
81
|
/** Highest snapshot `seq` seen, so stale (re-ordered) snapshots are dropped. */
|
|
@@ -176,6 +182,72 @@ export function sawIframeDragOver(seq: number): boolean {
|
|
|
176
182
|
);
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
// ─── Live expression preview (M6) ───────────────────────────────────────────────
|
|
186
|
+
// The parent asks a live iframe to evaluate expression nodes against its LIVE resolved scope and
|
|
187
|
+
// Correlates the `evalResult` reply by reqId (the measure/geometry precedent), gen-gating it so a
|
|
188
|
+
// Reply computed against a superseded render never paints badges.
|
|
189
|
+
|
|
190
|
+
/** Monotonic eval request id (shared across hosts; stale replies carry an older/unknown id). */
|
|
191
|
+
let evalReqId = 0;
|
|
192
|
+
|
|
193
|
+
/** Pending eval resolvers keyed by reqId; a timeout or stale reply resolves null. */
|
|
194
|
+
const pendingEvals = new Map<number, (results: EvalExprResult[] | null) => void>();
|
|
195
|
+
|
|
196
|
+
/** How long (ms) the parent waits for an `evalResult` before falling back to the snapshot. */
|
|
197
|
+
export const EVAL_TIMEOUT_MS = 300;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Post an `evalExpr` request to the live, ready iframe host rendering `tabId`'s document and
|
|
201
|
+
* resolve with its results. Resolves `null` when no such host exists (caller falls back to the
|
|
202
|
+
* snapshot evaluator immediately), when no reply lands within `timeoutMs`, or when the reply's gen
|
|
203
|
+
* shows it was computed against a superseded render.
|
|
204
|
+
*/
|
|
205
|
+
export function requestCanvasEval(
|
|
206
|
+
tabId: string | null,
|
|
207
|
+
exprs: { id: string; node: unknown }[],
|
|
208
|
+
contextPath: (string | number)[] | null,
|
|
209
|
+
timeoutMs: number = EVAL_TIMEOUT_MS,
|
|
210
|
+
): Promise<EvalExprResult[] | null> {
|
|
211
|
+
let target: HostState | null = null;
|
|
212
|
+
for (const host of liveHosts) {
|
|
213
|
+
if (!host.iframe.isConnected) {
|
|
214
|
+
liveHosts.delete(host);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Only a document host can answer (never a stylebook specimen catalog or a null-tab override
|
|
218
|
+
// Doc like git-diff), and only the one whose iframe DOM reflects this tab's document.
|
|
219
|
+
if (host.ready && !host.stylebook && tabId !== null && host.tabId === tabId) {
|
|
220
|
+
target = host;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!target) {
|
|
225
|
+
return Promise.resolve(null);
|
|
226
|
+
}
|
|
227
|
+
evalReqId += 1;
|
|
228
|
+
const reqId = evalReqId;
|
|
229
|
+
// Expression nodes come off the reactive document — only plain values may cross postMessage.
|
|
230
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
231
|
+
const wireExprs = JSON.parse(JSON.stringify(exprs)) as { id: string; node: JxExpressionNode }[];
|
|
232
|
+
target.channel.post({
|
|
233
|
+
contextPath: contextPath ? [...contextPath] : null,
|
|
234
|
+
exprs: wireExprs,
|
|
235
|
+
gen: target.lastRenderedGen,
|
|
236
|
+
kind: "evalExpr",
|
|
237
|
+
reqId,
|
|
238
|
+
});
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
pendingEvals.delete(reqId);
|
|
242
|
+
resolve(null);
|
|
243
|
+
}, timeoutMs);
|
|
244
|
+
pendingEvals.set(reqId, (results) => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
resolve(results);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
179
251
|
/** The host backing a given canvas element (or null), for the coordinator's host resolution. */
|
|
180
252
|
export function hostForCanvas(canvasEl: HTMLElement): HostState | null {
|
|
181
253
|
return hosts.get(canvasEl) ?? null;
|
|
@@ -263,6 +335,25 @@ export function hostDragGeometry(host: HostState): {
|
|
|
263
335
|
return { rect, scale };
|
|
264
336
|
}
|
|
265
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Pin the `.canvas-panel-viewport` (the white "page" surface, the canvas element's parent) to the
|
|
340
|
+
* SCALED content height when edit-mode content zoom is active. A `transform: scale()` on the canvas
|
|
341
|
+
* element rescales its painted box but never its ancestor's auto-height, so without this write the
|
|
342
|
+
* viewport background would stay sized to the UN-zoomed iframe height. The scale is read from the
|
|
343
|
+
* canvas element's own inline transform (written only by `applyEditZoom`) — NOT from
|
|
344
|
+
* {@link hostDragGeometry}, whose empirical ratio also reflects design mode's panzoom-wrap
|
|
345
|
+
* transform, where the viewport must keep its unscaled auto height.
|
|
346
|
+
*/
|
|
347
|
+
function syncEditZoomViewportHeight(state: HostState): void {
|
|
348
|
+
const viewport = state.canvasEl.parentElement;
|
|
349
|
+
if (!viewport) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const match = /scale\(([\d.]+)\)/.exec(state.canvasEl.style.transform);
|
|
353
|
+
const scale = match ? Number(match[1]!) : 1;
|
|
354
|
+
viewport.style.height = scale === 1 ? "" : `${state.iframe.offsetHeight * scale}px`;
|
|
355
|
+
}
|
|
356
|
+
|
|
266
357
|
/** Post a parent→iframe drag message to `host`'s channel (the coordinator builds it purely). */
|
|
267
358
|
export function postDragMessage(host: HostState, msg: ParentToIframe): void {
|
|
268
359
|
if (!host.iframe.isConnected) {
|
|
@@ -662,9 +753,11 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
662
753
|
});
|
|
663
754
|
|
|
664
755
|
const state: HostState = {
|
|
756
|
+
canvasEl,
|
|
665
757
|
canvasUrl,
|
|
666
758
|
channel,
|
|
667
759
|
editing: false,
|
|
760
|
+
editingProp: null,
|
|
668
761
|
iframe,
|
|
669
762
|
insertHideTimer: null,
|
|
670
763
|
insertZone: null,
|
|
@@ -875,6 +968,18 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
875
968
|
}
|
|
876
969
|
return;
|
|
877
970
|
}
|
|
971
|
+
case "evalResult": {
|
|
972
|
+
// A live expression-preview reply (M6). Resolve the pending request by reqId (unknown ids —
|
|
973
|
+
// Already timed out or from a torn-down request — are dropped), gen-gating it so values
|
|
974
|
+
// Computed against a superseded render resolve null (the caller keeps the snapshot preview).
|
|
975
|
+
const resolve = pendingEvals.get(msg.reqId);
|
|
976
|
+
if (!resolve) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
pendingEvals.delete(msg.reqId);
|
|
980
|
+
resolve(msg.gen === state.lastRenderedGen ? msg.results : null);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
878
983
|
case "renderComplete": {
|
|
879
984
|
// Adopt the tab identity this render was mounted with. host.tabId flips ONLY here — after any
|
|
880
985
|
// Edit-session commit the iframe posted ahead of this ack on the FIFO channel — so a commit
|
|
@@ -922,6 +1027,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
922
1027
|
// Component leaves dead space below — pages keep the floor and stay tall via #jx-canvas-root).
|
|
923
1028
|
state.iframe.style.height = `${msg.height}px`;
|
|
924
1029
|
state.iframe.style.minHeight = msg.fragment ? "0px" : "480px";
|
|
1030
|
+
syncEditZoomViewportHeight(state);
|
|
925
1031
|
return;
|
|
926
1032
|
}
|
|
927
1033
|
case "dragOver": {
|
|
@@ -1025,10 +1131,12 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1025
1131
|
// Tear down any other host's editing state and make this the single active edit host.
|
|
1026
1132
|
if (activeEditHost && activeEditHost !== state) {
|
|
1027
1133
|
activeEditHost.editing = false;
|
|
1134
|
+
activeEditHost.editingProp = null;
|
|
1028
1135
|
activeEditHost.snapshot = null;
|
|
1029
1136
|
}
|
|
1030
1137
|
activeEditHost = state;
|
|
1031
1138
|
state.editing = true;
|
|
1139
|
+
state.editingProp = msg.prop ?? null;
|
|
1032
1140
|
state.snapshot = null;
|
|
1033
1141
|
state.lastSnapshotSeq = 0;
|
|
1034
1142
|
toolbarRefresh?.();
|
|
@@ -1050,6 +1158,12 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1050
1158
|
applyInlineCommit(hostTab(state), msg.path, msg.children, msg.textContent);
|
|
1051
1159
|
return;
|
|
1052
1160
|
}
|
|
1161
|
+
case "editCommitProp": {
|
|
1162
|
+
// A prop-bound plain session committed: persist into the instance's $props (same host-tab
|
|
1163
|
+
// Routing as editCommit; the unchanged-value no-op lives in the apply).
|
|
1164
|
+
applyInlinePropCommit(hostTab(state), msg.path, msg.prop, msg.value);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1053
1167
|
case "editSplit": {
|
|
1054
1168
|
// The mutation lands now (surgical patch or escalated render); re-entry on the new paragraph
|
|
1055
1169
|
// Is deferred until this host acks the DOM that contains it (see deferEnterEdit).
|
|
@@ -1109,6 +1223,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1109
1223
|
return;
|
|
1110
1224
|
}
|
|
1111
1225
|
state.editing = false;
|
|
1226
|
+
state.editingProp = null;
|
|
1112
1227
|
state.snapshot = null;
|
|
1113
1228
|
if (activeEditHost === state) {
|
|
1114
1229
|
activeEditHost = null;
|
|
@@ -1381,11 +1496,19 @@ export function getActiveEditHost(): HostState | null {
|
|
|
1381
1496
|
}
|
|
1382
1497
|
|
|
1383
1498
|
/** The current edit session's editing flag + latest selection snapshot, for the parent toolbar. */
|
|
1384
|
-
export function getEditSnapshot(): {
|
|
1499
|
+
export function getEditSnapshot(): {
|
|
1500
|
+
editing: boolean;
|
|
1501
|
+
editingProp: string | null;
|
|
1502
|
+
snapshot: SelectionSnapshot | null;
|
|
1503
|
+
} {
|
|
1385
1504
|
if (!activeEditHost) {
|
|
1386
|
-
return { editing: false, snapshot: null };
|
|
1505
|
+
return { editing: false, editingProp: null, snapshot: null };
|
|
1387
1506
|
}
|
|
1388
|
-
return {
|
|
1507
|
+
return {
|
|
1508
|
+
editing: activeEditHost.editing,
|
|
1509
|
+
editingProp: activeEditHost.editingProp,
|
|
1510
|
+
snapshot: activeEditHost.snapshot,
|
|
1511
|
+
};
|
|
1389
1512
|
}
|
|
1390
1513
|
|
|
1391
1514
|
/** Post an `applyFormat` intent to the active edit host's iframe (no-op when none/not ready). */
|
|
@@ -1423,7 +1546,10 @@ function hostForActivePanel(): HostState | null {
|
|
|
1423
1546
|
* UNSCALED iframe-viewport px (D-2: the overlay draws them inside the scaled panzoom-wrap, so the
|
|
1424
1547
|
* browser applies the zoom there); the fixed bar gets no such free ride, so scale by the live
|
|
1425
1548
|
* empirical zoom ({@link hostDragGeometry}) and add the iframe's on-screen offset, whose GBCR
|
|
1426
|
-
* already bakes in pan + zoom + ancestor scroll.
|
|
1549
|
+
* already bakes in pan + zoom + ancestor scroll. The empirical ratio covers BOTH scale sources:
|
|
1550
|
+
* design mode's panzoom-wrap transform and edit mode's content-zoom counter-scale (where it
|
|
1551
|
+
* evaluates to exactly `editZoom` — the iframe's layout width is `renderWidth / editZoom` while its
|
|
1552
|
+
* rendered width is `renderWidth`).
|
|
1427
1553
|
*/
|
|
1428
1554
|
export function getEditBarAnchorRect(): ParentRect | null {
|
|
1429
1555
|
// 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);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { JxDocOp } from "../tabs/patch-ops";
|
|
13
13
|
import type { JxContentResult, SlashCommand } from "../editor/inline-edit";
|
|
14
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
14
|
+
import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The wire form of a value-carrying document op (the `forward` half of a recorded
|
|
@@ -110,6 +110,21 @@ export type ParentToIframe =
|
|
|
110
110
|
| { command: "link"; href: string | null } // Null/"" = remove
|
|
111
111
|
| { command: "insertData"; token: string };
|
|
112
112
|
}
|
|
113
|
+
// ─── Live expression preview (M6) ───────────────────────────────────────────
|
|
114
|
+
// Evaluate expression nodes against the iframe's LIVE resolved scope (real repeater items, real
|
|
115
|
+
// Window#/ globals) and post per-node display values back. `contextPath` is the document path of
|
|
116
|
+
// The node whose scope context the expressions bind to (null = the root scope); when it sits
|
|
117
|
+
// Inside a repeater template, the iframe binds the first rendered item's $map context. `reqId`
|
|
118
|
+
// Correlates the reply (measure/geometry precedent); `gen` is the render the request targets —
|
|
119
|
+
// The iframe refuses to evaluate against a different render, and the parent drops replies whose
|
|
120
|
+
// Gen no longer matches its last-rendered one.
|
|
121
|
+
| {
|
|
122
|
+
kind: "evalExpr";
|
|
123
|
+
exprs: { id: string; node: JxExpressionNode }[];
|
|
124
|
+
contextPath: (string | number)[] | null;
|
|
125
|
+
reqId: number;
|
|
126
|
+
gen: number;
|
|
127
|
+
}
|
|
113
128
|
// ─── Cross-frame DnD (Phase 4c spike) ──────────────────────────────────────
|
|
114
129
|
// Begin a drag session in the iframe: `src` is the realm-agnostic source kind, `dragSeq` is the
|
|
115
130
|
// Per-session id (parent drops any reply with a different seq), `gen` is the render the session
|
|
@@ -125,6 +140,19 @@ export type ParentToIframe =
|
|
|
125
140
|
// The drag was cancelled (Escape/abort from the parent realm) — same teardown as dragEnd.
|
|
126
141
|
| { kind: "dragCancel"; dragSeq: number };
|
|
127
142
|
|
|
143
|
+
/**
|
|
144
|
+
* One expression's live evaluation outcome (M6). `values` are path-key → display-string pairs — the
|
|
145
|
+
* trace hook's per-node values, already formatted INSIDE the iframe (same truncation rules as the
|
|
146
|
+
* parent's snapshot preview) so only postMessage-safe strings cross the boundary. `error` carries
|
|
147
|
+
* the thrown message when this expression failed (its `values` then hold whatever nodes reported
|
|
148
|
+
* before the throw).
|
|
149
|
+
*/
|
|
150
|
+
export interface EvalExprResult {
|
|
151
|
+
id: string;
|
|
152
|
+
values: [string, string][];
|
|
153
|
+
error?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
/** A node's bounding box, in the iframe's own viewport coordinates. */
|
|
129
157
|
export interface SerializableRect {
|
|
130
158
|
x: number;
|
|
@@ -229,6 +257,10 @@ export type IframeToParent =
|
|
|
229
257
|
// Response to `measure`: the rects of whichever requested paths resolved to a node (missing paths
|
|
230
258
|
// Are simply omitted). `reqId` echoes the request so the parent can drop stale responses.
|
|
231
259
|
| { kind: "geometry"; reqId: number; hits: NodeHit[] }
|
|
260
|
+
// Response to `evalExpr`: one result per requested expression (empty when the request's gen no
|
|
261
|
+
// Longer matches the live render — the iframe never evaluates against the wrong scope). `reqId`/
|
|
262
|
+
// `gen` echo the request so the parent can drop stale replies (measure/geometry precedent).
|
|
263
|
+
| { kind: "evalResult"; reqId: number; gen: number; results: EvalExprResult[] }
|
|
232
264
|
// A patch applied cleanly (echoes gen so the host can re-measure the selection overlay).
|
|
233
265
|
| { kind: "patchComplete"; gen: number }
|
|
234
266
|
// A patch could not be applied surgically — the parent escalates to a full render.
|
|
@@ -248,8 +280,9 @@ export type IframeToParent =
|
|
|
248
280
|
metaKey: boolean;
|
|
249
281
|
shiftKey: boolean;
|
|
250
282
|
}
|
|
251
|
-
// Inline editing started in the iframe (the parent shows the format toolbar from here).
|
|
252
|
-
|
|
283
|
+
// Inline editing started in the iframe (the parent shows the format toolbar from here). `prop`
|
|
284
|
+
// Names the component-instance prop when the session edits prop-bound text (plain sessions).
|
|
285
|
+
| { kind: "editStart"; path: (string | number)[]; prop?: string }
|
|
253
286
|
// Committed inline-edit content (rich `children` else `textContent`) for the parent to persist.
|
|
254
287
|
| {
|
|
255
288
|
kind: "editCommit";
|
|
@@ -257,6 +290,13 @@ export type IframeToParent =
|
|
|
257
290
|
children: (JxMutableNode | string)[] | null;
|
|
258
291
|
textContent: string | null;
|
|
259
292
|
}
|
|
293
|
+
// Committed prop-bound text: persist `value` into `$props[prop]` of the instance at `path`.
|
|
294
|
+
| {
|
|
295
|
+
kind: "editCommitProp";
|
|
296
|
+
path: (string | number)[];
|
|
297
|
+
prop: string;
|
|
298
|
+
value: string;
|
|
299
|
+
}
|
|
260
300
|
// Enter split a paragraph: keep `before` in the node, insert a new one with `after`.
|
|
261
301
|
| {
|
|
262
302
|
kind: "editSplit";
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
setCanvasViewportTranspose,
|
|
21
21
|
setRootMedia,
|
|
22
22
|
setSkipServerFunctions,
|
|
23
|
+
setStampPropBindings,
|
|
23
24
|
transposeCanvasUnits,
|
|
24
25
|
} from "@jxsuite/runtime";
|
|
25
26
|
import { classifyRenderNode, serializeJxPath } from "./path-mapping";
|
|
@@ -150,6 +151,18 @@ export const EDIT_PLACEHOLDER_CSS = `
|
|
|
150
151
|
font-size: 13px;
|
|
151
152
|
pointer-events: none;
|
|
152
153
|
}
|
|
154
|
+
[data-jx-bound-prop]:hover {
|
|
155
|
+
cursor: text;
|
|
156
|
+
outline: 1px dashed color-mix(in srgb, #808080 40%, transparent);
|
|
157
|
+
outline-offset: 1px;
|
|
158
|
+
}
|
|
159
|
+
[data-jx-bound-prop]:empty:not([contenteditable="plaintext-only"]):not([contenteditable="true"])::after {
|
|
160
|
+
content: "Empty \\2014 double-click to edit";
|
|
161
|
+
color: color-mix(in srgb, #808080 40%, transparent);
|
|
162
|
+
font-style: italic;
|
|
163
|
+
font-size: 13px;
|
|
164
|
+
pointer-events: none;
|
|
165
|
+
}
|
|
153
166
|
`;
|
|
154
167
|
|
|
155
168
|
/**
|
|
@@ -425,6 +438,11 @@ export async function renderResolvedDocument(opts: {
|
|
|
425
438
|
// De-link `<a href>` in design/edit so clicks select the anchor instead of navigating the iframe;
|
|
426
439
|
// Preview keeps real links live (mirrors the server-function gate above).
|
|
427
440
|
setCanvasDelinkAnchors(opts.mode !== "preview");
|
|
441
|
+
// Stamp `data-jx-bound-prop` on component-internal invertible text bindings in design/edit only —
|
|
442
|
+
// The inline prop-edit affordance. Set every render so a preview/stylebook render in the same
|
|
443
|
+
// Iframe clears it (page-level templates are inert in design/edit via prepareForEditMode, so only
|
|
444
|
+
// Component internals get stamped).
|
|
445
|
+
setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
|
|
428
446
|
applySiteStyle(opts.siteStyle);
|
|
429
447
|
injectHead(opts.doc);
|
|
430
448
|
syncEditModeCss(opts.container.ownerDocument, opts.mode);
|
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* before-image). Inbound: remote Y transactions convert back into JxDocOps and replay through
|
|
8
8
|
* `applyExternalDocOps`, riding the surgical canvas patcher; unconvertible shapes hard-reconcile
|
|
9
9
|
* the tab from the Y tree. Undo becomes a local-origin-scoped Y.UndoManager registered as the tab's
|
|
10
|
-
* history delegate.
|
|
11
|
-
*
|
|
10
|
+
* history delegate.
|
|
11
|
+
*
|
|
12
|
+
* Saving is EXPLICIT even while attached: syncing edits to peers (the Y.Doc) is automatic, but
|
|
13
|
+
* folding the shared source to disk is not. Local edits mark `tab.doc.dirty` immediately for
|
|
14
|
+
* instant Save-button feedback; the room-level, server-authoritative unsaved state then arrives via
|
|
15
|
+
* `handle.onDirty` (any peer's edit dirties the room for everyone; a save clears it for everyone).
|
|
16
|
+
* Cmd+S / Save runs a serialize→mirror→flush, and the provider broadcasts the room clean once the
|
|
17
|
+
* write lands.
|
|
12
18
|
*
|
|
13
19
|
* All yjs code sits behind a dynamic import: the unsplit bundle inlines the bytes, but module
|
|
14
20
|
* evaluation defers until a collab session actually attaches.
|
|
@@ -177,17 +183,18 @@ function onTransact(tab: Tab, record: TransactionRecord, origin: TransactOrigin)
|
|
|
177
183
|
}
|
|
178
184
|
session.lastSeenRef = toRaw(tab.doc.document) as object;
|
|
179
185
|
if (origin === "remote") {
|
|
180
|
-
// The reconciler keeps source text fresh regardless of WHO edited the structure.
|
|
186
|
+
// The reconciler keeps source text fresh regardless of WHO edited the structure. The tab's
|
|
187
|
+
// Dirty state is driven by the server's room-level `doc-dirty` broadcast (see onDirty in
|
|
188
|
+
// CreateSession), not forced here.
|
|
181
189
|
scheduleMirror(session);
|
|
182
|
-
tab.doc.dirty = false;
|
|
183
190
|
return;
|
|
184
191
|
}
|
|
185
192
|
if (session.canWrite) {
|
|
186
193
|
publishRecord(session, record);
|
|
187
194
|
scheduleMirror(session);
|
|
188
195
|
}
|
|
189
|
-
//
|
|
190
|
-
|
|
196
|
+
// Local edits leave the tab dirty (transactDoc already set it) until an explicit save flushes and
|
|
197
|
+
// The provider broadcasts the room clean.
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
function onBatchEnd(tab: Tab): void {
|
|
@@ -673,7 +680,15 @@ function createSession(
|
|
|
673
680
|
});
|
|
674
681
|
}
|
|
675
682
|
session.lastSeenRef = toRaw(tab.doc.document) as object;
|
|
676
|
-
|
|
683
|
+
|
|
684
|
+
// The server owns the room-level unsaved state: `onDirty` fires synchronously with the current
|
|
685
|
+
// Value on subscribe (delivered during the open handshake), so attaching to a clean room clears
|
|
686
|
+
// Any dirty the initial reconcile set, and attaching to an already-dirty room shows dirty.
|
|
687
|
+
session.disposers.push(
|
|
688
|
+
handle.onDirty((dirty) => {
|
|
689
|
+
tab.doc.dirty = dirty;
|
|
690
|
+
}),
|
|
691
|
+
);
|
|
677
692
|
|
|
678
693
|
// Presence identity + write capability (drives reconciler election).
|
|
679
694
|
handle.awareness.setLocalState({
|
|
@@ -864,7 +879,7 @@ function createSession(
|
|
|
864
879
|
session.lastSeenRef = nowRef;
|
|
865
880
|
publishDiff(session);
|
|
866
881
|
scheduleMirror(session);
|
|
867
|
-
|
|
882
|
+
// Dirty stays as the edit left it; the server's doc-dirty broadcast is authoritative.
|
|
868
883
|
});
|
|
869
884
|
});
|
|
870
885
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* fs-sync/reload guards without importing the session machinery.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { reactive } from "../reactivity";
|
|
7
|
+
import { reactive, toRaw } from "../reactivity";
|
|
8
8
|
import type { Tab } from "../tabs/tab";
|
|
9
9
|
import type { CollabAwarenessState } from "@jxsuite/collab/awareness-types";
|
|
10
10
|
|
|
@@ -33,7 +33,10 @@ const states = new WeakMap<Tab, TabCollabState>();
|
|
|
33
33
|
const attachedPaths = new Map<string, number>();
|
|
34
34
|
|
|
35
35
|
export function collabState(tab: Tab): TabCollabState {
|
|
36
|
-
|
|
36
|
+
// Key on the raw tab: writers (the collab session, via ensureCollab's raw tab) and readers
|
|
37
|
+
// (toolbar/tab-strip, via reactive `workspace.tabs.get` proxies) must resolve the same entry.
|
|
38
|
+
const key = toRaw(tab as unknown as object) as Tab;
|
|
39
|
+
let state = states.get(key);
|
|
37
40
|
if (!state) {
|
|
38
41
|
state = reactive({
|
|
39
42
|
active: false,
|
|
@@ -42,7 +45,7 @@ export function collabState(tab: Tab): TabCollabState {
|
|
|
42
45
|
sourceCanonical: false,
|
|
43
46
|
status: "detached",
|
|
44
47
|
}) as TabCollabState;
|
|
45
|
-
states.set(
|
|
48
|
+
states.set(key, state);
|
|
46
49
|
}
|
|
47
50
|
return state;
|
|
48
51
|
}
|