@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
|
@@ -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
|
|
@@ -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);
|
package/src/editor/shortcuts.ts
CHANGED
|
@@ -16,9 +16,11 @@ import {
|
|
|
16
16
|
undo as tabUndo,
|
|
17
17
|
transactDoc,
|
|
18
18
|
} from "../tabs/transact";
|
|
19
|
+
import { applyEditZoom, requestEditZoom, setEditZoom } from "../canvas/canvas-utils";
|
|
19
20
|
import { isEditing, stopEditing } from "./inline-edit";
|
|
20
21
|
import { copyNode, cutNode, pasteNode } from "./context-menu";
|
|
21
22
|
import { openQuickSearch } from "../panels/quick-search";
|
|
23
|
+
import { shouldWarnOnClose } from "../panels/tab-strip";
|
|
22
24
|
import { showConfirmDialog } from "../ui/layers";
|
|
23
25
|
import { rectOf } from "../utils/geometry";
|
|
24
26
|
|
|
@@ -33,7 +35,6 @@ import type { JxPath } from "../state";
|
|
|
33
35
|
* panY: number;
|
|
34
36
|
* setPan: (x: number, y: number) => void;
|
|
35
37
|
* applyTransform: () => void;
|
|
36
|
-
* positionZoomIndicator: () => void;
|
|
37
38
|
* saveFile: () => void;
|
|
38
39
|
* openProject: () => void;
|
|
39
40
|
* }} getContext
|
|
@@ -45,7 +46,6 @@ export function initShortcuts(
|
|
|
45
46
|
panY: number;
|
|
46
47
|
setPan: (x: number, y: number) => void;
|
|
47
48
|
applyTransform: () => void;
|
|
48
|
-
positionZoomIndicator: () => void;
|
|
49
49
|
saveFile: () => void;
|
|
50
50
|
openProject: () => void;
|
|
51
51
|
},
|
|
@@ -55,10 +55,18 @@ export function initShortcuts(
|
|
|
55
55
|
"wheel",
|
|
56
56
|
(e: WheelEvent) => {
|
|
57
57
|
const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
|
|
58
|
-
// Edit (content) mode:
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// Edit (content) mode: ctrl/cmd+wheel drives the content zoom (browser-page-zoom semantics —
|
|
59
|
+
// The footprint stays fixed, content reflows); plain wheel scrolls the edit-mode container
|
|
60
|
+
// Ourselves. The canvas iframe is sized to its content (no internal scroll) and a cross-origin
|
|
61
|
+
// OOPIF doesn't bubble wheel to the parent, so the wheel reaches us forwarded (or over the
|
|
62
|
+
// Canvas chrome) but never triggers native scroll.
|
|
61
63
|
if (canvasMode === "edit") {
|
|
64
|
+
if (e.ctrlKey || e.metaKey) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
const editZoom = activeTab.value?.session.ui.editZoom ?? 1;
|
|
67
|
+
requestEditZoom(editZoom * (1 + -e.deltaY * 0.005));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
62
70
|
const sc = canvasWrap.querySelector<HTMLElement>(".content-edit-canvas");
|
|
63
71
|
if (sc) {
|
|
64
72
|
e.preventDefault();
|
|
@@ -125,8 +133,13 @@ export function initShortcuts(
|
|
|
125
133
|
canvasWrap.addEventListener("pointerup", onUp);
|
|
126
134
|
});
|
|
127
135
|
|
|
128
|
-
//
|
|
129
|
-
|
|
136
|
+
// Re-fit the edit-mode content zoom on resize: its layout width derives from the LIVE column
|
|
137
|
+
// Width, which tracks the studio window.
|
|
138
|
+
window.addEventListener("resize", () => {
|
|
139
|
+
if (getContext().canvasMode === "edit") {
|
|
140
|
+
applyEditZoom();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
130
143
|
|
|
131
144
|
document.addEventListener("keydown", (e) => {
|
|
132
145
|
const { canvasMode, setPan, applyTransform, saveFile, openProject } = getContext();
|
|
@@ -161,12 +174,17 @@ export function initShortcuts(
|
|
|
161
174
|
return;
|
|
162
175
|
}
|
|
163
176
|
if (mod) {
|
|
177
|
+
// Grid mode: copy/paste/duplicate/zoom belong to the grid engine (Tabulator clipboard
|
|
178
|
+
// Needs the native events); only tab/app-level chords pass through.
|
|
179
|
+
if (canvasMode === "grid" && !["o", "p", "s", "w", "z", "Z"].includes(e.key)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
164
182
|
switch (e.key) {
|
|
165
183
|
case "w": {
|
|
166
184
|
e.preventDefault();
|
|
167
185
|
if (workspace.activeTabId && workspace.tabOrder.length > 1) {
|
|
168
186
|
const tabToClose = workspace.tabs.get(workspace.activeTabId);
|
|
169
|
-
if (tabToClose
|
|
187
|
+
if (tabToClose && shouldWarnOnClose(tabToClose)) {
|
|
170
188
|
const name = tabToClose.documentPath?.split("/").pop() || "Untitled";
|
|
171
189
|
void showConfirmDialog(
|
|
172
190
|
"Unsaved Changes",
|
|
@@ -198,7 +216,9 @@ export function initShortcuts(
|
|
|
198
216
|
saveFile();
|
|
199
217
|
break;
|
|
200
218
|
}
|
|
201
|
-
|
|
219
|
+
// With Shift held e.key is "Z", so redo needs the uppercase case too.
|
|
220
|
+
case "z":
|
|
221
|
+
case "Z": {
|
|
202
222
|
e.preventDefault();
|
|
203
223
|
if (e.shiftKey) {
|
|
204
224
|
tabRedo(activeTab.value!);
|
|
@@ -231,10 +251,11 @@ export function initShortcuts(
|
|
|
231
251
|
break;
|
|
232
252
|
}
|
|
233
253
|
case "0": {
|
|
254
|
+
e.preventDefault();
|
|
234
255
|
if (canvasMode === "edit") {
|
|
256
|
+
setEditZoom(1);
|
|
235
257
|
break;
|
|
236
258
|
}
|
|
237
|
-
e.preventDefault();
|
|
238
259
|
activeTab.value!.session.ui.zoom = 1;
|
|
239
260
|
setPan(16, 16);
|
|
240
261
|
applyTransform();
|
|
@@ -242,19 +263,21 @@ export function initShortcuts(
|
|
|
242
263
|
}
|
|
243
264
|
case "=":
|
|
244
265
|
case "+": {
|
|
266
|
+
e.preventDefault();
|
|
245
267
|
if (canvasMode === "edit") {
|
|
268
|
+
setEditZoom((tab?.session.ui.editZoom ?? 1) * 1.2);
|
|
246
269
|
break;
|
|
247
270
|
}
|
|
248
|
-
e.preventDefault();
|
|
249
271
|
activeTab.value!.session.ui.zoom = Math.min(5, (tab?.session.ui.zoom ?? 1) * 1.2);
|
|
250
272
|
applyTransform();
|
|
251
273
|
break;
|
|
252
274
|
}
|
|
253
275
|
case "-": {
|
|
276
|
+
e.preventDefault();
|
|
254
277
|
if (canvasMode === "edit") {
|
|
278
|
+
setEditZoom((tab?.session.ui.editZoom ?? 1) / 1.2);
|
|
255
279
|
break;
|
|
256
280
|
}
|
|
257
|
-
e.preventDefault();
|
|
258
281
|
activeTab.value!.session.ui.zoom = Math.max(0.05, (tab?.session.ui.zoom ?? 1) / 1.2);
|
|
259
282
|
applyTransform();
|
|
260
283
|
break;
|
|
@@ -266,6 +289,12 @@ export function initShortcuts(
|
|
|
266
289
|
return;
|
|
267
290
|
}
|
|
268
291
|
|
|
292
|
+
// Grid mode: Delete/Escape/Enter/arrows drive the grid's own range clearing and cell
|
|
293
|
+
// Navigation — never the canvas document.
|
|
294
|
+
if (canvasMode === "grid") {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
269
298
|
switch (e.key) {
|
|
270
299
|
case "Delete":
|
|
271
300
|
case "Backspace": {
|
package/src/files/file-ops.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
|
|
|
12
12
|
import { statusMessage } from "../panels/statusbar";
|
|
13
13
|
import { validateComponentSlots } from "../services/cem-export";
|
|
14
14
|
import { getPlatform } from "../platform";
|
|
15
|
+
import { getGridController } from "../grid/grid-controller";
|
|
15
16
|
import { activeTab, openTab } from "../workspace/workspace";
|
|
16
17
|
import { collabSave } from "../collab/collab-session";
|
|
17
18
|
import { isEditing, stopEditing } from "../editor/inline-edit";
|
|
@@ -162,6 +163,12 @@ export async function saveFile() {
|
|
|
162
163
|
if (!tab) {
|
|
163
164
|
return;
|
|
164
165
|
}
|
|
166
|
+
// Grid tabs batch-save through their controller (per-source commit semantics, not a doc write).
|
|
167
|
+
const grid = getGridController(tab);
|
|
168
|
+
if (grid) {
|
|
169
|
+
await grid.save();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
165
172
|
try {
|
|
166
173
|
// A co-edited tab persists through its provider (a direct file write would reset the room).
|
|
167
174
|
if (await collabSave(tab)) {
|
|
@@ -264,6 +271,13 @@ export async function exportFile() {
|
|
|
264
271
|
* @returns {Promise<string>}
|
|
265
272
|
*/
|
|
266
273
|
export async function serializeDocument(tab: Tab): Promise<string> {
|
|
274
|
+
// Grid tabs serialize through their source (pending edits included) — e.g. the Monaco source
|
|
275
|
+
// View of a CSV grid tab shows the live file text.
|
|
276
|
+
const grid = getGridController(tab);
|
|
277
|
+
const gridText = grid?.serializeForSource();
|
|
278
|
+
if (gridText) {
|
|
279
|
+
return gridText;
|
|
280
|
+
}
|
|
267
281
|
await loadFormats();
|
|
268
282
|
const sourceFormat = formatByName(tab.doc.sourceFormat);
|
|
269
283
|
if (sourceFormat?.capabilities.serialize) {
|
package/src/files/files.ts
CHANGED
|
@@ -27,7 +27,16 @@ import {
|
|
|
27
27
|
monitorForElements,
|
|
28
28
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
|
29
29
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
activateTab,
|
|
32
|
+
openTab,
|
|
33
|
+
renameTab,
|
|
34
|
+
replaceAllTabs,
|
|
35
|
+
setWorkspaceProject,
|
|
36
|
+
workspace,
|
|
37
|
+
} from "../workspace/workspace";
|
|
38
|
+
import { openCollectionGrid, openCsvGridTab, openPagesGrid } from "../grid/grid-open";
|
|
39
|
+
import { collectionDirs } from "../grid/sources/content-source";
|
|
31
40
|
import { parseSourceForPath, serializeDocument } from "./file-ops";
|
|
32
41
|
import {
|
|
33
42
|
documentExtensions,
|
|
@@ -98,6 +107,11 @@ export async function loadProject() {
|
|
|
98
107
|
searchQuery: "",
|
|
99
108
|
selectedPath: null,
|
|
100
109
|
});
|
|
110
|
+
// Only a site project counts as an open project for the workspace (a bare monorepo root keeps
|
|
111
|
+
// The assistant in bootstrap mode).
|
|
112
|
+
if (info.isSiteProject) {
|
|
113
|
+
setWorkspaceProject(meta.root || ".", info.projectConfig || null);
|
|
114
|
+
}
|
|
101
115
|
|
|
102
116
|
if (info.isSiteProject) {
|
|
103
117
|
addRecentProject(requireProjectState().name, meta.root);
|
|
@@ -160,6 +174,7 @@ export async function openProject({
|
|
|
160
174
|
searchQuery: "",
|
|
161
175
|
selectedPath: null,
|
|
162
176
|
});
|
|
177
|
+
setWorkspaceProject(handle.root, config);
|
|
163
178
|
|
|
164
179
|
await autoSyncProjectOnOpen();
|
|
165
180
|
await ensureDependenciesInstalled();
|
|
@@ -719,6 +734,26 @@ function showFileContextMenu(
|
|
|
719
734
|
action: () => createNewFile(entry.path, ctx.renderLeftPanel),
|
|
720
735
|
label: "New File\u2026",
|
|
721
736
|
});
|
|
737
|
+
// Directories backing a content collection get a bulk-edit affordance.
|
|
738
|
+
const collection = collectionDirs().find(
|
|
739
|
+
({ dir }) => entry.path === dir || entry.path.endsWith(`/${dir}`),
|
|
740
|
+
);
|
|
741
|
+
if (collection) {
|
|
742
|
+
items.push({
|
|
743
|
+
action: () => {
|
|
744
|
+
openCollectionGrid(collection.name);
|
|
745
|
+
},
|
|
746
|
+
label: "Edit Collection in Grid",
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (entry.path === "pages" || entry.path.endsWith("/pages")) {
|
|
750
|
+
items.push({
|
|
751
|
+
action: () => {
|
|
752
|
+
openPagesGrid();
|
|
753
|
+
},
|
|
754
|
+
label: "Edit Pages in Grid",
|
|
755
|
+
});
|
|
756
|
+
}
|
|
722
757
|
}
|
|
723
758
|
items.push(
|
|
724
759
|
{ label: "\u2014" },
|
|
@@ -1032,6 +1067,23 @@ export async function openFileInTab(path: string) {
|
|
|
1032
1067
|
}
|
|
1033
1068
|
}
|
|
1034
1069
|
|
|
1070
|
+
// CSV files open in the grid editor (source mode remains as the raw-text alternate).
|
|
1071
|
+
if (path.toLowerCase().endsWith(".csv")) {
|
|
1072
|
+
try {
|
|
1073
|
+
await openCsvGridTab(path);
|
|
1074
|
+
requireProjectState().selectedPath = path;
|
|
1075
|
+
trackRecentFile({
|
|
1076
|
+
name: path.split("/").pop() || path,
|
|
1077
|
+
path,
|
|
1078
|
+
root: requireProjectState().projectRoot,
|
|
1079
|
+
});
|
|
1080
|
+
statusMessage(`Opened ${path.split("/").pop()}`);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
statusMessage(`Error: ${errorMessage(error)}`);
|
|
1083
|
+
}
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1035
1087
|
const platform = getPlatform();
|
|
1036
1088
|
try {
|
|
1037
1089
|
const content = await platform.readFile(path);
|