@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.
Files changed (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. 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
- | { kind: "editStart"; path: (string | number)[] }
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. While attached, the shared session persists automatically: `dirty` stays false
11
- * and Cmd+S becomes a serialize→flush.
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
- // The shared session persists automatically; the dirty dot means "unsaved" and stays off.
190
- tab.doc.dirty = false;
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
- tab.doc.dirty = false;
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
- tab.doc.dirty = false;
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
- let state = states.get(tab);
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(tab, state);
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/parser/html-to-jx";
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 { mutateInsertNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
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
- el.contentEditable = "true";
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
- JxMappedArray,
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
- * `projectConfig.contentTypes[contentType].schema.properties` and emit as `item.data.<f>` (the
78
- * `.data.` nesting is LOAD-BEARING — runtime templates use `${item.data.title}`), plus `item.id`
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 ct = projectConfig?.contentTypes?.[def.contentType];
108
- const schema = ct?.schema as ContentTypeSchema | undefined;
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))) {