@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
|
@@ -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
|
|
@@ -61,8 +61,21 @@ export type ParentToIframe =
|
|
|
61
61
|
docBase: string;
|
|
62
62
|
mapperCtx: WireMapperCtx;
|
|
63
63
|
siteStyle: Record<string, unknown> | null;
|
|
64
|
+
// Forced color-scheme preview (spec §9.5): "light"/"dark" sets data-color-scheme on the
|
|
65
|
+
// Iframe root, null clears it (auto — follow the OS).
|
|
66
|
+
colorScheme: "light" | "dark" | null;
|
|
64
67
|
gen: number;
|
|
65
68
|
}
|
|
69
|
+
// Flip the forced color-scheme preview on the iframe root without re-rendering — a document-level
|
|
70
|
+
// Idempotent attribute write, deliberately gen-less (like endEdit).
|
|
71
|
+
| { kind: "setColorScheme"; scheme: "light" | "dark" | null }
|
|
72
|
+
// Replace the injected site-style sheet in place (live design-token editing) — idempotent and
|
|
73
|
+
// Render-free; the superseding render carries the same style via its own siteStyle.
|
|
74
|
+
| {
|
|
75
|
+
kind: "siteStyleUpdate";
|
|
76
|
+
siteStyle: Record<string, unknown> | null;
|
|
77
|
+
media: Record<string, string>;
|
|
78
|
+
}
|
|
66
79
|
// Ask the iframe to measure the given document paths and post their current rects back. Used to
|
|
67
80
|
// Draw the selection overlay regardless of where the selection change originated (canvas click,
|
|
68
81
|
// Layers panel, keyboard) — the parent can't measure iframe nodes itself (cross-origin bridge).
|
|
@@ -110,6 +123,21 @@ export type ParentToIframe =
|
|
|
110
123
|
| { command: "link"; href: string | null } // Null/"" = remove
|
|
111
124
|
| { command: "insertData"; token: string };
|
|
112
125
|
}
|
|
126
|
+
// ─── Live expression preview (M6) ───────────────────────────────────────────
|
|
127
|
+
// Evaluate expression nodes against the iframe's LIVE resolved scope (real repeater items, real
|
|
128
|
+
// Window#/ globals) and post per-node display values back. `contextPath` is the document path of
|
|
129
|
+
// The node whose scope context the expressions bind to (null = the root scope); when it sits
|
|
130
|
+
// Inside a repeater template, the iframe binds the first rendered item's $map context. `reqId`
|
|
131
|
+
// Correlates the reply (measure/geometry precedent); `gen` is the render the request targets —
|
|
132
|
+
// The iframe refuses to evaluate against a different render, and the parent drops replies whose
|
|
133
|
+
// Gen no longer matches its last-rendered one.
|
|
134
|
+
| {
|
|
135
|
+
kind: "evalExpr";
|
|
136
|
+
exprs: { id: string; node: JxExpressionNode }[];
|
|
137
|
+
contextPath: (string | number)[] | null;
|
|
138
|
+
reqId: number;
|
|
139
|
+
gen: number;
|
|
140
|
+
}
|
|
113
141
|
// ─── Cross-frame DnD (Phase 4c spike) ──────────────────────────────────────
|
|
114
142
|
// Begin a drag session in the iframe: `src` is the realm-agnostic source kind, `dragSeq` is the
|
|
115
143
|
// Per-session id (parent drops any reply with a different seq), `gen` is the render the session
|
|
@@ -125,6 +153,19 @@ export type ParentToIframe =
|
|
|
125
153
|
// The drag was cancelled (Escape/abort from the parent realm) — same teardown as dragEnd.
|
|
126
154
|
| { kind: "dragCancel"; dragSeq: number };
|
|
127
155
|
|
|
156
|
+
/**
|
|
157
|
+
* One expression's live evaluation outcome (M6). `values` are path-key → display-string pairs — the
|
|
158
|
+
* trace hook's per-node values, already formatted INSIDE the iframe (same truncation rules as the
|
|
159
|
+
* parent's snapshot preview) so only postMessage-safe strings cross the boundary. `error` carries
|
|
160
|
+
* the thrown message when this expression failed (its `values` then hold whatever nodes reported
|
|
161
|
+
* before the throw).
|
|
162
|
+
*/
|
|
163
|
+
export interface EvalExprResult {
|
|
164
|
+
id: string;
|
|
165
|
+
values: [string, string][];
|
|
166
|
+
error?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
128
169
|
/** A node's bounding box, in the iframe's own viewport coordinates. */
|
|
129
170
|
export interface SerializableRect {
|
|
130
171
|
x: number;
|
|
@@ -229,6 +270,10 @@ export type IframeToParent =
|
|
|
229
270
|
// Response to `measure`: the rects of whichever requested paths resolved to a node (missing paths
|
|
230
271
|
// Are simply omitted). `reqId` echoes the request so the parent can drop stale responses.
|
|
231
272
|
| { kind: "geometry"; reqId: number; hits: NodeHit[] }
|
|
273
|
+
// Response to `evalExpr`: one result per requested expression (empty when the request's gen no
|
|
274
|
+
// Longer matches the live render — the iframe never evaluates against the wrong scope). `reqId`/
|
|
275
|
+
// `gen` echo the request so the parent can drop stale replies (measure/geometry precedent).
|
|
276
|
+
| { kind: "evalResult"; reqId: number; gen: number; results: EvalExprResult[] }
|
|
232
277
|
// A patch applied cleanly (echoes gen so the host can re-measure the selection overlay).
|
|
233
278
|
| { kind: "patchComplete"; gen: number }
|
|
234
279
|
// A patch could not be applied surgically — the parent escalates to a full render.
|
|
@@ -248,8 +293,9 @@ export type IframeToParent =
|
|
|
248
293
|
metaKey: boolean;
|
|
249
294
|
shiftKey: boolean;
|
|
250
295
|
}
|
|
251
|
-
// Inline editing started in the iframe (the parent shows the format toolbar from here).
|
|
252
|
-
|
|
296
|
+
// Inline editing started in the iframe (the parent shows the format toolbar from here). `prop`
|
|
297
|
+
// Names the component-instance prop when the session edits prop-bound text (plain sessions).
|
|
298
|
+
| { kind: "editStart"; path: (string | number)[]; prop?: string }
|
|
253
299
|
// Committed inline-edit content (rich `children` else `textContent`) for the parent to persist.
|
|
254
300
|
| {
|
|
255
301
|
kind: "editCommit";
|
|
@@ -257,6 +303,13 @@ export type IframeToParent =
|
|
|
257
303
|
children: (JxMutableNode | string)[] | null;
|
|
258
304
|
textContent: string | null;
|
|
259
305
|
}
|
|
306
|
+
// Committed prop-bound text: persist `value` into `$props[prop]` of the instance at `path`.
|
|
307
|
+
| {
|
|
308
|
+
kind: "editCommitProp";
|
|
309
|
+
path: (string | number)[];
|
|
310
|
+
prop: string;
|
|
311
|
+
value: string;
|
|
312
|
+
}
|
|
260
313
|
// Enter split a paragraph: keep `before` in the node, insert a new one with `after`.
|
|
261
314
|
| {
|
|
262
315
|
kind: "editSplit";
|
|
@@ -20,9 +20,11 @@ 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";
|
|
27
|
+
import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
|
|
26
28
|
import type { CanvasMode } from "./iframe-protocol";
|
|
27
29
|
import type { JxDocument } from "@jxsuite/schema/types";
|
|
28
30
|
import type { PathMapCtx } from "./path-mapping";
|
|
@@ -150,6 +152,18 @@ export const EDIT_PLACEHOLDER_CSS = `
|
|
|
150
152
|
font-size: 13px;
|
|
151
153
|
pointer-events: none;
|
|
152
154
|
}
|
|
155
|
+
[data-jx-bound-prop]:hover {
|
|
156
|
+
cursor: text;
|
|
157
|
+
outline: 1px dashed color-mix(in srgb, #808080 40%, transparent);
|
|
158
|
+
outline-offset: 1px;
|
|
159
|
+
}
|
|
160
|
+
[data-jx-bound-prop]:empty:not([contenteditable="plaintext-only"]):not([contenteditable="true"])::after {
|
|
161
|
+
content: "Empty \\2014 double-click to edit";
|
|
162
|
+
color: color-mix(in srgb, #808080 40%, transparent);
|
|
163
|
+
font-style: italic;
|
|
164
|
+
font-size: 13px;
|
|
165
|
+
pointer-events: none;
|
|
166
|
+
}
|
|
153
167
|
`;
|
|
154
168
|
|
|
155
169
|
/**
|
|
@@ -281,22 +295,42 @@ export function syncStylebookCss(doc: Document, mode: CanvasMode): void {
|
|
|
281
295
|
doc.head.append(style);
|
|
282
296
|
}
|
|
283
297
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Apply the project's site style as a real stylesheet (replace-in-place): custom properties on
|
|
300
|
+
* `:root`, plain properties on `body`, conditional blocks dual-emitted per the forced-scheme
|
|
301
|
+
* contract. A stylesheet — not inline root properties — so `:root[data-color-scheme]` override
|
|
302
|
+
* selectors can win (spec §9.5), and so removed tokens can't linger on reused iframes.
|
|
303
|
+
*/
|
|
304
|
+
export function applySiteStyle(
|
|
305
|
+
siteStyle: Record<string, unknown> | null | undefined,
|
|
306
|
+
mediaQueries: Record<string, string> = {},
|
|
307
|
+
): void {
|
|
308
|
+
const existing = document.head.querySelector(`#${SITE_STYLE_ID}`);
|
|
286
309
|
if (!siteStyle || typeof siteStyle !== "object") {
|
|
310
|
+
existing?.remove();
|
|
287
311
|
return;
|
|
288
312
|
}
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
313
|
+
const css = buildSiteStyleCSS(siteStyle, mediaQueries, transposeCanvasUnits);
|
|
314
|
+
if (existing) {
|
|
315
|
+
existing.textContent = css;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const tag = document.createElement("style");
|
|
319
|
+
tag.id = SITE_STYLE_ID;
|
|
320
|
+
tag.textContent = css;
|
|
321
|
+
document.head.append(tag);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Force or clear the color-scheme preview on the iframe's root element (the platform's
|
|
326
|
+
* data-color-scheme contract, spec §9.5). Survives re-renders and patches — renders only replace
|
|
327
|
+
* the container's children, never the root element.
|
|
328
|
+
*/
|
|
329
|
+
export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark" | null): void {
|
|
330
|
+
if (scheme) {
|
|
331
|
+
doc.documentElement.dataset.colorScheme = scheme;
|
|
332
|
+
} else {
|
|
333
|
+
delete doc.documentElement.dataset.colorScheme;
|
|
300
334
|
}
|
|
301
335
|
}
|
|
302
336
|
|
|
@@ -425,7 +459,12 @@ export async function renderResolvedDocument(opts: {
|
|
|
425
459
|
// De-link `<a href>` in design/edit so clicks select the anchor instead of navigating the iframe;
|
|
426
460
|
// Preview keeps real links live (mirrors the server-function gate above).
|
|
427
461
|
setCanvasDelinkAnchors(opts.mode !== "preview");
|
|
428
|
-
|
|
462
|
+
// Stamp `data-jx-bound-prop` on component-internal invertible text bindings in design/edit only —
|
|
463
|
+
// The inline prop-edit affordance. Set every render so a preview/stylebook render in the same
|
|
464
|
+
// Iframe clears it (page-level templates are inert in design/edit via prepareForEditMode, so only
|
|
465
|
+
// Component internals get stamped).
|
|
466
|
+
setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
|
|
467
|
+
applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
|
|
429
468
|
injectHead(opts.doc);
|
|
430
469
|
syncEditModeCss(opts.container.ownerDocument, opts.mode);
|
|
431
470
|
syncStylebookCss(opts.container.ownerDocument, opts.mode);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Site-style stylesheet builder — pure functions extracted for testability.
|
|
4
|
+
*
|
|
5
|
+
* The canvas iframe injects the project's `style` as a real stylesheet instead of inline properties
|
|
6
|
+
* on the root element: inline custom properties would defeat the forced-scheme override selectors
|
|
7
|
+
* (`:root[data-color-scheme]`, spec §9.5), and object-valued `@--name` blocks used to be dropped
|
|
8
|
+
* entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so the canvas
|
|
9
|
+
* honors both the OS preference and the preview toggle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pureSchemeOf, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
|
|
13
|
+
|
|
14
|
+
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
15
|
+
export const SITE_STYLE_ID = "jx-site-style";
|
|
16
|
+
|
|
17
|
+
/** Serialize scalar declarations, kebab-casing property names and transposing values. */
|
|
18
|
+
function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
|
|
19
|
+
return Object.entries(props)
|
|
20
|
+
.map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
|
|
21
|
+
.join("; ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
|
|
26
|
+
* conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
|
|
27
|
+
* forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
|
|
28
|
+
*
|
|
29
|
+
* @param {Record<string, unknown>} siteStyle
|
|
30
|
+
* @param {Record<string, string>} mediaQueries
|
|
31
|
+
* @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function buildSiteStyleCSS(
|
|
35
|
+
siteStyle: Record<string, unknown>,
|
|
36
|
+
mediaQueries: Record<string, string>,
|
|
37
|
+
transpose: (value: string) => string,
|
|
38
|
+
): string {
|
|
39
|
+
const rules: string[] = [];
|
|
40
|
+
const rootProps: Record<string, unknown> = {};
|
|
41
|
+
const bodyProps: Record<string, unknown> = {};
|
|
42
|
+
const condBlocks: [string, Record<string, unknown>][] = [];
|
|
43
|
+
|
|
44
|
+
for (const [key, value] of Object.entries(siteStyle)) {
|
|
45
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
46
|
+
if (key.startsWith("@")) {
|
|
47
|
+
condBlocks.push([key, value as Record<string, unknown>]);
|
|
48
|
+
}
|
|
49
|
+
// Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
|
|
50
|
+
// Own style pass covers those; the site sheet handles tokens + conditional overrides.
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (key.startsWith("--")) {
|
|
57
|
+
rootProps[key] = value;
|
|
58
|
+
} else {
|
|
59
|
+
bodyProps[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rootCSS = toDecls(rootProps, transpose);
|
|
64
|
+
if (rootCSS) {
|
|
65
|
+
rules.push(`:root { ${rootCSS} }`);
|
|
66
|
+
}
|
|
67
|
+
const bodyCSS = toDecls(bodyProps, transpose);
|
|
68
|
+
if (bodyCSS) {
|
|
69
|
+
rules.push(`body { ${bodyCSS} }`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [atKey, block] of condBlocks) {
|
|
73
|
+
const query = atKey.startsWith("@--")
|
|
74
|
+
? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
|
|
75
|
+
: atKey.startsWith("@(")
|
|
76
|
+
? atKey.slice(1)
|
|
77
|
+
: null;
|
|
78
|
+
if (query === null) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const scheme = pureSchemeOf(query);
|
|
82
|
+
const emit = (selector: string, props: string) => {
|
|
83
|
+
if (!props) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (scheme) {
|
|
87
|
+
const { auto, forced } = schemeSelectors(selector, scheme);
|
|
88
|
+
rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
|
|
89
|
+
} else {
|
|
90
|
+
rules.push(`@media ${query} { ${selector} { ${props} } }`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const condRoot: Record<string, unknown> = {};
|
|
94
|
+
const condBody: Record<string, unknown> = {};
|
|
95
|
+
const condSubs: [string, Record<string, unknown>][] = [];
|
|
96
|
+
for (const [k, v] of Object.entries(block)) {
|
|
97
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
98
|
+
if (!k.startsWith("@")) {
|
|
99
|
+
condSubs.push([k, v as Record<string, unknown>]);
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (k.startsWith("--")) {
|
|
104
|
+
condRoot[k] = v;
|
|
105
|
+
} else {
|
|
106
|
+
condBody[k] = v;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
emit(":root", toDecls(condRoot, transpose));
|
|
110
|
+
emit("body", toDecls(condBody, transpose));
|
|
111
|
+
for (const [sel, sub] of condSubs) {
|
|
112
|
+
emit(sel, toDecls(sub, transpose));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
|
|
117
|
+
rules.push(":root { color-scheme: light dark }");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return rules.join("\n");
|
|
121
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component test props (M6) — the previewParams mirror for component docs. A non-instantiated
|
|
3
|
+
* component definition renders on the canvas with its state defaults; "test values" let the author
|
|
4
|
+
* pick per-prop values that the canvas render seeds into the definition's state, so templates,
|
|
5
|
+
* dataScope snapshots, and live/snapshot expression previews all see real data.
|
|
6
|
+
*
|
|
7
|
+
* Pure and DOM-free: {@link componentPropEntries} derives the editable prop rows from the doc's
|
|
8
|
+
* state (the same plain-data entries the CEM export publishes as fields), and
|
|
9
|
+
* {@link substitutePreviewProps} rebuilds a render doc with chosen values seeded — never mutating
|
|
10
|
+
* the source document (the substitutePreviewParams contract).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { JsonValue } from "./types";
|
|
14
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
15
|
+
|
|
16
|
+
/** One editable prop row: the state key and its current default value (undefined = none). */
|
|
17
|
+
export interface ComponentPropEntry {
|
|
18
|
+
name: string;
|
|
19
|
+
value: JsonValue | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether the doc is a component definition (a custom-element root) rather than a page. */
|
|
23
|
+
export function isComponentDoc(doc: JxMutableNode | null | undefined): boolean {
|
|
24
|
+
return typeof doc?.tagName === "string" && doc.tagName.includes("-");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classify a state entry as a prop (plain data the CEM export publishes as a field): naked
|
|
29
|
+
* literals/arrays/objects and expanded/typed signal defs — never expressions, computeds, functions,
|
|
30
|
+
* templates, or $prototype data sources (overriding those would clobber behavior, the boundPropKey
|
|
31
|
+
* rule).
|
|
32
|
+
*/
|
|
33
|
+
function propShape(def: unknown): "literal" | "signal" | null {
|
|
34
|
+
if (def === null || typeof def === "number" || typeof def === "boolean") {
|
|
35
|
+
return "literal";
|
|
36
|
+
}
|
|
37
|
+
if (typeof def === "string") {
|
|
38
|
+
return def.includes("${") ? null : "literal"; // Template strings render computed values.
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(def)) {
|
|
41
|
+
return "literal";
|
|
42
|
+
}
|
|
43
|
+
if (typeof def === "object") {
|
|
44
|
+
const d = def as Record<string, unknown>;
|
|
45
|
+
if (d.$expression || d.$compute || d.$prototype || d.$handler) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// An expanded signal ({default}) or a typed prop def ({type}/schema keywords) seeds through
|
|
49
|
+
// Its `default`; any other plain object is a Shape-1 naked value.
|
|
50
|
+
return "default" in d || "type" in d ? "signal" : "literal";
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The component's editable prop entries, derived from the doc's state — non-private plain-data
|
|
57
|
+
* entries with their current default values (the CEM-export "field" subset).
|
|
58
|
+
*/
|
|
59
|
+
export function componentPropEntries(doc: JxMutableNode | null | undefined): ComponentPropEntry[] {
|
|
60
|
+
const state = (doc?.state ?? {}) as Record<string, unknown>;
|
|
61
|
+
const out: ComponentPropEntry[] = [];
|
|
62
|
+
for (const [name, def] of Object.entries(state)) {
|
|
63
|
+
if (name.startsWith("#")) {
|
|
64
|
+
continue; // Private entries are not props (the CEM-export rule).
|
|
65
|
+
}
|
|
66
|
+
const shape = propShape(def);
|
|
67
|
+
if (!shape) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const value =
|
|
71
|
+
shape === "signal"
|
|
72
|
+
? ((def as { default?: JsonValue }).default as JsonValue | undefined)
|
|
73
|
+
: (def as JsonValue);
|
|
74
|
+
out.push({ name, value });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rebuild `renderDoc` with the chosen test-prop values seeded into its state: a literal entry is
|
|
81
|
+
* replaced by the value; a signal/typed entry keeps its metadata and gets the value as `default`
|
|
82
|
+
* (exactly what buildScope reads). Pure — returns a shallow rebuild; the render doc shares node
|
|
83
|
+
* references with the tab's source document, so in-place mutation would corrupt the edited doc.
|
|
84
|
+
*/
|
|
85
|
+
export function substitutePreviewProps(
|
|
86
|
+
renderDoc: JxMutableNode,
|
|
87
|
+
props: Record<string, JsonValue>,
|
|
88
|
+
): JxMutableNode {
|
|
89
|
+
const state = { ...((renderDoc.state ?? {}) as Record<string, unknown>) };
|
|
90
|
+
let seeded = false;
|
|
91
|
+
for (const [name, value] of Object.entries(props)) {
|
|
92
|
+
const shape = propShape(state[name]);
|
|
93
|
+
if (!shape) {
|
|
94
|
+
continue; // Unknown/behavioral entries are never overridden by a stale test value.
|
|
95
|
+
}
|
|
96
|
+
state[name] =
|
|
97
|
+
shape === "signal" ? { ...(state[name] as Record<string, unknown>), default: value } : value;
|
|
98
|
+
seeded = true;
|
|
99
|
+
}
|
|
100
|
+
if (!seeded) {
|
|
101
|
+
return renderDoc;
|
|
102
|
+
}
|
|
103
|
+
return { ...renderDoc, state } as JxMutableNode;
|
|
104
|
+
}
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
import { childIndex, getNodeAtPath, parentElementPath } from "../store";
|
|
11
11
|
import { isTabActive } from "../workspace/workspace";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
mutateInsertNode,
|
|
14
|
+
mutateUpdateProp,
|
|
15
|
+
mutateUpdateProperty,
|
|
16
|
+
transactDoc,
|
|
17
|
+
} from "../tabs/transact";
|
|
13
18
|
import { defaultDef } from "../panels/shared";
|
|
14
19
|
|
|
15
20
|
import type { JxContentResult, SlashCommand } from "./inline-edit";
|
|
@@ -83,6 +88,36 @@ export function applyInlineCommit(
|
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Commit a prop-bound inline edit: persist `value` into `$props[prop]` of the component instance at
|
|
93
|
+
* `path`. The unchanged-value no-op is LOAD-BEARING, not cosmetic: Escape-cancel and
|
|
94
|
+
* patch-disturbed sessions post unchanged values, and without the equality check a commit→patch→
|
|
95
|
+
* disturb→re-commit cycle loops (and pollutes undo history). An empty value deletes the prop
|
|
96
|
+
* (mutateUpdateProp), reverting the instance to the definition default — same as clearing the
|
|
97
|
+
* sidebar field.
|
|
98
|
+
*/
|
|
99
|
+
export function applyInlinePropCommit(
|
|
100
|
+
tab: Tab | null,
|
|
101
|
+
path: JxPath,
|
|
102
|
+
prop: string,
|
|
103
|
+
value: string,
|
|
104
|
+
): void {
|
|
105
|
+
if (!tab) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const node = getNodeAtPath(tab.doc.document, path);
|
|
109
|
+
if (!node) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const current = (node.$props as Record<string, unknown> | undefined)?.[prop];
|
|
113
|
+
if (`${current ?? ""}` === value) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
transactDoc(tab, (t) => {
|
|
117
|
+
mutateUpdateProp(t, path, prop, value);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
/**
|
|
87
122
|
* Apply a paragraph split to `tab`'s document: keep `before` in the node, insert a new `<p>` with
|
|
88
123
|
* `after`. Returns its path (unchanged when `tab` is gone — the caller may still use it for
|