@jxsuite/studio 0.37.1 → 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 +103709 -81001
- package/dist/studio.js.map +201 -157
- package/package.json +47 -45
- package/src/account-status.ts +39 -0
- package/src/browse/browse.ts +10 -14
- 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 +132 -6
- 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/context-menu.ts +1 -1
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/repeater-scope.ts +8 -13
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +56 -1
- package/src/format/format-host.ts +63 -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/new-project/add-repo-modal.ts +183 -0
- package/src/new-project/new-project-modal.ts +22 -3
- package/src/page-params.ts +34 -8
- package/src/panels/ai-chat/chat-markdown.ts +2 -2
- package/src/panels/ai-panel.ts +61 -4
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +241 -0
- 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 +217 -460
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/statusbar.ts +1 -1
- 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/panels/welcome-screen.ts +50 -0
- package/src/platform-errors.ts +30 -0
- package/src/platforms/cloud.ts +172 -89
- package/src/platforms/devserver.ts +172 -0
- 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/context-resolver.ts +73 -0
- package/src/services/data-service.ts +155 -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/monaco-setup.ts +75 -26
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/settings/contributed-section.ts +406 -0
- package/src/settings/extension-sections.ts +145 -0
- package/src/settings/schema-field-ui.ts +4 -2
- package/src/settings/settings-modal.ts +101 -42
- package/src/site-context.ts +12 -1
- package/src/store.ts +4 -0
- package/src/studio.ts +107 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +61 -14
- package/src/types.ts +132 -1
- 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/form-controls.ts +322 -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/ui/progress-modal.ts +2 -2
- package/src/ui/schema-form.ts +524 -0
- package/src/utils/preview-format.ts +26 -0
- package/src/utils/studio-utils.ts +4 -3
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
- package/src/settings/content-types-editor.ts +0 -599
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { html } from "lit-html";
|
|
4
4
|
import { jsonClone } from "../utils/studio-utils";
|
|
5
5
|
import { ref } from "lit-html/directives/ref.js";
|
|
6
|
-
import { htmlToJx } from "@jxsuite/
|
|
6
|
+
import { htmlToJx } from "@jxsuite/markup/html-to-jx";
|
|
7
7
|
import { childIndex, getNodeAtPath, parentElementPath } from "../store";
|
|
8
8
|
import { activeTab, workspace } from "../workspace/workspace";
|
|
9
9
|
import {
|
|
@@ -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
|
|
@@ -193,6 +193,15 @@ let endFn: (() => void) | null = null; // Function() called when editing stops
|
|
|
193
193
|
*/
|
|
194
194
|
let _blurCloseSuspended = false;
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Plain (plaintext-only) session state — used for prop-bound text, where the committed value is a
|
|
198
|
+
* plain string (a directive attribute), so rich formatting, Enter-split, and the slash menu are all
|
|
199
|
+
* disabled: Enter commits, Escape restores `_plainOriginal` and commits unchanged (the host no-ops
|
|
200
|
+
* an unchanged prop value).
|
|
201
|
+
*/
|
|
202
|
+
let _plainMode = false;
|
|
203
|
+
let _plainOriginal = "";
|
|
204
|
+
|
|
196
205
|
/** Suspend blur-driven `stopEditing` (call while the parent toolbar/popover may steal focus). */
|
|
197
206
|
export function suspendBlurClose() {
|
|
198
207
|
_blurCloseSuspended = true;
|
|
@@ -252,6 +261,8 @@ export function isInlineElement(node: JxMutableNode, parentNode?: JxMutableNode)
|
|
|
252
261
|
* onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
|
|
253
262
|
* onEnd: () => void;
|
|
254
263
|
* }} callbacks
|
|
264
|
+
* @param {{ plainText?: boolean }} [opts] - `plainText` runs a plaintext-only session (prop-bound
|
|
265
|
+
* text: no rich formatting/split/slash; Enter commits, Escape cancels)
|
|
255
266
|
*/
|
|
256
267
|
export function startEditing(
|
|
257
268
|
el: HTMLElement,
|
|
@@ -270,6 +281,7 @@ export function startEditing(
|
|
|
270
281
|
onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
|
|
271
282
|
onEnd: () => void;
|
|
272
283
|
},
|
|
284
|
+
opts?: { plainText?: boolean },
|
|
273
285
|
) {
|
|
274
286
|
if (activeEl) {
|
|
275
287
|
// Re-enter (e.g. after a split/insert re-render): tear the old session down WITHOUT firing the
|
|
@@ -283,9 +295,21 @@ export function startEditing(
|
|
|
283
295
|
splitFn = callbacks.onSplit;
|
|
284
296
|
insertFn = callbacks.onInsert;
|
|
285
297
|
endFn = callbacks.onEnd;
|
|
298
|
+
_plainMode = opts?.plainText === true;
|
|
299
|
+
_plainOriginal = _plainMode ? (el.textContent ?? "") : "";
|
|
286
300
|
|
|
287
301
|
// Enable editing
|
|
288
|
-
|
|
302
|
+
if (_plainMode) {
|
|
303
|
+
try {
|
|
304
|
+
el.contentEditable = "plaintext-only";
|
|
305
|
+
} catch {
|
|
306
|
+
// Engines without plaintext-only support throw on assignment; paste is already plain via
|
|
307
|
+
// HandlePaste, and the keydown plain branch inertifies the format shortcuts.
|
|
308
|
+
el.contentEditable = "true";
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
el.contentEditable = "true";
|
|
312
|
+
}
|
|
289
313
|
el.style.pointerEvents = "auto";
|
|
290
314
|
el.style.outline = "2px solid var(--accent, #4a9eff)";
|
|
291
315
|
el.style.outlineOffset = "1px";
|
|
@@ -338,6 +362,8 @@ export function stopEditing(silent = false) {
|
|
|
338
362
|
commitFn = null;
|
|
339
363
|
splitFn = null;
|
|
340
364
|
insertFn = null;
|
|
365
|
+
_plainMode = false;
|
|
366
|
+
_plainOriginal = "";
|
|
341
367
|
|
|
342
368
|
if (silent) {
|
|
343
369
|
endFn = null;
|
|
@@ -389,6 +415,30 @@ export function isSlashActive() {
|
|
|
389
415
|
|
|
390
416
|
/** @param {KeyboardEvent} e */
|
|
391
417
|
function handleKeydown(e: KeyboardEvent) {
|
|
418
|
+
if (_plainMode) {
|
|
419
|
+
if (e.key === "Escape") {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
e.stopPropagation();
|
|
422
|
+
if (activeEl) {
|
|
423
|
+
// Cancel: restore the original text; the commit posts the unchanged value (host no-ops).
|
|
424
|
+
activeEl.textContent = _plainOriginal;
|
|
425
|
+
}
|
|
426
|
+
stopEditing();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (e.key === "Enter") {
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
e.stopPropagation();
|
|
432
|
+
stopEditing();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Inertify the browser's native rich shortcuts for the contentEditable="true" fallback.
|
|
436
|
+
if ((e.ctrlKey || e.metaKey) && ["b", "i", "u", "`"].includes(e.key)) {
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
392
442
|
if (e.key === "Escape") {
|
|
393
443
|
e.preventDefault();
|
|
394
444
|
e.stopPropagation();
|
|
@@ -551,6 +601,13 @@ function commitChanges() {
|
|
|
551
601
|
return;
|
|
552
602
|
}
|
|
553
603
|
|
|
604
|
+
if (_plainMode) {
|
|
605
|
+
// A prop value is a plain single-line string (a directive attribute) — flatten any newline
|
|
606
|
+
// That survived plaintext editing and skip the rich DOM→Jx serialization entirely.
|
|
607
|
+
commitFn(activePath, null, (activeEl.textContent ?? "").replaceAll("\n", " "));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
554
611
|
normalizeInlineContent(activeEl);
|
|
555
612
|
const result = elementToJx(activeEl);
|
|
556
613
|
commitFn(activePath, result.children ?? null, result.textContent ?? null);
|
|
@@ -16,12 +16,8 @@ import { isRef } from "@jxsuite/schema/guards";
|
|
|
16
16
|
import { getNodeAtPath } from "../state";
|
|
17
17
|
|
|
18
18
|
import type { JxPath } from "../state";
|
|
19
|
-
import type {
|
|
20
|
-
|
|
21
|
-
JxMutableNode,
|
|
22
|
-
ProjectConfig,
|
|
23
|
-
ContentTypeSchema,
|
|
24
|
-
} from "@jxsuite/schema/types";
|
|
19
|
+
import type { ContentSectionEntry } from "../types";
|
|
20
|
+
import type { JxMappedArray, JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
|
|
25
21
|
|
|
26
22
|
/** Cap on how many inferred/declared fields we enumerate (mirrors merge-tags BREADTH_CAP breadth). */
|
|
27
23
|
const FIELD_CAP = 50;
|
|
@@ -73,10 +69,9 @@ function safeFields(names: string[]): string[] {
|
|
|
73
69
|
* `index`; appends field tokens resolved from the repeater's binding:
|
|
74
70
|
*
|
|
75
71
|
* - **Content collection** — `items` refs a `{ contentType }` state def. Item shape is `{ id, data: {
|
|
76
|
-
* …fields }, body }`, so fields come from
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* / `item.body`.
|
|
72
|
+
* …fields }, body }`, so fields come from `projectConfig.content[contentType].schema.properties`
|
|
73
|
+
* and emit as `item.data.<f>` (the `.data.` nesting is LOAD-BEARING — runtime templates use
|
|
74
|
+
* `${item.data.title}`), plus `item.id` / `item.body`.
|
|
80
75
|
* - **State array** — `items` refs a state def that is an array of objects. Fields come from a
|
|
81
76
|
* declared `items.properties`, else are inferred from a sample `default[0]`. Emitted FLAT
|
|
82
77
|
* (`item.<f>`, no `.data.`).
|
|
@@ -104,9 +99,9 @@ export function resolveRepeaterItemFields(
|
|
|
104
99
|
// (a) Content collection — `{ contentType: "<name>" }`. Import-aliased `$prototype` still
|
|
105
100
|
// Matches because we detect on the `contentType` string, not the prototype name.
|
|
106
101
|
if (typeof def.contentType === "string") {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const props = schema?.properties;
|
|
102
|
+
const content = (projectConfig?.content ?? {}) as Record<string, ContentSectionEntry>;
|
|
103
|
+
const ct = content[def.contentType];
|
|
104
|
+
const props = ct?.schema?.properties;
|
|
110
105
|
const out = [...base, "item.id", "item.body"];
|
|
111
106
|
if (props && typeof props === "object") {
|
|
112
107
|
for (const f of safeFields(Object.keys(props))) {
|