@marimo-team/islands 0.24.1-dev80 → 0.24.1-dev82
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/{chat-ui-Dcb8pQhM.js → chat-ui-DhRKl9Ie.js} +1809 -1809
- package/dist/{common-C8LWIius.js → common-C7wXlbOk.js} +6517 -6406
- package/dist/{html-to-image-CvCnIqTI.js → html-to-image-CT8hxf1p.js} +5335 -5295
- package/dist/main.js +6689 -6641
- package/dist/{process-output-C2joqP7d.js → process-output-CINXfXUt.js} +2 -2
- package/dist/{reveal-component-CdEcs6WV.js → reveal-component-CBBGm8i5.js} +1112 -1086
- package/package.json +1 -1
- package/src/__tests__/mount.test.ts +54 -2
- package/src/components/editor/__tests__/viewer-banner.test.tsx +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +9 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +8 -1
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +2 -0
- package/src/components/editor/actions/useNotebookActions.tsx +1 -1
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +1 -1
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +1 -1
- package/src/components/editor/renderers/cells-renderer.tsx +15 -28
- package/src/components/editor/renderers/grid-layout/__tests__/plugin.test.ts +42 -0
- package/src/components/editor/renderers/grid-layout/plugin.tsx +20 -10
- package/src/components/editor/renderers/layout-select.tsx +1 -1
- package/src/components/editor/renderers/plugins.ts +7 -1
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +49 -15
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +16 -2
- package/src/components/editor/renderers/slides-layout/types.ts +15 -11
- package/src/components/editor/renderers/types.ts +5 -5
- package/src/components/editor/viewer-banner.tsx +1 -1
- package/src/components/pages/run-page.tsx +16 -7
- package/src/components/slides/__tests__/reveal-component.test.ts +33 -8
- package/src/components/slides/__tests__/speaker-view.test.ts +27 -0
- package/src/components/slides/reveal-component.tsx +110 -45
- package/src/components/slides/speaker-view.ts +22 -0
- package/src/components/static-html/share-modal.tsx +2 -0
- package/src/core/cells/__tests__/document-changes.test.ts +343 -26
- package/src/core/cells/document-changes.ts +181 -4
- package/src/core/export/hooks.ts +2 -0
- package/src/core/export/layout.ts +9 -0
- package/src/core/kernel/handlers.ts +7 -16
- package/src/core/kernel/state.ts +2 -0
- package/src/core/layout/__tests__/layout.test.ts +95 -0
- package/src/core/layout/layout.ts +59 -65
- package/src/core/layout/state.ts +121 -0
- package/src/core/meta/__tests__/code-visibility.test.tsx +28 -0
- package/src/core/meta/code-visibility.ts +3 -1
- package/src/core/run-app.tsx +6 -2
- package/src/core/saving/__tests__/save-component.test.ts +31 -1
- package/src/core/saving/save-component.tsx +39 -29
- package/src/core/saving/state.ts +1 -1
- package/src/core/static/download-html.ts +2 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +47 -14
- package/src/core/websocket/useMarimoKernelConnection.tsx +7 -2
- package/src/mount.tsx +24 -6
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { getSerializedLayout } from "@/core/layout/layout";
|
|
5
|
+
import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
|
|
6
|
+
import { kioskModeAtom } from "@/core/mode";
|
|
4
7
|
import { connectionAtom } from "@/core/network/connection";
|
|
5
8
|
import { store } from "@/core/state/jotai";
|
|
9
|
+
import { isStaticNotebook } from "@/core/static/static-state";
|
|
6
10
|
import { WebSocketState } from "@/core/websocket/types";
|
|
7
11
|
import { mount, visibleForTesting } from "../mount";
|
|
8
12
|
|
|
@@ -15,6 +19,8 @@ vi.mock("react-dom/client", () => ({
|
|
|
15
19
|
|
|
16
20
|
// Mock static state
|
|
17
21
|
vi.mock("@/core/static/static-state", () => ({
|
|
22
|
+
getStaticModelNotifications: vi.fn(() => undefined),
|
|
23
|
+
getStaticVirtualFiles: vi.fn(() => ({})),
|
|
18
24
|
isStaticNotebook: vi.fn(() => false),
|
|
19
25
|
}));
|
|
20
26
|
|
|
@@ -46,6 +52,10 @@ describe("mount", () => {
|
|
|
46
52
|
|
|
47
53
|
beforeEach(() => {
|
|
48
54
|
visibleForTesting.reset();
|
|
55
|
+
window.history.replaceState({}, "", "/");
|
|
56
|
+
vi.mocked(isStaticNotebook).mockReturnValue(false);
|
|
57
|
+
store.set(layoutStateAtom, initialLayoutState());
|
|
58
|
+
store.set(kioskModeAtom, false);
|
|
49
59
|
// Reset connection atom to initial state
|
|
50
60
|
store.set(connectionAtom, { state: WebSocketState.NOT_STARTED });
|
|
51
61
|
});
|
|
@@ -66,6 +76,12 @@ describe("mount", () => {
|
|
|
66
76
|
serverToken: "",
|
|
67
77
|
};
|
|
68
78
|
|
|
79
|
+
const mountRead = (options: Record<string, unknown> = {}) =>
|
|
80
|
+
mount(
|
|
81
|
+
{ ...baseOptions, mode: "read", runtimeConfig: [], ...options },
|
|
82
|
+
mockElement,
|
|
83
|
+
);
|
|
84
|
+
|
|
69
85
|
describe("connection state initialization", () => {
|
|
70
86
|
it("should set connection to CONNECTING when runtimeConfig has lazy=false", () => {
|
|
71
87
|
mount(
|
|
@@ -106,8 +122,7 @@ describe("mount", () => {
|
|
|
106
122
|
expect(connection.state).toBe(WebSocketState.NOT_STARTED);
|
|
107
123
|
});
|
|
108
124
|
|
|
109
|
-
it("should keep connection as NOT_STARTED for static notebooks even with lazy=false",
|
|
110
|
-
const { isStaticNotebook } = await import("@/core/static/static-state");
|
|
125
|
+
it("should keep connection as NOT_STARTED for static notebooks even with lazy=false", () => {
|
|
111
126
|
vi.mocked(isStaticNotebook).mockReturnValue(true);
|
|
112
127
|
|
|
113
128
|
// Reset mount state to allow another mount
|
|
@@ -125,4 +140,41 @@ describe("mount", () => {
|
|
|
125
140
|
expect(connection.state).toBe(WebSocketState.NOT_STARTED);
|
|
126
141
|
});
|
|
127
142
|
});
|
|
143
|
+
|
|
144
|
+
it("hydrates the layout embedded in the mount config", () => {
|
|
145
|
+
const layout = {
|
|
146
|
+
type: "slides",
|
|
147
|
+
data: {
|
|
148
|
+
deck: { transition: "fade", verticalAlign: "center" },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const error = mountRead({ layout });
|
|
152
|
+
|
|
153
|
+
expect(error).toBeUndefined();
|
|
154
|
+
const layoutState = store.get(layoutStateAtom);
|
|
155
|
+
expect(layoutState.selectedLayout).toBe("slides");
|
|
156
|
+
expect(getSerializedLayout()).toEqual({
|
|
157
|
+
type: "slides",
|
|
158
|
+
data: {
|
|
159
|
+
cells: [],
|
|
160
|
+
deck: { transition: "fade", verticalAlign: "center" },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("uses vertical layout for malformed mount data", () => {
|
|
166
|
+
const error = mountRead({ layout: { data: {} } });
|
|
167
|
+
|
|
168
|
+
expect(error).toBeUndefined();
|
|
169
|
+
expect(store.get(layoutStateAtom)).toEqual(initialLayoutState());
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("starts kiosk clients in kiosk mode", () => {
|
|
173
|
+
window.history.replaceState({}, "", "/?kiosk=true");
|
|
174
|
+
|
|
175
|
+
const error = mountRead();
|
|
176
|
+
|
|
177
|
+
expect(error).toBeUndefined();
|
|
178
|
+
expect(store.get(kioskModeAtom)).toBe(true);
|
|
179
|
+
});
|
|
128
180
|
});
|
|
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
|
|
|
3
3
|
import { createStore, Provider } from "jotai";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
6
|
-
import { layoutStateAtom } from "@/core/layout/
|
|
6
|
+
import { layoutStateAtom } from "@/core/layout/state";
|
|
7
7
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
8
8
|
import { API } from "@/core/network/api";
|
|
9
9
|
import { ViewerBanner } from "../viewer-banner";
|
|
@@ -43,12 +43,17 @@ describe("exportNotebook", () => {
|
|
|
43
43
|
let captureOutputs: Mock<() => Promise<void>>;
|
|
44
44
|
let capturePNG: Mock<() => Promise<void>>;
|
|
45
45
|
let downloadFile: Mock<(file: ExportedFile) => void>;
|
|
46
|
+
let getLayout: Mock;
|
|
46
47
|
|
|
47
48
|
beforeEach(() => {
|
|
48
49
|
requests = makeRequests();
|
|
49
50
|
captureOutputs = vi.fn().mockResolvedValue(undefined);
|
|
50
51
|
capturePNG = vi.fn().mockResolvedValue(undefined);
|
|
51
52
|
downloadFile = vi.fn();
|
|
53
|
+
getLayout = vi.fn().mockResolvedValue({
|
|
54
|
+
type: "slides",
|
|
55
|
+
data: { deck: { transition: "fade" } },
|
|
56
|
+
});
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
const run = (
|
|
@@ -61,6 +66,7 @@ describe("exportNotebook", () => {
|
|
|
61
66
|
requests,
|
|
62
67
|
sourceFilename: "notebook.py",
|
|
63
68
|
htmlFiles: ["data.csv"],
|
|
69
|
+
getLayout,
|
|
64
70
|
captureOutputs,
|
|
65
71
|
capturePNG,
|
|
66
72
|
downloadFile,
|
|
@@ -73,8 +79,10 @@ describe("exportNotebook", () => {
|
|
|
73
79
|
download: false,
|
|
74
80
|
files: ["data.csv"],
|
|
75
81
|
includeCode: false,
|
|
82
|
+
layout: { type: "slides", data: { deck: { transition: "fade" } } },
|
|
76
83
|
});
|
|
77
84
|
expect(downloadFile).toHaveBeenCalledWith(FILE);
|
|
85
|
+
expect(getLayout).toHaveBeenCalledOnce();
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
it("passes the selected Markdown flavor through the session API", async () => {
|
|
@@ -84,6 +92,7 @@ describe("exportNotebook", () => {
|
|
|
84
92
|
download: false,
|
|
85
93
|
flavor: "qmd",
|
|
86
94
|
});
|
|
95
|
+
expect(getLayout).not.toHaveBeenCalled();
|
|
87
96
|
expect(downloadFile).toHaveBeenCalledWith(FILE);
|
|
88
97
|
});
|
|
89
98
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
EditRequests,
|
|
5
|
+
ExportAsHTMLRequest,
|
|
6
|
+
ExportedFile,
|
|
7
|
+
} from "@/core/network/types";
|
|
4
8
|
import { assertNever } from "@/utils/assertNever";
|
|
5
9
|
import { runServerSidePDFDownload } from "../pdf-export";
|
|
6
10
|
import type { ExportFormat, ExportOptions } from "./state";
|
|
@@ -21,6 +25,7 @@ export async function exportNotebook({
|
|
|
21
25
|
requests,
|
|
22
26
|
sourceFilename,
|
|
23
27
|
htmlFiles,
|
|
28
|
+
getLayout,
|
|
24
29
|
captureOutputs,
|
|
25
30
|
capturePNG,
|
|
26
31
|
downloadFile,
|
|
@@ -30,6 +35,7 @@ export async function exportNotebook({
|
|
|
30
35
|
requests: ExportRequests;
|
|
31
36
|
sourceFilename: string;
|
|
32
37
|
htmlFiles: string[];
|
|
38
|
+
getLayout: () => Promise<ExportAsHTMLRequest["layout"]>;
|
|
33
39
|
captureOutputs: () => Promise<void>;
|
|
34
40
|
capturePNG: () => Promise<void>;
|
|
35
41
|
downloadFile: (file: ExportedFile) => void;
|
|
@@ -40,6 +46,7 @@ export async function exportNotebook({
|
|
|
40
46
|
download: false,
|
|
41
47
|
files: htmlFiles,
|
|
42
48
|
includeCode: options.html.includeCode,
|
|
49
|
+
layout: await getLayout(),
|
|
43
50
|
});
|
|
44
51
|
downloadFile(file);
|
|
45
52
|
return;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
updateCellOutputsWithScreenshots,
|
|
8
8
|
useEnrichCellOutputs,
|
|
9
9
|
} from "@/core/export/hooks";
|
|
10
|
+
import { getExportLayout } from "@/core/export/layout";
|
|
10
11
|
import { runDuringPresentMode, useInstallAllowed } from "@/core/mode";
|
|
11
12
|
import { useRequestClient } from "@/core/network/requests";
|
|
12
13
|
import type { ExportAvailabilityResponse } from "@/core/network/types";
|
|
@@ -349,6 +350,7 @@ function useExportDialogAction({
|
|
|
349
350
|
requests,
|
|
350
351
|
sourceFilename,
|
|
351
352
|
htmlFiles: VirtualFileTracker.INSTANCE.filenames(),
|
|
353
|
+
getLayout: getExportLayout,
|
|
352
354
|
captureOutputs: () => captureOutputs(progress),
|
|
353
355
|
capturePNG,
|
|
354
356
|
downloadFile: downloadExportedFile,
|
|
@@ -66,7 +66,7 @@ import { disabledCellIds } from "@/core/cells/utils";
|
|
|
66
66
|
import { capabilitiesAtom } from "@/core/config/capabilities";
|
|
67
67
|
import { aiEnabledAtom, useResolvedMarimoConfig } from "@/core/config/config";
|
|
68
68
|
import { Constants } from "@/core/constants";
|
|
69
|
-
import { useLayoutActions, useLayoutState } from "@/core/layout/
|
|
69
|
+
import { useLayoutActions, useLayoutState } from "@/core/layout/state";
|
|
70
70
|
import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
|
|
71
71
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
72
72
|
import { useRequestClient } from "@/core/network/requests";
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
invalidateDataSourceDiscovery,
|
|
19
19
|
} from "@/core/datasets/data-source-discovery";
|
|
20
20
|
import { DiscoverDataSources } from "@/core/datasets/request-registry";
|
|
21
|
-
import { layoutStateAtom } from "@/core/layout/
|
|
21
|
+
import { layoutStateAtom } from "@/core/layout/state";
|
|
22
22
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
23
23
|
import { connectionAtom } from "@/core/network/connection";
|
|
24
24
|
import { requestClientAtom } from "@/core/network/requests";
|
|
@@ -4,7 +4,7 @@ import { createStore, Provider } from "jotai";
|
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { MockRequestClient } from "@/__mocks__/requests";
|
|
6
6
|
import { parseAppConfig } from "@/core/config/config-schema";
|
|
7
|
-
import { initialLayoutState, layoutStateAtom } from "@/core/layout/
|
|
7
|
+
import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
|
|
8
8
|
import { type AppMode, kioskModeAtom } from "@/core/mode";
|
|
9
9
|
import { requestClientAtom } from "@/core/network/requests";
|
|
10
10
|
import { CellsRenderer } from "../cells-renderer";
|
|
@@ -5,19 +5,15 @@ import type React from "react";
|
|
|
5
5
|
import { memo, type PropsWithChildren } from "react";
|
|
6
6
|
import { flattenTopLevelNotebookCells, useNotebook } from "@/core/cells/cells";
|
|
7
7
|
import type { AppConfig } from "@/core/config/config-schema";
|
|
8
|
-
import {
|
|
8
|
+
import { resolveLayoutData } from "@/core/layout/layout";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
resolveLayoutType,
|
|
11
11
|
useLayoutActions,
|
|
12
12
|
useLayoutState,
|
|
13
|
-
} from "@/core/layout/
|
|
13
|
+
} from "@/core/layout/state";
|
|
14
14
|
import { type AppMode, kioskModeAtom } from "@/core/mode";
|
|
15
15
|
import { cellRendererPlugins } from "./plugins";
|
|
16
|
-
import {
|
|
17
|
-
type ICellRendererPlugin,
|
|
18
|
-
type LayoutType,
|
|
19
|
-
OVERRIDABLE_LAYOUT_TYPES,
|
|
20
|
-
} from "./types";
|
|
16
|
+
import type { ICellRendererPlugin } from "./types";
|
|
21
17
|
|
|
22
18
|
interface Props {
|
|
23
19
|
appConfig: AppConfig;
|
|
@@ -26,7 +22,7 @@ interface Props {
|
|
|
26
22
|
|
|
27
23
|
export const CellsRenderer: React.FC<PropsWithChildren<Props>> = memo(
|
|
28
24
|
({ appConfig, mode, children }) => {
|
|
29
|
-
const { selectedLayout
|
|
25
|
+
const { selectedLayout } = useLayoutState();
|
|
30
26
|
const kioskMode = useAtomValue(kioskModeAtom);
|
|
31
27
|
|
|
32
28
|
// Render children (the editable notebook) in edit mode, and in present
|
|
@@ -43,14 +39,11 @@ export const CellsRenderer: React.FC<PropsWithChildren<Props>> = memo(
|
|
|
43
39
|
// We allow overriding the layout type by url params when in 'read' mode,
|
|
44
40
|
// for example, forcing the 'slides' view.
|
|
45
41
|
// https://marimo.app/?slug=14ovyr8&mode=run&view-as=slides
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
finalLayout = viewAsOverride as LayoutType;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
42
|
+
const finalLayout = resolveLayoutType({
|
|
43
|
+
selectedLayout,
|
|
44
|
+
isReading: mode === "read",
|
|
45
|
+
searchParams: new URLSearchParams(window.location.search),
|
|
46
|
+
});
|
|
54
47
|
|
|
55
48
|
const plugin = cellRendererPlugins.find((p) => p.type === finalLayout);
|
|
56
49
|
|
|
@@ -60,13 +53,7 @@ export const CellsRenderer: React.FC<PropsWithChildren<Props>> = memo(
|
|
|
60
53
|
}
|
|
61
54
|
|
|
62
55
|
return (
|
|
63
|
-
<PluginCellRenderer
|
|
64
|
-
appConfig={appConfig}
|
|
65
|
-
mode={mode}
|
|
66
|
-
plugin={plugin}
|
|
67
|
-
layoutData={layoutData}
|
|
68
|
-
finalLayout={finalLayout}
|
|
69
|
-
/>
|
|
56
|
+
<PluginCellRenderer appConfig={appConfig} mode={mode} plugin={plugin} />
|
|
70
57
|
);
|
|
71
58
|
},
|
|
72
59
|
);
|
|
@@ -77,15 +64,15 @@ interface PluginCellRendererProps extends PropsWithChildren<Props> {
|
|
|
77
64
|
mode: AppMode;
|
|
78
65
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
79
66
|
plugin: ICellRendererPlugin<any, any>;
|
|
80
|
-
layoutData: Partial<Record<LayoutType, LayoutData>>;
|
|
81
|
-
finalLayout: LayoutType;
|
|
82
67
|
}
|
|
83
68
|
|
|
84
69
|
export const PluginCellRenderer = (props: PluginCellRendererProps) => {
|
|
85
|
-
const { appConfig, mode, plugin
|
|
70
|
+
const { appConfig, mode, plugin } = props;
|
|
71
|
+
const layoutState = useLayoutState();
|
|
86
72
|
const notebook = useNotebook();
|
|
87
73
|
const { setCurrentLayoutData } = useLayoutActions();
|
|
88
74
|
const cells = flattenTopLevelNotebookCells(notebook);
|
|
75
|
+
const layout = resolveLayoutData({ state: layoutState, plugin, cells });
|
|
89
76
|
|
|
90
77
|
const Renderer = plugin.Component;
|
|
91
78
|
const body = (
|
|
@@ -93,7 +80,7 @@ export const PluginCellRenderer = (props: PluginCellRendererProps) => {
|
|
|
93
80
|
appConfig={appConfig}
|
|
94
81
|
mode={mode}
|
|
95
82
|
cells={cells}
|
|
96
|
-
layout={
|
|
83
|
+
layout={layout}
|
|
97
84
|
setLayout={setCurrentLayoutData}
|
|
98
85
|
/>
|
|
99
86
|
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { MockNotebook } from "@/__mocks__/notebook";
|
|
5
|
+
import { cellId } from "@/__tests__/branded";
|
|
6
|
+
import { deserializeLayout } from "../../plugins";
|
|
7
|
+
|
|
8
|
+
describe("GridLayoutPlugin validation", () => {
|
|
9
|
+
it("preserves valid cell positions while normalizing optional fields", () => {
|
|
10
|
+
const a = cellId("a");
|
|
11
|
+
const b = cellId("b");
|
|
12
|
+
const { cellData } = MockNotebook.notebookState({
|
|
13
|
+
cellData: { [a]: {}, [b]: {} },
|
|
14
|
+
});
|
|
15
|
+
const layout = deserializeLayout({
|
|
16
|
+
type: "grid",
|
|
17
|
+
data: {
|
|
18
|
+
columns: 12,
|
|
19
|
+
rowHeight: 20,
|
|
20
|
+
maxWidth: null,
|
|
21
|
+
cells: [
|
|
22
|
+
{
|
|
23
|
+
position: [0, 0, 6, 4],
|
|
24
|
+
side: "right",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
position: [1, 4, 6, 4],
|
|
28
|
+
side: "future-side",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
cells: [cellData[a], cellData[b]],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(layout.maxWidth).toBeUndefined();
|
|
36
|
+
expect(layout.cellSide).toEqual(new Map([["a", "right"]]));
|
|
37
|
+
expect(layout.cells).toEqual([
|
|
38
|
+
{ i: "a", x: 0, y: 0, w: 6, h: 4 },
|
|
39
|
+
{ i: "b", x: 1, y: 4, w: 6, h: 4 },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -24,19 +24,29 @@ export const GridLayoutPlugin: ICellRendererPlugin<
|
|
|
24
24
|
type: "grid",
|
|
25
25
|
name: "Grid",
|
|
26
26
|
|
|
27
|
-
validator: z.
|
|
27
|
+
validator: z.looseObject({
|
|
28
28
|
columns: z.number().min(1),
|
|
29
29
|
rowHeight: z.number().min(1),
|
|
30
|
-
maxWidth: z
|
|
31
|
-
|
|
30
|
+
maxWidth: z
|
|
31
|
+
.number()
|
|
32
|
+
.nullish()
|
|
33
|
+
.transform((value) => value ?? undefined)
|
|
34
|
+
.catch(undefined),
|
|
35
|
+
bordered: z.boolean().optional().catch(undefined),
|
|
32
36
|
cells: z.array(
|
|
33
|
-
z
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
z
|
|
38
|
+
.looseObject({
|
|
39
|
+
position: z
|
|
40
|
+
.tuple([z.number(), z.number(), z.number(), z.number()])
|
|
41
|
+
.nullable()
|
|
42
|
+
.catch(null),
|
|
43
|
+
scrollable: z.boolean().optional().catch(undefined),
|
|
44
|
+
side: z
|
|
45
|
+
.enum(["top", "bottom", "left", "right"])
|
|
46
|
+
.optional()
|
|
47
|
+
.catch(undefined),
|
|
48
|
+
})
|
|
49
|
+
.catch({ position: null }),
|
|
40
50
|
),
|
|
41
51
|
}),
|
|
42
52
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
SelectValue,
|
|
18
18
|
} from "@/components/ui/select";
|
|
19
19
|
import { getFeatureFlag } from "@/core/config/feature-flag";
|
|
20
|
-
import { useLayoutActions, useLayoutState } from "@/core/layout/
|
|
20
|
+
import { useLayoutActions, useLayoutState } from "@/core/layout/state";
|
|
21
21
|
import { isWasm } from "@/core/wasm/utils";
|
|
22
22
|
import { logNever } from "@/utils/assertNever";
|
|
23
23
|
import { Strings } from "@/utils/strings";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import type { CellData } from "@/core/cells/types";
|
|
4
|
+
import { Logger } from "@/utils/Logger";
|
|
4
5
|
import { GridLayoutPlugin } from "./grid-layout/plugin";
|
|
5
6
|
import { SlidesLayoutPlugin } from "./slides-layout/plugin";
|
|
6
7
|
import type { ICellRendererPlugin, LayoutType } from "./types";
|
|
@@ -27,5 +28,10 @@ export function deserializeLayout({
|
|
|
27
28
|
if (plugin === undefined) {
|
|
28
29
|
throw new Error(`Unknown layout type: ${type}`);
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
const parsed = plugin.validator.safeParse(data);
|
|
32
|
+
if (!parsed.success) {
|
|
33
|
+
Logger.warn(`Invalid ${type} layout`, parsed.error);
|
|
34
|
+
return plugin.getInitialLayout(cells);
|
|
35
|
+
}
|
|
36
|
+
return plugin.deserializeLayout(parsed.data, cells);
|
|
31
37
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
4
|
import { SlidesLayoutPlugin } from "../plugin";
|
|
5
5
|
import type { CellData } from "@/core/cells/types";
|
|
6
6
|
import type { CellId } from "@/core/cells/ids";
|
|
7
7
|
import { cellId } from "@/__tests__/branded";
|
|
8
|
+
import { deserializeLayout } from "../../plugins";
|
|
9
|
+
import { Logger } from "@/utils/Logger";
|
|
8
10
|
|
|
9
11
|
function makeCell(id: string, code = "print('hi')"): CellData {
|
|
10
12
|
return {
|
|
@@ -35,12 +37,36 @@ describe("SlidesLayoutPlugin validator", () => {
|
|
|
35
37
|
).toBe(true);
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
it("
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
it("drops unsupported fields while preserving the rest of the layout", () => {
|
|
41
|
+
const layout = deserializeLayout({
|
|
42
|
+
type: "slides",
|
|
43
|
+
data: {
|
|
44
|
+
cells: [
|
|
45
|
+
{
|
|
46
|
+
type: "bogus",
|
|
47
|
+
speakerNotes: "Keep these notes",
|
|
48
|
+
futureCellOption: true,
|
|
49
|
+
},
|
|
50
|
+
{ type: "fragment" },
|
|
51
|
+
],
|
|
52
|
+
deck: {
|
|
53
|
+
transition: "future-transition",
|
|
54
|
+
verticalAlign: "center",
|
|
55
|
+
futureDeckOption: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
cells: [makeCell("a"), makeCell("b")],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(layout.cells.get("a" as CellId)).toEqual({
|
|
62
|
+
speakerNotes: "Keep these notes",
|
|
63
|
+
futureCellOption: true,
|
|
64
|
+
});
|
|
65
|
+
expect(layout.cells.get("b" as CellId)).toEqual({ type: "fragment" });
|
|
66
|
+
expect(layout.deck).toEqual({
|
|
67
|
+
verticalAlign: "center",
|
|
68
|
+
futureDeckOption: true,
|
|
69
|
+
});
|
|
44
70
|
});
|
|
45
71
|
|
|
46
72
|
it("accepts each valid deck vertical alignment", () => {
|
|
@@ -52,12 +78,21 @@ describe("SlidesLayoutPlugin validator", () => {
|
|
|
52
78
|
}
|
|
53
79
|
});
|
|
54
80
|
|
|
55
|
-
it("
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
it("falls back to the initial layout when serialized data is invalid", () => {
|
|
82
|
+
const warning = vi
|
|
83
|
+
.spyOn(Logger, "warn")
|
|
84
|
+
.mockImplementation(() => undefined);
|
|
85
|
+
try {
|
|
86
|
+
expect(
|
|
87
|
+
deserializeLayout({
|
|
88
|
+
type: "slides",
|
|
89
|
+
data: null,
|
|
90
|
+
cells: [makeCell("a")],
|
|
91
|
+
}),
|
|
92
|
+
).toEqual({ cells: new Map(), deck: {} });
|
|
93
|
+
} finally {
|
|
94
|
+
warning.mockRestore();
|
|
95
|
+
}
|
|
61
96
|
});
|
|
62
97
|
});
|
|
63
98
|
|
|
@@ -169,8 +204,7 @@ describe("SlidesLayoutPlugin serializeLayout", () => {
|
|
|
169
204
|
* shape to `serializedSnapshot` so a diff will surface the change in review.
|
|
170
205
|
*
|
|
171
206
|
* Every snapshot is required to:
|
|
172
|
-
* 1. pass the zod validator
|
|
173
|
-
* this guards against regressions once it is),
|
|
207
|
+
* 1. pass the zod validator used during load,
|
|
174
208
|
* 2. deserialize without throwing,
|
|
175
209
|
* 3. survive a deserialize → serialize → deserialize round trip without
|
|
176
210
|
* throwing or losing any user-set field carried in the expectations.
|
|
@@ -8,6 +8,7 @@ import type { ICellRendererProps } from "../types";
|
|
|
8
8
|
import type { SlidesLayout } from "./types";
|
|
9
9
|
import { computeSlideCellsInfo } from "./compute-slide-cells";
|
|
10
10
|
import { SlidesMinimap } from "@/components/slides/minimap";
|
|
11
|
+
import { isSpeakerViewReceiver } from "@/components/slides/speaker-view";
|
|
11
12
|
import useEvent from "react-use-event-hook";
|
|
12
13
|
|
|
13
14
|
type Props = ICellRendererProps<SlidesLayout>;
|
|
@@ -38,6 +39,19 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
|
|
|
38
39
|
: startCellIndex;
|
|
39
40
|
const resolvedIndex =
|
|
40
41
|
activeSlideIndex === -1 ? startCellIndex : activeSlideIndex;
|
|
42
|
+
const isSpeakerReceiver = isSpeakerViewReceiver(
|
|
43
|
+
kioskMode,
|
|
44
|
+
window.location.search,
|
|
45
|
+
);
|
|
46
|
+
// Let reveal.js resolve an initial URL coordinate before React resumes
|
|
47
|
+
// controlling the deck through the active cell.
|
|
48
|
+
const isInitialDeepLink =
|
|
49
|
+
isReading && activeCellId == null && window.location.hash.startsWith("#/");
|
|
50
|
+
// Speaker-view receivers take their position from reveal.js messages and
|
|
51
|
+
// the URL hash. Controlling their index here can push the presenter back to
|
|
52
|
+
// the first slide while the receiver initializes.
|
|
53
|
+
const controlledIndex =
|
|
54
|
+
isSpeakerReceiver || isInitialDeepLink ? undefined : resolvedIndex;
|
|
41
55
|
|
|
42
56
|
const handleSlideChange = useEvent((index: number) => {
|
|
43
57
|
const cell = slideCells[index];
|
|
@@ -52,8 +66,8 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
|
|
|
52
66
|
layout={layout}
|
|
53
67
|
setLayout={setLayout}
|
|
54
68
|
noOutputIds={noOutputIds}
|
|
55
|
-
activeIndex={
|
|
56
|
-
onSlideChange={handleSlideChange}
|
|
69
|
+
activeIndex={controlledIndex}
|
|
70
|
+
onSlideChange={isSpeakerReceiver ? undefined : handleSlideChange}
|
|
57
71
|
configWidth={280}
|
|
58
72
|
mode={isReading ? "read" : mode}
|
|
59
73
|
isEditable={!isReading}
|
|
@@ -6,11 +6,13 @@ import type { CellId } from "@/core/cells/ids";
|
|
|
6
6
|
const SlideTypeSchema = z.enum(["slide", "sub-slide", "fragment", "skip"]);
|
|
7
7
|
export type SlideType = z.infer<typeof SlideTypeSchema>;
|
|
8
8
|
|
|
9
|
-
const SlideConfigSchema = z
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const SlideConfigSchema = z
|
|
10
|
+
.looseObject({
|
|
11
|
+
type: SlideTypeSchema.optional().catch(undefined),
|
|
12
|
+
speakerNotes: z.string().optional().catch(undefined),
|
|
13
|
+
showCode: z.boolean().optional().catch(undefined),
|
|
14
|
+
})
|
|
15
|
+
.catch({});
|
|
14
16
|
export type SlideConfig = z.infer<typeof SlideConfigSchema>;
|
|
15
17
|
|
|
16
18
|
const DeckTransitionSchema = z.enum([
|
|
@@ -26,10 +28,12 @@ export type DeckTransition = z.infer<typeof DeckTransitionSchema>;
|
|
|
26
28
|
const DeckVerticalAlignSchema = z.enum(["top", "center", "bottom"]);
|
|
27
29
|
export type DeckVerticalAlign = z.infer<typeof DeckVerticalAlignSchema>;
|
|
28
30
|
|
|
29
|
-
const DeckConfigSchema = z
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const DeckConfigSchema = z
|
|
32
|
+
.looseObject({
|
|
33
|
+
transition: DeckTransitionSchema.optional().catch(undefined),
|
|
34
|
+
verticalAlign: DeckVerticalAlignSchema.optional().catch(undefined),
|
|
35
|
+
})
|
|
36
|
+
.catch({});
|
|
33
37
|
export type DeckConfig = z.infer<typeof DeckConfigSchema>;
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -41,8 +45,8 @@ export type DeckConfig = z.infer<typeof DeckConfigSchema>;
|
|
|
41
45
|
* keys are preserved (via `looseObject`) for the same reason.
|
|
42
46
|
*/
|
|
43
47
|
export const SlidesLayoutSchema = z.looseObject({
|
|
44
|
-
cells: z.array(SlideConfigSchema).optional(),
|
|
45
|
-
deck: DeckConfigSchema.optional(),
|
|
48
|
+
cells: z.array(SlideConfigSchema).optional().catch(undefined),
|
|
49
|
+
deck: DeckConfigSchema.optional().catch(undefined),
|
|
46
50
|
});
|
|
47
51
|
export type SerializedSlidesLayout = z.infer<typeof SlidesLayoutSchema>;
|
|
48
52
|
|
|
@@ -37,11 +37,11 @@ export interface ICellRendererProps<L> {
|
|
|
37
37
|
setLayout: (layout: L) => void;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
export const LAYOUT_TYPES = ["vertical", "grid", "slides"] as const;
|
|
41
|
+
export type LayoutType = (typeof LAYOUT_TYPES)[number];
|
|
42
|
+
export function isLayoutType(value: string): value is LayoutType {
|
|
43
|
+
return (LAYOUT_TYPES as readonly string[]).includes(value);
|
|
44
|
+
}
|
|
45
45
|
/**
|
|
46
46
|
* Overridable layout types.
|
|
47
47
|
* These types can override the current layout via a URL parameter.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useAtomValue } from "jotai/react";
|
|
4
4
|
import { ArrowRightSquareIcon, EyeIcon } from "lucide-react";
|
|
5
5
|
import { KnownQueryParams } from "@/core/constants";
|
|
6
|
-
import { useLayoutState } from "@/core/layout/
|
|
6
|
+
import { useLayoutState } from "@/core/layout/state";
|
|
7
7
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
8
8
|
import { API } from "@/core/network/api";
|
|
9
9
|
import { Banner } from "@/plugins/impl/common/error-banner";
|