@jxsuite/studio 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
package/src/tabs/tab.ts
CHANGED
|
@@ -4,14 +4,16 @@ import { formatByName, formatForPath } from "../format/format-host";
|
|
|
4
4
|
import { normalizeArrayChildren } from "../state";
|
|
5
5
|
import type {
|
|
6
6
|
DocumentStackEntry,
|
|
7
|
+
FormulaEditDef,
|
|
7
8
|
FunctionEditDef,
|
|
8
9
|
GitBranchesResult,
|
|
9
10
|
GitDiffState,
|
|
10
11
|
GitStatusResult,
|
|
11
12
|
InlineEditDef,
|
|
13
|
+
JsonValue,
|
|
12
14
|
} from "../types";
|
|
13
15
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
14
|
-
import type { JxDocOp } from "./patch-ops";
|
|
16
|
+
import type { JxDocOp, JxFmOp } from "./patch-ops";
|
|
15
17
|
|
|
16
18
|
export interface TabUi {
|
|
17
19
|
rightTab: string;
|
|
@@ -20,12 +22,26 @@ export interface TabUi {
|
|
|
20
22
|
preview: boolean;
|
|
21
23
|
/** Show elements inherited from the page's layout (pages with an effective layout only). */
|
|
22
24
|
showLayout: boolean;
|
|
25
|
+
/** Above-canvas frontmatter Properties accordion expanded (content-collection docs, edit mode). */
|
|
26
|
+
frontmatterOpen: boolean;
|
|
23
27
|
/** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
|
|
24
28
|
previewParams: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Chosen test values for a component doc's props (state entries), seeded into the canvas render
|
|
31
|
+
* so a non-instantiated component previews with real data (M6) — the previewParams mirror.
|
|
32
|
+
*/
|
|
33
|
+
previewProps: Record<string, JsonValue> | null;
|
|
25
34
|
zoom: number;
|
|
35
|
+
/**
|
|
36
|
+
* Edit-mode content zoom — browser-page-zoom semantics (content reflows at the zoomed effective
|
|
37
|
+
* width while the canvas footprint stays fixed), unlike `zoom` which pan/zooms the whole canvas.
|
|
38
|
+
*/
|
|
39
|
+
editZoom: number;
|
|
26
40
|
activeMedia: string | null;
|
|
27
41
|
activeSelector: string | null;
|
|
28
42
|
editingFunction: FunctionEditDef | null;
|
|
43
|
+
/** Full-screen formula workspace target ($expression editing); editingFunction wins if both set. */
|
|
44
|
+
editingFormula: FormulaEditDef | null;
|
|
29
45
|
featureToggles: Record<string, boolean>;
|
|
30
46
|
styleSections: Record<string, boolean>;
|
|
31
47
|
inspectorSections: Record<string, boolean>;
|
|
@@ -46,6 +62,18 @@ export interface TabUi {
|
|
|
46
62
|
pendingInlineEdit: InlineEditDef | null;
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A live (iframe-evaluated) expression preview retained per editing target (M6). Stored on
|
|
67
|
+
* `session.canvas` beside `scope` — plain wire data (path-key → display-string pairs), rebuilt into
|
|
68
|
+
* an ExpressionPreview by services/live-preview.ts on read.
|
|
69
|
+
*/
|
|
70
|
+
export interface StoredLivePreview {
|
|
71
|
+
/** Serialized expression + context the values were computed for (staleness check). */
|
|
72
|
+
key: string;
|
|
73
|
+
values: [string, string][];
|
|
74
|
+
error: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
interface HistorySnapshot {
|
|
50
78
|
/** Full document at this state (checkpoint), or null when the ops describe the transition. */
|
|
51
79
|
document: Record<string, unknown> | null;
|
|
@@ -57,6 +85,8 @@ interface HistorySnapshot {
|
|
|
57
85
|
forwardOps?: JxDocOp[] | null;
|
|
58
86
|
/** Replayable ops transforming this state back into the previous one. */
|
|
59
87
|
inverseOps?: JxDocOp[] | null;
|
|
88
|
+
/** Frontmatter key changes in this transaction (before/after values, replayed both ways). */
|
|
89
|
+
fmOps?: JxFmOp[] | null;
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
export interface Tab {
|
|
@@ -89,6 +119,9 @@ export interface Tab {
|
|
|
89
119
|
// The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
|
|
90
120
|
// The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
|
|
91
121
|
scope: Record<string, unknown> | null;
|
|
122
|
+
// Latest live (iframe-evaluated) expression previews keyed by editing-target id, stored
|
|
123
|
+
// Beside `scope` the same way (services/live-preview.ts owns reads/writes).
|
|
124
|
+
livePreviews: Record<string, StoredLivePreview> | null;
|
|
92
125
|
error: string | null;
|
|
93
126
|
pendingInlineEdit: InlineEditDef | null;
|
|
94
127
|
};
|
|
@@ -109,8 +142,11 @@ function createDefaultUi(canvasMode: string, preview = false) {
|
|
|
109
142
|
activeMedia: null,
|
|
110
143
|
activeSelector: null,
|
|
111
144
|
canvasMode,
|
|
145
|
+
editZoom: 1,
|
|
146
|
+
editingFormula: null,
|
|
112
147
|
editingFunction: null,
|
|
113
148
|
featureToggles: {},
|
|
149
|
+
frontmatterOpen: true,
|
|
114
150
|
gitBranches: null,
|
|
115
151
|
gitCommitMessage: "",
|
|
116
152
|
gitDiffState: null,
|
|
@@ -121,6 +157,7 @@ function createDefaultUi(canvasMode: string, preview = false) {
|
|
|
121
157
|
pendingInlineEdit: null,
|
|
122
158
|
preview,
|
|
123
159
|
previewParams: {},
|
|
160
|
+
previewProps: null,
|
|
124
161
|
rightTab: "properties",
|
|
125
162
|
settingsTab: "stylebook",
|
|
126
163
|
showLayout: true,
|
|
@@ -204,6 +241,7 @@ export function createTab({
|
|
|
204
241
|
session: reactive({
|
|
205
242
|
canvas: {
|
|
206
243
|
error: null,
|
|
244
|
+
livePreviews: null,
|
|
207
245
|
pendingInlineEdit: null,
|
|
208
246
|
scope: null,
|
|
209
247
|
status: "idle",
|
package/src/tabs/transact.ts
CHANGED
|
@@ -8,16 +8,23 @@ import {
|
|
|
8
8
|
endRecording,
|
|
9
9
|
getPatchConsumer,
|
|
10
10
|
recordDocOp,
|
|
11
|
+
recordFmOp,
|
|
11
12
|
recordPatch,
|
|
12
13
|
} from "./patch-ops";
|
|
13
14
|
|
|
14
|
-
import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
|
|
15
|
+
import type { JxDocOp, JxDocOpPair, JxFmOp, TransactionRecord } from "./patch-ops";
|
|
15
16
|
|
|
16
17
|
import type { Tab } from "../tabs/tab";
|
|
17
18
|
import type { JxPath } from "../state";
|
|
18
19
|
|
|
19
20
|
import type { JsonValue } from "../types";
|
|
20
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
JxAttributeValue,
|
|
23
|
+
JxEventBinding,
|
|
24
|
+
JxMutableNode,
|
|
25
|
+
JxStateObject,
|
|
26
|
+
JxStyle,
|
|
27
|
+
} from "@jxsuite/schema/types";
|
|
21
28
|
import { ensureNestedStyle, getNestedStyle, isEventBinding } from "@jxsuite/schema/guards";
|
|
22
29
|
|
|
23
30
|
const HISTORY_LIMIT = 100;
|
|
@@ -205,10 +212,14 @@ function pushHistoryEntry(
|
|
|
205
212
|
selectionBefore: JxPath | null,
|
|
206
213
|
) {
|
|
207
214
|
const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
|
|
208
|
-
const useOps =
|
|
215
|
+
const useOps =
|
|
216
|
+
patchHistoryEnabled() &&
|
|
217
|
+
record.invertible &&
|
|
218
|
+
(record.docOps.length > 0 || record.fmOps.length > 0);
|
|
209
219
|
const needCheckpoint = !useOps || truncated.length % CHECKPOINT_INTERVAL === 0;
|
|
210
220
|
truncated.push({
|
|
211
221
|
document: needCheckpoint ? jsonClone(raw) : null,
|
|
222
|
+
fmOps: useOps && record.fmOps.length > 0 ? record.fmOps : null,
|
|
212
223
|
forwardOps: useOps ? record.docOps.map((p) => p.forward) : null,
|
|
213
224
|
inverseOps: useOps ? record.docOps.map((p) => p.inverse) : null,
|
|
214
225
|
selection: tab.session.selection ? [...tab.session.selection] : null,
|
|
@@ -471,14 +482,23 @@ export function undo(tab: Tab) {
|
|
|
471
482
|
}
|
|
472
483
|
const entry = tab.history.snapshots[tab.history.index]!;
|
|
473
484
|
const inverseOps = patchHistoryEnabled() ? entry.inverseOps : null;
|
|
474
|
-
|
|
485
|
+
const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
|
|
486
|
+
if ((inverseOps && inverseOps.length > 0) || (fmOps && fmOps.length > 0)) {
|
|
475
487
|
// Surgical path: apply inverse ops through the normal transaction pipeline (the canvas
|
|
476
488
|
// Patches in place when it can), in reverse recording order.
|
|
477
489
|
transactDoc(
|
|
478
490
|
tab,
|
|
479
491
|
(t) => {
|
|
480
|
-
|
|
481
|
-
|
|
492
|
+
if (inverseOps) {
|
|
493
|
+
for (let i = inverseOps.length - 1; i >= 0; i--) {
|
|
494
|
+
applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (fmOps) {
|
|
498
|
+
for (let i = fmOps.length - 1; i >= 0; i--) {
|
|
499
|
+
const op = toRaw(fmOps[i]) as JxFmOp;
|
|
500
|
+
applyFmState(t, op.field, op.before);
|
|
501
|
+
}
|
|
482
502
|
}
|
|
483
503
|
},
|
|
484
504
|
{ origin: "history", skipHistory: true },
|
|
@@ -505,12 +525,21 @@ export function redo(tab: Tab) {
|
|
|
505
525
|
}
|
|
506
526
|
const entry = tab.history.snapshots[tab.history.index + 1]!;
|
|
507
527
|
const forwardOps = patchHistoryEnabled() ? entry.forwardOps : null;
|
|
508
|
-
|
|
528
|
+
const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
|
|
529
|
+
if ((forwardOps && forwardOps.length > 0) || (fmOps && fmOps.length > 0)) {
|
|
509
530
|
transactDoc(
|
|
510
531
|
tab,
|
|
511
532
|
(t) => {
|
|
512
|
-
|
|
513
|
-
|
|
533
|
+
if (forwardOps) {
|
|
534
|
+
for (const op of forwardOps) {
|
|
535
|
+
applyDocOp(t, toRaw(op) as JxDocOp);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (fmOps) {
|
|
539
|
+
for (const op of fmOps) {
|
|
540
|
+
const raw = toRaw(op) as JxFmOp;
|
|
541
|
+
applyFmState(t, raw.field, raw.after);
|
|
542
|
+
}
|
|
514
543
|
}
|
|
515
544
|
},
|
|
516
545
|
{ origin: "history", skipHistory: true },
|
|
@@ -746,13 +775,13 @@ export function mutateUpdateStyle(tab: Tab, path: JxPath, prop: string, value: s
|
|
|
746
775
|
* @param {Tab} tab
|
|
747
776
|
* @param {JxPath} path
|
|
748
777
|
* @param {string} attr
|
|
749
|
-
* @param {
|
|
778
|
+
* @param {JxAttributeValue | undefined} value — a literal, `$ref` binding, or `${}` template.
|
|
750
779
|
*/
|
|
751
780
|
export function mutateUpdateAttribute(
|
|
752
781
|
tab: Tab,
|
|
753
782
|
path: JxPath,
|
|
754
783
|
attr: string,
|
|
755
|
-
value?:
|
|
784
|
+
value?: JxAttributeValue | undefined,
|
|
756
785
|
) {
|
|
757
786
|
const node = getNodeAtPath(tab.doc.document, path);
|
|
758
787
|
const attrsBefore = cloneValue(node.attributes);
|
|
@@ -1196,10 +1225,28 @@ export function mutateRenameSwitchCase(tab: Tab, path: JxPath, oldName: string,
|
|
|
1196
1225
|
* @param {JsonValue} value
|
|
1197
1226
|
*/
|
|
1198
1227
|
export function mutateUpdateFrontmatter(tab: Tab, field: string, value?: JsonValue) {
|
|
1199
|
-
|
|
1228
|
+
const fm = tab.doc.content.frontmatter;
|
|
1229
|
+
const before = Object.hasOwn(fm, field) ? cloneValue(fm[field]) : undefined;
|
|
1230
|
+
const deletes = value === undefined || value === null || value === "";
|
|
1231
|
+
if (deletes) {
|
|
1232
|
+
delete fm[field];
|
|
1233
|
+
} else {
|
|
1234
|
+
fm[field] = value;
|
|
1235
|
+
}
|
|
1236
|
+
const after = deletes ? undefined : cloneValue(value);
|
|
1237
|
+
recordFmOp({ after, before, field });
|
|
1238
|
+
tab.doc.dirty = true;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Restore a frontmatter key to a recorded history state: `undefined` means the key was absent.
|
|
1243
|
+
* Unlike `mutateUpdateFrontmatter`, values like `""`/`false`/`null` are written verbatim (source
|
|
1244
|
+
* round-trips can put them there) and no fm op is recorded — history replay must not re-record.
|
|
1245
|
+
*/
|
|
1246
|
+
function applyFmState(tab: Tab, field: string, value: unknown) {
|
|
1247
|
+
if (value === undefined) {
|
|
1200
1248
|
delete tab.doc.content.frontmatter[field];
|
|
1201
1249
|
} else {
|
|
1202
1250
|
tab.doc.content.frontmatter[field] = value;
|
|
1203
1251
|
}
|
|
1204
|
-
tab.doc.dirty = true;
|
|
1205
1252
|
}
|
package/src/types.ts
CHANGED
|
@@ -427,6 +427,18 @@ export interface FunctionEditDef {
|
|
|
427
427
|
parameters?: string[];
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Identifies which document position's `$expression` the full-screen formula workspace is editing:
|
|
432
|
+
* a state entry (`type: "def"` + defName) or an element event binding (`type: "event"` + path +
|
|
433
|
+
* eventKey). Mirrors FunctionEditDef, the Monaco function editor's target shape.
|
|
434
|
+
*/
|
|
435
|
+
export interface FormulaEditDef {
|
|
436
|
+
type: "def" | "event";
|
|
437
|
+
defName?: string;
|
|
438
|
+
path?: JxPath;
|
|
439
|
+
eventKey?: string;
|
|
440
|
+
}
|
|
441
|
+
|
|
430
442
|
export interface GitDiffState {
|
|
431
443
|
filePath: string;
|
|
432
444
|
originalContent: string;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Dynamic-slot — the shared "make this dynamic" control for any bindable document position.
|
|
4
|
+
*
|
|
5
|
+
* Renders the panel-provided static widget plus a label-side mode button that cycles the value up
|
|
6
|
+
* the Rule of Least Power ladder (spec §2.2): literal → $ref pointer → ${} template → $expression.
|
|
7
|
+
* Capability-driven: each slot offers only the modes its schema position allows, so panels stay
|
|
8
|
+
* decoupled from which rungs are blessed where. Each mode's last value is remembered per field for
|
|
9
|
+
* the session, so cycling back to an earlier mode restores what the user had there.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { html, nothing } from "lit-html";
|
|
13
|
+
import { live } from "lit-html/directives/live.js";
|
|
14
|
+
import { isRef, isTemplateString } from "@jxsuite/schema/guards";
|
|
15
|
+
import { cloneValue } from "../tabs/doc-op-apply";
|
|
16
|
+
import { activeTab } from "../workspace/workspace";
|
|
17
|
+
import { renderExpressionEditor } from "./expression-editor";
|
|
18
|
+
|
|
19
|
+
import type { TemplateResult } from "lit-html";
|
|
20
|
+
import type { EditorPreview } from "./expression-editor";
|
|
21
|
+
import type { JxStateDefinition } from "@jxsuite/schema/types";
|
|
22
|
+
import type { JsonValue } from "../types";
|
|
23
|
+
|
|
24
|
+
export type SlotMode = "literal" | "ref" | "template" | "expression";
|
|
25
|
+
|
|
26
|
+
const MODE_LABELS: Record<SlotMode, string> = {
|
|
27
|
+
expression: "fx",
|
|
28
|
+
literal: "abc",
|
|
29
|
+
ref: "$ref",
|
|
30
|
+
template: "${}",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MODE_NOUNS: Record<SlotMode, string> = {
|
|
34
|
+
expression: "expression",
|
|
35
|
+
literal: "static",
|
|
36
|
+
ref: "signal binding",
|
|
37
|
+
template: "template literal",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Session memory of each field's last value per mode, so cycling modes round-trips user input.
|
|
42
|
+
* Keyed `${tabId}|${fieldKey}|${mode}`; the mode key is derived from the value at stash time, so a
|
|
43
|
+
* restore always lands a value whose slotMode() matches the target mode. `undefined` is a
|
|
44
|
+
* legitimate stash (cleared literal), hence Map.has() discrimination on recall.
|
|
45
|
+
*/
|
|
46
|
+
const slotModeMemory = new Map<string, JsonValue | undefined>();
|
|
47
|
+
|
|
48
|
+
/** Test hook: drop all remembered per-mode field values. */
|
|
49
|
+
export function resetSlotModeMemory(): void {
|
|
50
|
+
slotModeMemory.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function memoryKey(fieldKey: string, mode: SlotMode): string {
|
|
54
|
+
return `${activeTab.value?.id ?? "-"}|${fieldKey}|${mode}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasRefOptions(opts: DynamicSlotOpts): boolean {
|
|
58
|
+
return opts.stateDefs.length > 0 || Boolean(opts.extraSignals?.length);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SignalOption {
|
|
62
|
+
value: string;
|
|
63
|
+
label: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DynamicSlotOpts {
|
|
67
|
+
/** Stable identity for per-mode value memory; must incorporate the node path. */
|
|
68
|
+
fieldKey: string;
|
|
69
|
+
/** Current raw document value at this position. */
|
|
70
|
+
value: unknown;
|
|
71
|
+
/** Write-through to the document; `undefined` clears the position. */
|
|
72
|
+
onChange: (v?: JsonValue) => void;
|
|
73
|
+
/** Panel-provided literal editor for this slot's type. */
|
|
74
|
+
staticWidget: unknown;
|
|
75
|
+
/** Modes this schema position allows, in ladder order. "literal" and "ref" are the floor. */
|
|
76
|
+
caps: SlotMode[];
|
|
77
|
+
/** State keys offered by the ref picker and expression editor. */
|
|
78
|
+
stateDefs: string[];
|
|
79
|
+
/** Full state defs map — lets expression mode resolve named-formula catalog entries. */
|
|
80
|
+
stateEntries?: Record<string, JxStateDefinition> | null;
|
|
81
|
+
/** Extra pointer options beyond #/state/* (e.g. $map/item within repeater templates). */
|
|
82
|
+
extraSignals?: SignalOption[] | null;
|
|
83
|
+
/** Offer event#/ refs inside expression mode (handler positions only). */
|
|
84
|
+
allowEventRef?: boolean;
|
|
85
|
+
/** Live values for expression mode badges (services/preview-eval.ts). */
|
|
86
|
+
preview?: EditorPreview | null;
|
|
87
|
+
/** Static value restored when de-escalating to literal mode. */
|
|
88
|
+
literalDefault?: JsonValue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Detect which rung of the ladder a raw document value occupies. */
|
|
92
|
+
export function slotMode(value: unknown): SlotMode {
|
|
93
|
+
if (isRef(value)) {
|
|
94
|
+
return "ref";
|
|
95
|
+
}
|
|
96
|
+
if (isTemplateString(value)) {
|
|
97
|
+
return "template";
|
|
98
|
+
}
|
|
99
|
+
if (value && typeof value === "object" && "$expression" in value) {
|
|
100
|
+
return "expression";
|
|
101
|
+
}
|
|
102
|
+
return "literal";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function defaultForSlotMode(mode: SlotMode, opts: DynamicSlotOpts): JsonValue | undefined {
|
|
106
|
+
switch (mode) {
|
|
107
|
+
case "ref": {
|
|
108
|
+
const [first] = opts.stateDefs;
|
|
109
|
+
return first ? { $ref: `#/state/${first}` } : { $ref: opts.extraSignals?.[0]?.value ?? "" };
|
|
110
|
+
}
|
|
111
|
+
case "template": {
|
|
112
|
+
const [first] = opts.stateDefs;
|
|
113
|
+
return first ? `\${state.${first}}` : "${}";
|
|
114
|
+
}
|
|
115
|
+
case "expression": {
|
|
116
|
+
return { $expression: { operator: "??", target: null, value: null } };
|
|
117
|
+
}
|
|
118
|
+
default: {
|
|
119
|
+
return opts.literalDefault;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderRefWidget(refVal: string, opts: DynamicSlotOpts) {
|
|
125
|
+
return html`
|
|
126
|
+
<sp-picker
|
|
127
|
+
size="s"
|
|
128
|
+
quiet
|
|
129
|
+
style="flex:1"
|
|
130
|
+
placeholder="— select signal —"
|
|
131
|
+
value=${refVal || nothing}
|
|
132
|
+
@change=${(e: Event) => {
|
|
133
|
+
const v = (e.target as HTMLInputElement).value;
|
|
134
|
+
if (v) {
|
|
135
|
+
opts.onChange({ $ref: v });
|
|
136
|
+
} else {
|
|
137
|
+
opts.onChange();
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
${opts.stateDefs.map(
|
|
142
|
+
(name) => html`<sp-menu-item value=${`#/state/${name}`}>${name}</sp-menu-item>`,
|
|
143
|
+
)}
|
|
144
|
+
${opts.extraSignals?.length
|
|
145
|
+
? html`
|
|
146
|
+
<sp-menu-divider></sp-menu-divider>
|
|
147
|
+
${opts.extraSignals.map(
|
|
148
|
+
(sig) => html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
|
|
149
|
+
)}
|
|
150
|
+
`
|
|
151
|
+
: nothing}
|
|
152
|
+
</sp-picker>
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderTemplateWidget(value: unknown, opts: DynamicSlotOpts) {
|
|
157
|
+
return html`
|
|
158
|
+
<sp-textfield
|
|
159
|
+
size="s"
|
|
160
|
+
style="flex:1;font-family:var(--spectrum-code-font-family, monospace)"
|
|
161
|
+
placeholder="\${state.…}"
|
|
162
|
+
.value=${live(String(value ?? ""))}
|
|
163
|
+
@change=${(e: Event) => opts.onChange((e.target as HTMLInputElement).value)}
|
|
164
|
+
></sp-textfield>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface DynamicSlotParts {
|
|
169
|
+
/** Label-side mode-cycle button. */
|
|
170
|
+
modeButton: TemplateResult;
|
|
171
|
+
/** Active-mode widget, sized to fill the row's widget column. */
|
|
172
|
+
widget: TemplateResult;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The label-side field-mode button. Shows the current mode's glyph — quiet/gray while static and
|
|
177
|
+
* accent-tinted once the value is dynamic — and each click cycles to the next capped mode, stashing
|
|
178
|
+
* the outgoing mode's value and restoring the incoming mode's remembered one.
|
|
179
|
+
*/
|
|
180
|
+
function renderModeButton(mode: SlotMode, caps: SlotMode[], opts: DynamicSlotOpts): TemplateResult {
|
|
181
|
+
const color =
|
|
182
|
+
mode !== "literal"
|
|
183
|
+
? "color:var(--spectrum-accent-content-color-default, #5c9dff)"
|
|
184
|
+
: "color:var(--spectrum-gray-600, #808080)";
|
|
185
|
+
// A ref rung with nothing to point at is a dead end — drop it rather than trap the cycle there.
|
|
186
|
+
const cycle = caps.filter((m) => m !== "ref" || hasRefOptions(opts));
|
|
187
|
+
if (cycle.length < 2) {
|
|
188
|
+
const hint = `Field mode: ${MODE_NOUNS[mode]} (no other modes available)`;
|
|
189
|
+
return html`
|
|
190
|
+
<sp-action-button
|
|
191
|
+
size="xs"
|
|
192
|
+
quiet
|
|
193
|
+
disabled
|
|
194
|
+
class="dynamic-slot-mode"
|
|
195
|
+
style=${color}
|
|
196
|
+
title=${hint}
|
|
197
|
+
aria-label=${hint}
|
|
198
|
+
>${MODE_LABELS[mode]}</sp-action-button
|
|
199
|
+
>
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
// An uncapped current mode indexes to -1, so its next stop is cycle[0] (literal).
|
|
203
|
+
const next = cycle[(cycle.indexOf(mode) + 1) % cycle.length]!;
|
|
204
|
+
const hint = `Field mode: ${MODE_NOUNS[mode]} — click for ${MODE_NOUNS[next]}`;
|
|
205
|
+
return html`
|
|
206
|
+
<sp-action-button
|
|
207
|
+
size="xs"
|
|
208
|
+
quiet
|
|
209
|
+
class="dynamic-slot-mode"
|
|
210
|
+
style=${color}
|
|
211
|
+
title=${hint}
|
|
212
|
+
aria-label=${hint}
|
|
213
|
+
@click=${() => {
|
|
214
|
+
slotModeMemory.set(
|
|
215
|
+
memoryKey(opts.fieldKey, mode),
|
|
216
|
+
cloneValue(opts.value as JsonValue | undefined),
|
|
217
|
+
);
|
|
218
|
+
// Leaving ref seeds an empty literal stash with the signal's declared default.
|
|
219
|
+
// Unbind-restores-default thus survives multi-hop cycles (ref → template → literal).
|
|
220
|
+
if (mode === "ref" && opts.literalDefault !== undefined) {
|
|
221
|
+
const litKey = memoryKey(opts.fieldKey, "literal");
|
|
222
|
+
if (!slotModeMemory.has(litKey)) {
|
|
223
|
+
slotModeMemory.set(litKey, cloneValue(opts.literalDefault));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const key = memoryKey(opts.fieldKey, next);
|
|
227
|
+
opts.onChange(
|
|
228
|
+
slotModeMemory.has(key)
|
|
229
|
+
? cloneValue(slotModeMemory.get(key))
|
|
230
|
+
: defaultForSlotMode(next, opts),
|
|
231
|
+
);
|
|
232
|
+
}}
|
|
233
|
+
>${MODE_LABELS[mode]}</sp-action-button
|
|
234
|
+
>
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Render a bindable slot as two parts the panel places independently: the active mode's widget for
|
|
240
|
+
* the row's value column, and the mode-cycle button for the label side.
|
|
241
|
+
*/
|
|
242
|
+
export function renderDynamicSlot(opts: DynamicSlotOpts): DynamicSlotParts {
|
|
243
|
+
const caps: SlotMode[] = opts.caps.length > 0 ? opts.caps : ["literal", "ref"];
|
|
244
|
+
const mode = slotMode(opts.value);
|
|
245
|
+
|
|
246
|
+
const widget =
|
|
247
|
+
mode === "ref"
|
|
248
|
+
? renderRefWidget(isRef(opts.value) ? opts.value.$ref : "", opts)
|
|
249
|
+
: mode === "template"
|
|
250
|
+
? renderTemplateWidget(opts.value, opts)
|
|
251
|
+
: mode === "expression"
|
|
252
|
+
? renderExpressionEditor(
|
|
253
|
+
(opts.value as { $expression?: unknown }).$expression,
|
|
254
|
+
(node) => opts.onChange({ $expression: node } as JsonValue),
|
|
255
|
+
{
|
|
256
|
+
allowEventRef: opts.allowEventRef ?? false,
|
|
257
|
+
preview: opts.preview ?? null,
|
|
258
|
+
stateDefs: opts.stateDefs,
|
|
259
|
+
stateEntries: opts.stateEntries ?? null,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
: opts.staticWidget;
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
modeButton: renderModeButton(mode, caps, opts),
|
|
266
|
+
widget: html`
|
|
267
|
+
<div class="dynamic-slot" style="display:flex;flex:1;min-width:0">
|
|
268
|
+
<div style="flex:1;min-width:0">${widget}</div>
|
|
269
|
+
</div>
|
|
270
|
+
`,
|
|
271
|
+
};
|
|
272
|
+
}
|