@marimo-team/islands 0.19.5-dev25 → 0.19.5-dev29
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/main.js +234 -214
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/app-config/user-config-form.tsx +33 -0
- package/src/components/data-table/cell-utils.ts +10 -0
- package/src/components/editor/Output.tsx +17 -14
- package/src/components/editor/actions/useNotebookActions.tsx +44 -12
- package/src/components/editor/cell/cell-actions.tsx +6 -1
- package/src/components/editor/navigation/navigation.ts +39 -1
- package/src/core/codemirror/language/panel/panel.tsx +3 -0
- package/src/core/codemirror/language/panel/sql.tsx +6 -2
- package/src/core/config/feature-flag.tsx +2 -0
- package/src/core/export/__tests__/hooks.test.ts +120 -1
- package/src/core/export/hooks.ts +33 -8
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +9 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +2 -0
- package/src/core/wasm/bridge.ts +1 -0
- package/src/utils/__tests__/download.test.tsx +96 -0
- package/src/utils/download.ts +54 -0
- package/src/utils/filenames.ts +3 -0
package/package.json
CHANGED
|
@@ -60,6 +60,7 @@ export const MockRequestClient = {
|
|
|
60
60
|
shutdownSession: vi.fn().mockResolvedValue({}),
|
|
61
61
|
exportAsHTML: vi.fn().mockResolvedValue({ html: "" }),
|
|
62
62
|
exportAsMarkdown: vi.fn().mockResolvedValue({ markdown: "" }),
|
|
63
|
+
exportAsPDF: vi.fn().mockResolvedValue(new Blob()),
|
|
63
64
|
autoExportAsHTML: vi.fn().mockResolvedValue({}),
|
|
64
65
|
autoExportAsMarkdown: vi.fn().mockResolvedValue({}),
|
|
65
66
|
autoExportAsIPYNB: vi.fn().mockResolvedValue({}),
|
|
@@ -1283,6 +1283,39 @@ export const UserConfigForm: React.FC = () => {
|
|
|
1283
1283
|
</div>
|
|
1284
1284
|
)}
|
|
1285
1285
|
/>
|
|
1286
|
+
<FormField
|
|
1287
|
+
control={form.control}
|
|
1288
|
+
name="experimental.server_side_pdf_export"
|
|
1289
|
+
render={({ field }) => (
|
|
1290
|
+
<div className="flex flex-col gap-y-1">
|
|
1291
|
+
<FormItem className={formItemClasses}>
|
|
1292
|
+
<FormLabel className="font-normal">
|
|
1293
|
+
Better PDF Export
|
|
1294
|
+
</FormLabel>
|
|
1295
|
+
<FormControl>
|
|
1296
|
+
<Checkbox
|
|
1297
|
+
data-testid="server-side-pdf-export-checkbox"
|
|
1298
|
+
checked={field.value === true}
|
|
1299
|
+
onCheckedChange={field.onChange}
|
|
1300
|
+
/>
|
|
1301
|
+
</FormControl>
|
|
1302
|
+
</FormItem>
|
|
1303
|
+
<IsOverridden
|
|
1304
|
+
userConfig={config}
|
|
1305
|
+
name="experimental.server_side_pdf_export"
|
|
1306
|
+
/>
|
|
1307
|
+
<FormDescription>
|
|
1308
|
+
Enable PDF export using{" "}
|
|
1309
|
+
<Kbd className="inline">nbconvert</Kbd> and{" "}
|
|
1310
|
+
<Kbd className="inline">playwright</Kbd>. Refer to{" "}
|
|
1311
|
+
<ExternalLink href="https://docs.marimo.io/guides/exporting/#exporting-to-pdf-slides-or-rst">
|
|
1312
|
+
the docs
|
|
1313
|
+
</ExternalLink>
|
|
1314
|
+
.
|
|
1315
|
+
</FormDescription>
|
|
1316
|
+
</div>
|
|
1317
|
+
)}
|
|
1318
|
+
/>
|
|
1286
1319
|
</SettingGroup>
|
|
1287
1320
|
);
|
|
1288
1321
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { CellId } from "@/core/cells/ids";
|
|
4
|
+
|
|
3
5
|
export const DATA_CELL_ID = "data-cell-id";
|
|
4
6
|
|
|
5
7
|
export function getCellDomProps(cellId: string) {
|
|
@@ -7,3 +9,11 @@ export function getCellDomProps(cellId: string) {
|
|
|
7
9
|
[DATA_CELL_ID]: cellId,
|
|
8
10
|
};
|
|
9
11
|
}
|
|
12
|
+
|
|
13
|
+
export const DATA_FOR_CELL_ID = "data-for-cell-id";
|
|
14
|
+
|
|
15
|
+
export function getCellForDomProps(cellId: CellId) {
|
|
16
|
+
return {
|
|
17
|
+
[DATA_FOR_CELL_ID]: cellId,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -20,6 +20,7 @@ import { TextOutput } from "./output/TextOutput";
|
|
|
20
20
|
import { VideoOutput } from "./output/VideoOutput";
|
|
21
21
|
|
|
22
22
|
import "./output/Outputs.css";
|
|
23
|
+
import { useAtomValue } from "jotai";
|
|
23
24
|
import {
|
|
24
25
|
ChevronsDownUpIcon,
|
|
25
26
|
ChevronsUpDownIcon,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
} from "lucide-react";
|
|
28
29
|
import { tooltipHandler } from "@/components/charts/tooltip";
|
|
29
30
|
import { useExpandedOutput } from "@/core/cells/outputs";
|
|
31
|
+
import { viewStateAtom } from "@/core/mode";
|
|
30
32
|
import { useIframeCapabilities } from "@/hooks/useIframeCapabilities";
|
|
31
33
|
import { renderHTML } from "@/plugins/core/RenderHTML";
|
|
32
34
|
import { Banner } from "@/plugins/impl/common/error-banner";
|
|
@@ -46,10 +48,9 @@ import { renderMimeIcon } from "./renderMimeIcon";
|
|
|
46
48
|
|
|
47
49
|
const METADATA_KEY = "__metadata__";
|
|
48
50
|
|
|
49
|
-
type
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
>;
|
|
51
|
+
export type MimeType = OutputMessage["mimetype"];
|
|
52
|
+
|
|
53
|
+
type MimeBundleWithoutMetadata = Record<MimeType, { [key: string]: unknown }>;
|
|
53
54
|
|
|
54
55
|
type MimeBundle = MimeBundleWithoutMetadata & {
|
|
55
56
|
[METADATA_KEY]?: Record<string, { width?: number; height?: number }>;
|
|
@@ -70,7 +71,7 @@ export const OutputRenderer: React.FC<{
|
|
|
70
71
|
onRefactorWithAI?: OnRefactorWithAI;
|
|
71
72
|
wrapText?: boolean;
|
|
72
73
|
metadata?: { width?: number; height?: number };
|
|
73
|
-
renderFallback?: (mimetype:
|
|
74
|
+
renderFallback?: (mimetype: MimeType) => React.ReactNode;
|
|
74
75
|
}> = memo((props) => {
|
|
75
76
|
const {
|
|
76
77
|
message,
|
|
@@ -220,9 +221,7 @@ export const OutputRenderer: React.FC<{
|
|
|
220
221
|
return (
|
|
221
222
|
<MimeBundleOutputRenderer
|
|
222
223
|
channel={channel}
|
|
223
|
-
data={
|
|
224
|
-
parsedJsonData as Record<OutputMessage["mimetype"], OutputMessage>
|
|
225
|
-
}
|
|
224
|
+
data={parsedJsonData as Record<MimeType, OutputMessage>}
|
|
226
225
|
/>
|
|
227
226
|
);
|
|
228
227
|
case "application/vnd.jupyter.widget-view+json":
|
|
@@ -262,6 +261,8 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
262
261
|
cellId?: CellId;
|
|
263
262
|
}> = memo(({ data, channel, cellId }) => {
|
|
264
263
|
const mimebundle = Array.isArray(data) ? data[0] : data;
|
|
264
|
+
const { mode } = useAtomValue(viewStateAtom);
|
|
265
|
+
const appView = mode === "present" || mode === "read";
|
|
265
266
|
|
|
266
267
|
// Extract metadata if present (e.g., for retina image rendering)
|
|
267
268
|
const metadata = mimebundle[METADATA_KEY];
|
|
@@ -269,10 +270,7 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
269
270
|
// Filter out metadata from the mime entries and type narrow
|
|
270
271
|
const mimeEntries = Objects.entries(mimebundle as Record<string, unknown>)
|
|
271
272
|
.filter(([key]) => key !== METADATA_KEY)
|
|
272
|
-
.map(
|
|
273
|
-
([mime, data]) =>
|
|
274
|
-
[mime, data] as [OutputMessage["mimetype"], CellOutput["data"]],
|
|
275
|
-
);
|
|
273
|
+
.map(([mime, data]) => [mime, data] as [MimeType, CellOutput["data"]]);
|
|
276
274
|
|
|
277
275
|
// If there is none, return null
|
|
278
276
|
const first = mimeEntries[0]?.[0];
|
|
@@ -306,7 +304,12 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
306
304
|
return (
|
|
307
305
|
<Tabs defaultValue={first} orientation="vertical">
|
|
308
306
|
<div className="flex">
|
|
309
|
-
<TabsList
|
|
307
|
+
<TabsList
|
|
308
|
+
className={cn(
|
|
309
|
+
"self-start max-h-none flex flex-col gap-2 mr-3 shrink-0",
|
|
310
|
+
appView && "mt-4",
|
|
311
|
+
)}
|
|
312
|
+
>
|
|
310
313
|
{mimeEntries.map(([mime]) => (
|
|
311
314
|
<TabsTrigger
|
|
312
315
|
key={mime}
|
|
@@ -319,7 +322,7 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
319
322
|
</TabsTrigger>
|
|
320
323
|
))}
|
|
321
324
|
</TabsList>
|
|
322
|
-
<div className="flex-1">
|
|
325
|
+
<div className="flex-1 w-full">
|
|
323
326
|
{mimeEntries.map(([mime, output]) => (
|
|
324
327
|
<TabsContent key={mime} value={mime}>
|
|
325
328
|
<ErrorBoundary>
|
|
@@ -54,7 +54,12 @@ import {
|
|
|
54
54
|
} from "@/core/cells/cells";
|
|
55
55
|
import { disabledCellIds } from "@/core/cells/utils";
|
|
56
56
|
import { useResolvedMarimoConfig } from "@/core/config/config";
|
|
57
|
+
import { getFeatureFlag } from "@/core/config/feature-flag";
|
|
57
58
|
import { Constants } from "@/core/constants";
|
|
59
|
+
import {
|
|
60
|
+
updateCellOutputsWithScreenshots,
|
|
61
|
+
useEnrichCellOutputs,
|
|
62
|
+
} from "@/core/export/hooks";
|
|
58
63
|
import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
|
|
59
64
|
import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
|
|
60
65
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
@@ -64,7 +69,12 @@ import { downloadAsHTML } from "@/core/static/download-html";
|
|
|
64
69
|
import { createShareableLink } from "@/core/wasm/share";
|
|
65
70
|
import { isWasm } from "@/core/wasm/utils";
|
|
66
71
|
import { copyToClipboard } from "@/utils/copy";
|
|
67
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
downloadAsPDF,
|
|
74
|
+
downloadBlob,
|
|
75
|
+
downloadHTMLAsImage,
|
|
76
|
+
withLoadingToast,
|
|
77
|
+
} from "@/utils/download";
|
|
68
78
|
import { Filenames } from "@/utils/filenames";
|
|
69
79
|
import { Objects } from "@/utils/objects";
|
|
70
80
|
import { newNotebookURL } from "@/utils/urls";
|
|
@@ -110,13 +120,15 @@ export function useNotebookActions() {
|
|
|
110
120
|
const setCommandPaletteOpen = useSetAtom(commandPaletteAtom);
|
|
111
121
|
const setSettingsDialogOpen = useSetAtom(settingDialogAtom);
|
|
112
122
|
const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
|
|
113
|
-
const { exportAsMarkdown, readCode, saveCellConfig } =
|
|
123
|
+
const { exportAsMarkdown, readCode, saveCellConfig, updateCellOutputs } =
|
|
124
|
+
useRequestClient();
|
|
114
125
|
|
|
115
126
|
const hasDisabledCells = useAtomValue(hasDisabledCellsAtom);
|
|
116
127
|
const canUndoDeletes = useAtomValue(canUndoDeletesAtom);
|
|
117
128
|
const { selectedLayout } = useLayoutState();
|
|
118
129
|
const { setLayoutView } = useLayoutActions();
|
|
119
130
|
const togglePresenting = useTogglePresenting();
|
|
131
|
+
const takeScreenshots = useEnrichCellOutputs();
|
|
120
132
|
|
|
121
133
|
// Fallback: if sharing is undefined, both are enabled by default
|
|
122
134
|
const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
|
|
@@ -139,11 +151,7 @@ export function useNotebookActions() {
|
|
|
139
151
|
label: "Download as HTML",
|
|
140
152
|
handle: async () => {
|
|
141
153
|
if (!filename) {
|
|
142
|
-
|
|
143
|
-
variant: "danger",
|
|
144
|
-
title: "Error",
|
|
145
|
-
description: "Notebooks must be named to be exported.",
|
|
146
|
-
});
|
|
154
|
+
toastNotebookMustBeNamed();
|
|
147
155
|
return;
|
|
148
156
|
}
|
|
149
157
|
await downloadAsHTML({ filename, includeCode: true });
|
|
@@ -154,11 +162,7 @@ export function useNotebookActions() {
|
|
|
154
162
|
label: "Download as HTML (exclude code)",
|
|
155
163
|
handle: async () => {
|
|
156
164
|
if (!filename) {
|
|
157
|
-
|
|
158
|
-
variant: "danger",
|
|
159
|
-
title: "Error",
|
|
160
|
-
description: "Notebooks must be named to be exported.",
|
|
161
|
-
});
|
|
165
|
+
toastNotebookMustBeNamed();
|
|
162
166
|
return;
|
|
163
167
|
}
|
|
164
168
|
await downloadAsHTML({ filename, includeCode: false });
|
|
@@ -220,6 +224,26 @@ export function useNotebookActions() {
|
|
|
220
224
|
</span>
|
|
221
225
|
),
|
|
222
226
|
handle: async () => {
|
|
227
|
+
if (getFeatureFlag("server_side_pdf_export")) {
|
|
228
|
+
if (!filename) {
|
|
229
|
+
toastNotebookMustBeNamed();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const downloadPDF = async () => {
|
|
234
|
+
await updateCellOutputsWithScreenshots(
|
|
235
|
+
takeScreenshots,
|
|
236
|
+
updateCellOutputs,
|
|
237
|
+
);
|
|
238
|
+
await downloadAsPDF({
|
|
239
|
+
filename: filename,
|
|
240
|
+
webpdf: false,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
await withLoadingToast("Downloading PDF...", downloadPDF);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
223
247
|
const beforeprint = new Event("export-beforeprint");
|
|
224
248
|
const afterprint = new Event("export-afterprint");
|
|
225
249
|
function print() {
|
|
@@ -527,3 +551,11 @@ export function useNotebookActions() {
|
|
|
527
551
|
return action;
|
|
528
552
|
});
|
|
529
553
|
}
|
|
554
|
+
|
|
555
|
+
function toastNotebookMustBeNamed() {
|
|
556
|
+
toast({
|
|
557
|
+
title: "Error",
|
|
558
|
+
description: "Notebooks must be named to be exported.",
|
|
559
|
+
variant: "danger",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
@@ -12,6 +12,7 @@ import React, {
|
|
|
12
12
|
useState,
|
|
13
13
|
} from "react";
|
|
14
14
|
import useEvent from "react-use-event-hook";
|
|
15
|
+
import { getCellForDomProps } from "@/components/data-table/cell-utils";
|
|
15
16
|
import {
|
|
16
17
|
renderMinimalShortcut,
|
|
17
18
|
renderShortcut,
|
|
@@ -104,7 +105,11 @@ const CellActionsDropdownInternal = (
|
|
|
104
105
|
{...restoreFocus}
|
|
105
106
|
>
|
|
106
107
|
<Command>
|
|
107
|
-
<CommandInput
|
|
108
|
+
<CommandInput
|
|
109
|
+
placeholder="Search actions..."
|
|
110
|
+
className="h-6 m-1"
|
|
111
|
+
{...getCellForDomProps(props.cellId)}
|
|
112
|
+
/>
|
|
108
113
|
<CommandList>
|
|
109
114
|
<CommandEmpty>No results</CommandEmpty>
|
|
110
115
|
{actions.map((group, i) => (
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
|
11
11
|
import { useMemo } from "react";
|
|
12
12
|
import { mergeProps, useFocusWithin, useKeyboard } from "react-aria";
|
|
13
|
+
import { DATA_FOR_CELL_ID } from "@/components/data-table/cell-utils";
|
|
13
14
|
import { aiCompletionCellAtom } from "@/core/ai/state";
|
|
14
15
|
import { cellIdsAtom, notebookAtom, useCellActions } from "@/core/cells/cells";
|
|
15
16
|
import { useCellFocusActions } from "@/core/cells/focus";
|
|
@@ -101,7 +102,23 @@ function useCellFocusProps(
|
|
|
101
102
|
// On focus, set the last focused cell id.
|
|
102
103
|
focusActions.focusCell({ cellId });
|
|
103
104
|
},
|
|
104
|
-
onBlurWithin: () => {
|
|
105
|
+
onBlurWithin: (e) => {
|
|
106
|
+
// Check if blur is happening because of vim search panel interaction
|
|
107
|
+
if (isInVimPanel(e.relatedTarget) || isInVimPanel(e.target)) {
|
|
108
|
+
// Don't hide code if we're just interacting with vim search
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// If the related target is for a cell id (data-for-cell-id), then we don't want to hide the code, otherwise it might
|
|
113
|
+
// close the dropdown.
|
|
114
|
+
if (
|
|
115
|
+
getDataForCellId(e.relatedTarget) === cellId ||
|
|
116
|
+
getDataForCellId(e.relatedTarget?.closest(`[${DATA_FOR_CELL_ID}]`)) ===
|
|
117
|
+
cellId
|
|
118
|
+
) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
// On blur, hide the code if it was temporarily shown.
|
|
106
123
|
temporarilyShownCodeActions.remove(cellId);
|
|
107
124
|
actions.markTouched({ cellId });
|
|
@@ -116,6 +133,27 @@ function useCellFocusProps(
|
|
|
116
133
|
return focusWithinProps;
|
|
117
134
|
}
|
|
118
135
|
|
|
136
|
+
function isInVimPanel(element: Element | null): boolean {
|
|
137
|
+
if (!element) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (element instanceof HTMLElement) {
|
|
141
|
+
return element.closest(".cm-vim-panel") !== null;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getDataForCellId(element: Element | null | undefined): CellId | null {
|
|
147
|
+
if (!element) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const cellId = element.getAttribute(DATA_FOR_CELL_ID);
|
|
151
|
+
if (!cellId) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return cellId as CellId;
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
type KeymapHandlers = Record<string, () => boolean>;
|
|
120
158
|
|
|
121
159
|
/**
|
|
@@ -8,6 +8,7 @@ import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
|
|
|
8
8
|
import { normalizeName } from "@/core/cells/names";
|
|
9
9
|
import { type ConnectionName, DUCKDB_ENGINE } from "@/core/datasets/engines";
|
|
10
10
|
import { useAutoGrowInputProps } from "@/hooks/useAutoGrowInputProps";
|
|
11
|
+
import { cellIdState } from "../../cells/state";
|
|
11
12
|
import { formatSQL } from "../../format";
|
|
12
13
|
import { languageAdapterState } from "../extension";
|
|
13
14
|
import { MarkdownLanguageAdapter } from "../languages/markdown";
|
|
@@ -31,6 +32,7 @@ export const LanguagePanelComponent: React.FC<{
|
|
|
31
32
|
}> = ({ view }) => {
|
|
32
33
|
const { spanProps, inputProps } = useAutoGrowInputProps({ minWidth: 50 });
|
|
33
34
|
const languageAdapter = view.state.field(languageAdapterState);
|
|
35
|
+
const cellId = view.state.facet(cellIdState);
|
|
34
36
|
|
|
35
37
|
let actions: React.ReactNode = <div />;
|
|
36
38
|
let showDivider = false;
|
|
@@ -93,6 +95,7 @@ export const LanguagePanelComponent: React.FC<{
|
|
|
93
95
|
<SQLEngineSelect
|
|
94
96
|
selectedEngine={metadata.engine}
|
|
95
97
|
onChange={switchEngine}
|
|
98
|
+
cellId={cellId}
|
|
96
99
|
/>
|
|
97
100
|
<div className="flex items-center gap-2 ml-auto">
|
|
98
101
|
{metadata.engine === DUCKDB_ENGINE && <SQLModeSelect />}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DatabaseBackup,
|
|
9
9
|
SearchCheck,
|
|
10
10
|
} from "lucide-react";
|
|
11
|
+
import { getCellForDomProps } from "@/components/data-table/cell-utils";
|
|
11
12
|
import { transformDisplayName } from "@/components/databases/display";
|
|
12
13
|
import { DatabaseLogo } from "@/components/databases/icon";
|
|
13
14
|
import { Button } from "@/components/ui/button";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
SelectValue,
|
|
23
24
|
} from "@/components/ui/select";
|
|
24
25
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
26
|
+
import type { CellId } from "@/core/cells/ids";
|
|
25
27
|
import {
|
|
26
28
|
dataConnectionsMapAtom,
|
|
27
29
|
setLatestEngineSelected,
|
|
@@ -38,11 +40,13 @@ import { type SQLMode, useSQLMode } from "../languages/sql/sql-mode";
|
|
|
38
40
|
interface SelectProps {
|
|
39
41
|
selectedEngine: ConnectionName;
|
|
40
42
|
onChange: (engine: ConnectionName) => void;
|
|
43
|
+
cellId: CellId;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export const SQLEngineSelect: React.FC<SelectProps> = ({
|
|
44
47
|
selectedEngine,
|
|
45
48
|
onChange,
|
|
49
|
+
cellId,
|
|
46
50
|
}) => {
|
|
47
51
|
const connectionsMap = useAtomValue(dataConnectionsMapAtom);
|
|
48
52
|
|
|
@@ -98,10 +102,10 @@ export const SQLEngineSelect: React.FC<SelectProps> = ({
|
|
|
98
102
|
return (
|
|
99
103
|
<div className="flex flex-row gap-1 items-center">
|
|
100
104
|
<Select value={selectedEngine} onValueChange={handleSelectEngine}>
|
|
101
|
-
<SQLSelectTrigger>
|
|
105
|
+
<SQLSelectTrigger {...getCellForDomProps(cellId)}>
|
|
102
106
|
<SelectValue placeholder="Select an engine" />
|
|
103
107
|
</SQLSelectTrigger>
|
|
104
|
-
<SelectContent>
|
|
108
|
+
<SelectContent {...getCellForDomProps(cellId)}>
|
|
105
109
|
<SelectGroup>
|
|
106
110
|
<SelectLabel>Database connections</SelectLabel>
|
|
107
111
|
{engineIsDisconnected && (
|
|
@@ -13,6 +13,7 @@ export interface ExperimentalFeatures {
|
|
|
13
13
|
chat_modes: boolean;
|
|
14
14
|
cache_panel: boolean;
|
|
15
15
|
external_agents: boolean;
|
|
16
|
+
server_side_pdf_export: boolean;
|
|
16
17
|
// Add new feature flags here
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -24,6 +25,7 @@ const defaultValues: ExperimentalFeatures = {
|
|
|
24
25
|
chat_modes: false,
|
|
25
26
|
cache_panel: false,
|
|
26
27
|
external_agents: import.meta.env.DEV,
|
|
28
|
+
server_side_pdf_export: false,
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
|
|
@@ -8,7 +8,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
8
8
|
import type { CellId } from "@/core/cells/ids";
|
|
9
9
|
import { CellOutputId } from "@/core/cells/ids";
|
|
10
10
|
import type { CellRuntimeState } from "@/core/cells/types";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
updateCellOutputsWithScreenshots,
|
|
13
|
+
useEnrichCellOutputs,
|
|
14
|
+
} from "../hooks";
|
|
12
15
|
|
|
13
16
|
// Mock html-to-image
|
|
14
17
|
vi.mock("html-to-image", () => ({
|
|
@@ -22,6 +25,11 @@ vi.mock("@/utils/Logger", () => ({
|
|
|
22
25
|
},
|
|
23
26
|
}));
|
|
24
27
|
|
|
28
|
+
// Mock toast
|
|
29
|
+
vi.mock("@/components/ui/use-toast", () => ({
|
|
30
|
+
toast: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
25
33
|
// Mock cellsRuntimeAtom - must be defined inline in the factory function
|
|
26
34
|
vi.mock("@/core/cells/cells", async () => {
|
|
27
35
|
const { atom } = await import("jotai");
|
|
@@ -31,6 +39,7 @@ vi.mock("@/core/cells/cells", async () => {
|
|
|
31
39
|
});
|
|
32
40
|
|
|
33
41
|
import { toPng } from "html-to-image";
|
|
42
|
+
import { toast } from "@/components/ui/use-toast";
|
|
34
43
|
import { cellsRuntimeAtom } from "@/core/cells/cells";
|
|
35
44
|
import { Logger } from "@/utils/Logger";
|
|
36
45
|
|
|
@@ -502,3 +511,113 @@ describe("useEnrichCellOutputs", () => {
|
|
|
502
511
|
}
|
|
503
512
|
});
|
|
504
513
|
});
|
|
514
|
+
|
|
515
|
+
describe("updateCellOutputsWithScreenshots", () => {
|
|
516
|
+
beforeEach(() => {
|
|
517
|
+
vi.clearAllMocks();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("should call updateCellOutputs when there are screenshots", async () => {
|
|
521
|
+
const cellId = "cell-1" as CellId;
|
|
522
|
+
const mockScreenshots = {
|
|
523
|
+
[cellId]: ["image/png", "data:image/png;base64,test"] as [
|
|
524
|
+
"image/png",
|
|
525
|
+
string,
|
|
526
|
+
],
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
530
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
531
|
+
|
|
532
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
533
|
+
|
|
534
|
+
expect(takeScreenshots).toHaveBeenCalledTimes(1);
|
|
535
|
+
expect(updateCellOutputs).toHaveBeenCalledTimes(1);
|
|
536
|
+
expect(updateCellOutputs).toHaveBeenCalledWith({
|
|
537
|
+
cellIdsToOutput: mockScreenshots,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("should not call updateCellOutputs when there are no screenshots", async () => {
|
|
542
|
+
const takeScreenshots = vi.fn().mockResolvedValue({});
|
|
543
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
544
|
+
|
|
545
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
546
|
+
|
|
547
|
+
expect(takeScreenshots).toHaveBeenCalledTimes(1);
|
|
548
|
+
expect(updateCellOutputs).not.toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("should handle multiple cell screenshots", async () => {
|
|
552
|
+
const cell1 = "cell-1" as CellId;
|
|
553
|
+
const cell2 = "cell-2" as CellId;
|
|
554
|
+
const mockScreenshots = {
|
|
555
|
+
[cell1]: ["image/png", "data:image/png;base64,image1"] as [
|
|
556
|
+
"image/png",
|
|
557
|
+
string,
|
|
558
|
+
],
|
|
559
|
+
[cell2]: ["image/png", "data:image/png;base64,image2"] as [
|
|
560
|
+
"image/png",
|
|
561
|
+
string,
|
|
562
|
+
],
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
566
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
567
|
+
|
|
568
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
569
|
+
|
|
570
|
+
expect(updateCellOutputs).toHaveBeenCalledWith({
|
|
571
|
+
cellIdsToOutput: mockScreenshots,
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("should catch errors from takeScreenshots and show toast", async () => {
|
|
576
|
+
const error = new Error("Screenshot failed");
|
|
577
|
+
const takeScreenshots = vi.fn().mockRejectedValue(error);
|
|
578
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
579
|
+
|
|
580
|
+
// Should not throw - errors are caught and shown via toast
|
|
581
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
582
|
+
|
|
583
|
+
expect(updateCellOutputs).not.toHaveBeenCalled();
|
|
584
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
585
|
+
"Error updating cell outputs with screenshots:",
|
|
586
|
+
error,
|
|
587
|
+
);
|
|
588
|
+
expect(toast).toHaveBeenCalledWith({
|
|
589
|
+
title: "Failed to capture cell outputs",
|
|
590
|
+
description:
|
|
591
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
592
|
+
variant: "danger",
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("should catch errors from updateCellOutputs and show toast", async () => {
|
|
597
|
+
const cellId = "cell-1" as CellId;
|
|
598
|
+
const mockScreenshots = {
|
|
599
|
+
[cellId]: ["image/png", "data:image/png;base64,test"] as [
|
|
600
|
+
"image/png",
|
|
601
|
+
string,
|
|
602
|
+
],
|
|
603
|
+
};
|
|
604
|
+
const error = new Error("Update failed");
|
|
605
|
+
|
|
606
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
607
|
+
const updateCellOutputs = vi.fn().mockRejectedValue(error);
|
|
608
|
+
|
|
609
|
+
// Should not throw - errors are caught and shown via toast
|
|
610
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
611
|
+
|
|
612
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
613
|
+
"Error updating cell outputs with screenshots:",
|
|
614
|
+
error,
|
|
615
|
+
);
|
|
616
|
+
expect(toast).toHaveBeenCalledWith({
|
|
617
|
+
title: "Failed to capture cell outputs",
|
|
618
|
+
description:
|
|
619
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
620
|
+
variant: "danger",
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
});
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { toPng } from "html-to-image";
|
|
3
3
|
import { atom, useAtom, useAtomValue } from "jotai";
|
|
4
|
+
import type { MimeType } from "@/components/editor/Output";
|
|
5
|
+
import { toast } from "@/components/ui/use-toast";
|
|
4
6
|
import { appConfigAtom } from "@/core/config/config";
|
|
5
7
|
import { useInterval } from "@/hooks/useInterval";
|
|
6
8
|
import { Logger } from "@/utils/Logger";
|
|
@@ -9,6 +11,7 @@ import { cellsRuntimeAtom } from "../cells/cells";
|
|
|
9
11
|
import { type CellId, CellOutputId } from "../cells/ids";
|
|
10
12
|
import { connectionAtom } from "../network/connection";
|
|
11
13
|
import { useRequestClient } from "../network/requests";
|
|
14
|
+
import type { UpdateCellOutputsRequest } from "../network/types";
|
|
12
15
|
import { VirtualFileTracker } from "../static/virtual-file-tracker";
|
|
13
16
|
import { WebSocketState } from "../websocket/types";
|
|
14
17
|
|
|
@@ -60,12 +63,10 @@ export function useAutoExport() {
|
|
|
60
63
|
|
|
61
64
|
useInterval(
|
|
62
65
|
async () => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
}
|
|
66
|
+
await updateCellOutputsWithScreenshots(
|
|
67
|
+
takeScreenshots,
|
|
68
|
+
updateCellOutputs,
|
|
69
|
+
);
|
|
69
70
|
await autoExportAsIPYNB({
|
|
70
71
|
download: false,
|
|
71
72
|
});
|
|
@@ -86,7 +87,7 @@ export function useAutoExport() {
|
|
|
86
87
|
const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
|
|
87
88
|
|
|
88
89
|
// MIME types to capture screenshots for
|
|
89
|
-
const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set([
|
|
90
|
+
const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
90
91
|
"text/html",
|
|
91
92
|
"application/vnd.vegalite.v5+json",
|
|
92
93
|
"application/vnd.vega.v5+json",
|
|
@@ -112,7 +113,8 @@ export function useEnrichCellOutputs() {
|
|
|
112
113
|
// Track latest output for this cell
|
|
113
114
|
trackedCellsOutput[cellId] = outputData;
|
|
114
115
|
if (
|
|
115
|
-
|
|
116
|
+
runtime.output?.mimetype &&
|
|
117
|
+
MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
|
|
116
118
|
outputData &&
|
|
117
119
|
outputHasChanged
|
|
118
120
|
) {
|
|
@@ -157,3 +159,26 @@ export function useEnrichCellOutputs() {
|
|
|
157
159
|
);
|
|
158
160
|
};
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Utility function to take screenshots of cells with HTML outputs and update the cell outputs.
|
|
165
|
+
*/
|
|
166
|
+
export async function updateCellOutputsWithScreenshots(
|
|
167
|
+
takeScreenshots: () => Promise<Record<CellId, ["image/png", string]>>,
|
|
168
|
+
updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>,
|
|
169
|
+
) {
|
|
170
|
+
try {
|
|
171
|
+
const cellIdsToOutput = await takeScreenshots();
|
|
172
|
+
if (Object.keys(cellIdsToOutput).length > 0) {
|
|
173
|
+
await updateCellOutputs({ cellIdsToOutput });
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
Logger.error("Error updating cell outputs with screenshots:", error);
|
|
177
|
+
toast({
|
|
178
|
+
title: "Failed to capture cell outputs",
|
|
179
|
+
description:
|
|
180
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
181
|
+
variant: "danger",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -168,6 +168,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
168
168
|
openTutorial = throwNotImplemented;
|
|
169
169
|
exportAsHTML = throwNotImplemented;
|
|
170
170
|
exportAsMarkdown = throwNotImplemented;
|
|
171
|
+
exportAsPDF = throwNotImplemented;
|
|
171
172
|
autoExportAsHTML = throwNotImplemented;
|
|
172
173
|
autoExportAsMarkdown = throwNotImplemented;
|
|
173
174
|
autoExportAsIPYNB = throwNotImplemented;
|