@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/{chat-ui-DsZJj75A.js → chat-ui-DH3hwWoI.js} +2 -2
  2. package/dist/{common-BGCQJb-W.js → common-D8DGPf0N.js} +5 -5
  3. package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-CxlSazqu.js} +2095 -2088
  4. package/dist/main.js +5 -5
  5. package/dist/{process-output-bVfbcx5_.js → process-output-B24cDGzV.js} +1 -1
  6. package/dist/{reveal-component-NNi374so.js → reveal-component-UfXDaVe-.js} +2 -2
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/__mocks__/requests.ts +5 -0
  10. package/src/components/databases/icons/huggingface.svg +8 -0
  11. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
  12. package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
  13. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +458 -0
  14. package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
  15. package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +295 -0
  16. package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
  17. package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
  18. package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
  19. package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
  20. package/src/components/editor/actions/export-dialog/format-notice.tsx +166 -0
  21. package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
  22. package/src/components/editor/actions/export-dialog/state.ts +339 -0
  23. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +372 -0
  24. package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
  25. package/src/components/editor/actions/useNotebookActions.tsx +72 -170
  26. package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
  27. package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
  28. package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
  29. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
  30. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
  31. package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
  32. package/src/components/editor/connections/storage/as-code.ts +19 -1
  33. package/src/components/editor/connections/storage/schemas.ts +19 -0
  34. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
  35. package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
  36. package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
  37. package/src/components/storage/components.tsx +2 -0
  38. package/src/components/storage/storage-snippets.ts +58 -0
  39. package/src/core/__tests__/mode.test.ts +85 -0
  40. package/src/core/dom/outline.ts +25 -2
  41. package/src/core/mode.ts +11 -9
  42. package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
  43. package/src/core/storage/types.ts +1 -0
  44. package/src/utils/__tests__/download.test.tsx +6 -4
  45. package/src/utils/download.ts +3 -1
  46. package/src/utils/shell.ts +17 -0
@@ -0,0 +1,339 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atomWithStorage } from "jotai/utils";
4
+ import { z } from "zod";
5
+ import type { ExportAvailabilityResponse } from "@/core/network/types";
6
+ import { adaptForLocalStorage } from "@/utils/storage/jotai";
7
+
8
+ export const EXPORT_FORMATS = [
9
+ "html",
10
+ "markdown",
11
+ "ipynb",
12
+ "pdf",
13
+ "script",
14
+ "png",
15
+ ] as const;
16
+
17
+ export type ExportFormat = (typeof EXPORT_FORMATS)[number];
18
+ export type ExportSetupRequirement =
19
+ ExportAvailabilityResponse["formats"][number]["missingSetup"][number];
20
+
21
+ export const MARKDOWN_FLAVORS = ["pymdown", "qmd", "mystmd", "mdx"] as const;
22
+ export const IPYNB_SORT_MODES = ["topological", "top-down"] as const;
23
+ export const PDF_PRESETS = ["document", "slides"] as const;
24
+ export const SCRIPT_TYPES = ["source", "flat"] as const;
25
+
26
+ export type MarkdownFlavor = (typeof MARKDOWN_FLAVORS)[number];
27
+ export type ScriptType = (typeof SCRIPT_TYPES)[number];
28
+
29
+ export function isExportFormat(value: unknown): value is ExportFormat {
30
+ return (
31
+ typeof value === "string" &&
32
+ EXPORT_FORMATS.some((format) => format === value)
33
+ );
34
+ }
35
+
36
+ export interface ExportOptions {
37
+ html: {
38
+ includeCode: boolean;
39
+ };
40
+ markdown: {
41
+ flavor: MarkdownFlavor | null;
42
+ };
43
+ ipynb: {
44
+ sortMode: (typeof IPYNB_SORT_MODES)[number];
45
+ includeOutputs: boolean;
46
+ };
47
+ pdf: {
48
+ preset: (typeof PDF_PRESETS)[number];
49
+ includeInputs: boolean;
50
+ includeOutputs: boolean;
51
+ webpdf: boolean;
52
+ };
53
+ script: {
54
+ type: ScriptType;
55
+ };
56
+ }
57
+
58
+ export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
59
+ html: {
60
+ includeCode: true,
61
+ },
62
+ markdown: {
63
+ flavor: null,
64
+ },
65
+ ipynb: {
66
+ sortMode: "topological",
67
+ includeOutputs: false,
68
+ },
69
+ pdf: {
70
+ preset: "document",
71
+ includeInputs: true,
72
+ includeOutputs: true,
73
+ webpdf: true,
74
+ },
75
+ script: {
76
+ type: "source",
77
+ },
78
+ };
79
+
80
+ function storedValue<Schema extends z.ZodType>(
81
+ schema: Schema,
82
+ fallback: Exclude<z.output<Schema>, undefined>,
83
+ ) {
84
+ return schema.default(fallback).catch(fallback);
85
+ }
86
+
87
+ function storedGroup<Shape extends z.ZodRawShape>(shape: Shape) {
88
+ return z.object(shape).optional().catch(undefined);
89
+ }
90
+
91
+ const storedExportOptionsSchema = z
92
+ .object({
93
+ html: storedGroup({
94
+ includeCode: storedValue(
95
+ z.boolean(),
96
+ DEFAULT_EXPORT_OPTIONS.html.includeCode,
97
+ ),
98
+ }),
99
+ markdown: storedGroup({
100
+ flavor: storedValue(
101
+ z.enum(MARKDOWN_FLAVORS).nullable(),
102
+ DEFAULT_EXPORT_OPTIONS.markdown.flavor,
103
+ ),
104
+ }),
105
+ ipynb: storedGroup({
106
+ sortMode: storedValue(
107
+ z.enum(IPYNB_SORT_MODES),
108
+ DEFAULT_EXPORT_OPTIONS.ipynb.sortMode,
109
+ ),
110
+ includeOutputs: storedValue(
111
+ z.boolean(),
112
+ DEFAULT_EXPORT_OPTIONS.ipynb.includeOutputs,
113
+ ),
114
+ }),
115
+ pdf: storedGroup({
116
+ preset: storedValue(
117
+ z.enum(PDF_PRESETS),
118
+ DEFAULT_EXPORT_OPTIONS.pdf.preset,
119
+ ),
120
+ includeInputs: storedValue(
121
+ z.boolean(),
122
+ DEFAULT_EXPORT_OPTIONS.pdf.includeInputs,
123
+ ),
124
+ includeOutputs: storedValue(
125
+ z.boolean(),
126
+ DEFAULT_EXPORT_OPTIONS.pdf.includeOutputs,
127
+ ),
128
+ webpdf: storedValue(z.boolean(), DEFAULT_EXPORT_OPTIONS.pdf.webpdf),
129
+ }),
130
+ script: storedGroup({
131
+ type: storedValue(
132
+ z.enum(SCRIPT_TYPES),
133
+ DEFAULT_EXPORT_OPTIONS.script.type,
134
+ ),
135
+ }),
136
+ })
137
+ .catch({});
138
+
139
+ export type ExportOptionOverrides = {
140
+ [Format in keyof ExportOptions]?: Partial<ExportOptions[Format]>;
141
+ };
142
+
143
+ export function applyExportOptionOverrides(
144
+ options: ExportOptions,
145
+ overrides: ExportOptionOverrides,
146
+ ): ExportOptions {
147
+ const next = { ...options };
148
+ const mergeFormat = <Format extends keyof ExportOptions>(format: Format) => {
149
+ next[format] = { ...options[format], ...overrides[format] };
150
+ };
151
+ for (const format of Object.keys(overrides) as (keyof ExportOptions)[]) {
152
+ mergeFormat(format);
153
+ }
154
+ return next;
155
+ }
156
+
157
+ export function mergeExportOptions(saved: unknown): ExportOptions {
158
+ return applyExportOptionOverrides(
159
+ DEFAULT_EXPORT_OPTIONS,
160
+ storedExportOptionsSchema.parse(saved),
161
+ );
162
+ }
163
+
164
+ const exportOptionsStorage = adaptForLocalStorage<ExportOptions, unknown>({
165
+ toSerializable: (value) => value,
166
+ fromSerializable: mergeExportOptions,
167
+ });
168
+
169
+ export const exportOptionsAtom = atomWithStorage<ExportOptions>(
170
+ "marimo:export:options:v1",
171
+ DEFAULT_EXPORT_OPTIONS,
172
+ exportOptionsStorage,
173
+ { getOnInit: true },
174
+ );
175
+
176
+ export const lastExportFormatAtom = atomWithStorage<ExportFormat>(
177
+ "marimo:export:last-format:v1",
178
+ "html",
179
+ adaptForLocalStorage<ExportFormat, unknown>({
180
+ toSerializable: (value) => value,
181
+ fromSerializable: (value) => (isExportFormat(value) ? value : "html"),
182
+ }),
183
+ { getOnInit: true },
184
+ );
185
+
186
+ export type ExportBlockReason =
187
+ | { type: "checking-requirements" }
188
+ | { type: "notebook-must-be-named" }
189
+ | { type: "missing-packages"; packages: string[] }
190
+ | { type: "missing-setup"; requirements: ExportSetupRequirement[] }
191
+ | { type: "wasm-runtime" };
192
+
193
+ export interface ExportFormatStatus {
194
+ available: boolean;
195
+ reason?: ExportBlockReason;
196
+ availabilityCheckFailed?: boolean;
197
+ }
198
+
199
+ interface GetExportFormatStatusOptions {
200
+ format: ExportFormat;
201
+ options: ExportOptions;
202
+ runtime: "server" | "wasm";
203
+ filename: string | null;
204
+ availability:
205
+ | { status: "pending" }
206
+ | { status: "error" }
207
+ | { status: "success"; data: ExportAvailabilityResponse | null };
208
+ }
209
+
210
+ interface ExportFormatRequirements {
211
+ requiresNamedNotebookOnServer: boolean;
212
+ requiresServerRuntime: boolean;
213
+ checksServerAvailability: boolean;
214
+ }
215
+
216
+ const FORMAT_REQUIREMENTS: Record<ExportFormat, ExportFormatRequirements> = {
217
+ html: {
218
+ requiresNamedNotebookOnServer: true,
219
+ requiresServerRuntime: false,
220
+ checksServerAvailability: false,
221
+ },
222
+ markdown: {
223
+ requiresNamedNotebookOnServer: true,
224
+ requiresServerRuntime: false,
225
+ checksServerAvailability: false,
226
+ },
227
+ ipynb: {
228
+ requiresNamedNotebookOnServer: true,
229
+ requiresServerRuntime: true,
230
+ checksServerAvailability: true,
231
+ },
232
+ pdf: {
233
+ requiresNamedNotebookOnServer: true,
234
+ requiresServerRuntime: true,
235
+ checksServerAvailability: true,
236
+ },
237
+ script: {
238
+ requiresNamedNotebookOnServer: false,
239
+ requiresServerRuntime: false,
240
+ checksServerAvailability: false,
241
+ },
242
+ png: {
243
+ requiresNamedNotebookOnServer: false,
244
+ requiresServerRuntime: false,
245
+ checksServerAvailability: false,
246
+ },
247
+ };
248
+
249
+ export function isBrowserPrintExport(
250
+ runtime: "server" | "wasm",
251
+ format: ExportFormat,
252
+ ): boolean {
253
+ return runtime === "wasm" && format === "pdf";
254
+ }
255
+
256
+ export function getExportFormatStatus({
257
+ format,
258
+ options,
259
+ runtime,
260
+ filename,
261
+ availability,
262
+ }: GetExportFormatStatusOptions): ExportFormatStatus {
263
+ const isNotebookSource =
264
+ format === "script" && options.script.type === "source";
265
+ const requirements = isNotebookSource
266
+ ? {
267
+ ...FORMAT_REQUIREMENTS.script,
268
+ requiresNamedNotebookOnServer: true,
269
+ }
270
+ : FORMAT_REQUIREMENTS[format];
271
+
272
+ const usesBrowserPrint = isBrowserPrintExport(runtime, format);
273
+
274
+ if (runtime === "wasm" && requirements.requiresServerRuntime) {
275
+ return {
276
+ available: usesBrowserPrint,
277
+ reason: { type: "wasm-runtime" },
278
+ };
279
+ }
280
+
281
+ if (
282
+ runtime === "server" &&
283
+ requirements.requiresNamedNotebookOnServer &&
284
+ !filename
285
+ ) {
286
+ return {
287
+ available: false,
288
+ reason: { type: "notebook-must-be-named" },
289
+ };
290
+ }
291
+
292
+ if (runtime === "wasm" || !requirements.checksServerAvailability) {
293
+ return { available: true };
294
+ }
295
+
296
+ if (availability.status === "pending") {
297
+ return {
298
+ available: false,
299
+ reason: { type: "checking-requirements" },
300
+ };
301
+ }
302
+
303
+ if (availability.status === "error" || !availability.data) {
304
+ return { available: true, availabilityCheckFailed: true };
305
+ }
306
+
307
+ const serverStatus = availability.data.formats.find(
308
+ (candidate) => candidate.format === format,
309
+ );
310
+ if (!serverStatus) {
311
+ return { available: true, availabilityCheckFailed: true };
312
+ }
313
+ if (!serverStatus.dependenciesAvailable) {
314
+ if (serverStatus.missingPackages.length > 0) {
315
+ return {
316
+ available: false,
317
+ reason: {
318
+ type: "missing-packages",
319
+ packages: serverStatus.missingPackages,
320
+ },
321
+ };
322
+ }
323
+ if (serverStatus.missingSetup.length > 0) {
324
+ return {
325
+ available: false,
326
+ reason: {
327
+ type: "missing-setup",
328
+ requirements: serverStatus.missingSetup,
329
+ },
330
+ };
331
+ }
332
+ return {
333
+ available: true,
334
+ availabilityCheckFailed: true,
335
+ };
336
+ }
337
+
338
+ return { available: true };
339
+ }
@@ -0,0 +1,372 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useAtom } from "jotai";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { toast } from "@/components/ui/use-toast";
6
+ import {
7
+ updateCellOutputsWithScreenshots,
8
+ useEnrichCellOutputs,
9
+ } from "@/core/export/hooks";
10
+ import { runDuringPresentMode } from "@/core/mode";
11
+ import { useRequestClient } from "@/core/network/requests";
12
+ import type { ExportAvailabilityResponse } from "@/core/network/types";
13
+ import { useFilename } from "@/core/saving/filename";
14
+ import { VirtualFileTracker } from "@/core/static/virtual-file-tracker";
15
+ import { isWasm } from "@/core/wasm/utils";
16
+ import { type AsyncDataResult, useAsyncData } from "@/hooks/useAsyncData";
17
+ import {
18
+ ADD_PRINTING_CLASS,
19
+ downloadExportedFile,
20
+ downloadHTMLAsImage,
21
+ withLoadingToast,
22
+ } from "@/utils/download";
23
+ import { Filenames } from "@/utils/filenames";
24
+ import { Logger } from "@/utils/Logger";
25
+ import { Paths } from "@/utils/paths";
26
+ import { getExportCommand } from "./export-command";
27
+ import { exportNotebook } from "./export-notebook";
28
+ import { FORMAT_DEFINITIONS, type UpdateExportOptions } from "./format-options";
29
+ import {
30
+ EXPORT_FORMATS,
31
+ type ExportFormat,
32
+ type ExportOptions,
33
+ exportOptionsAtom,
34
+ getExportFormatStatus,
35
+ isBrowserPrintExport,
36
+ isExportFormat,
37
+ lastExportFormatAtom,
38
+ } from "./state";
39
+
40
+ type ExportAvailability = Parameters<
41
+ typeof getExportFormatStatus
42
+ >[0]["availability"];
43
+
44
+ const SCRIPT_EXPORT_COPY: Record<
45
+ ExportOptions["script"]["type"],
46
+ { actionLabel: string; progressLabel: string }
47
+ > = {
48
+ source: {
49
+ actionLabel: "Export notebook source",
50
+ progressLabel: "notebook source",
51
+ },
52
+ flat: {
53
+ actionLabel: "Export flat script",
54
+ progressLabel: "flat script",
55
+ },
56
+ };
57
+
58
+ function getExportAvailability(
59
+ runtime: "server" | "wasm",
60
+ request: AsyncDataResult<ExportAvailabilityResponse | null>,
61
+ ): ExportAvailability {
62
+ if (runtime === "wasm") {
63
+ return { status: "success", data: null };
64
+ }
65
+
66
+ switch (request.status) {
67
+ case "pending":
68
+ case "loading":
69
+ return { status: "pending" };
70
+ case "error":
71
+ return { status: "error" };
72
+ case "success":
73
+ return { status: "success", data: request.data };
74
+ }
75
+ }
76
+
77
+ function getSourceFilename(
78
+ runtime: "server" | "wasm",
79
+ filename: string | null,
80
+ ): string {
81
+ if (runtime === "server" && filename) {
82
+ return Paths.basename(filename);
83
+ }
84
+ return Filenames.toPY(document.title);
85
+ }
86
+
87
+ function getFooterDescription({
88
+ usesBrowserPrint,
89
+ hasCommand,
90
+ format,
91
+ isNotebookSource,
92
+ }: {
93
+ usesBrowserPrint: boolean;
94
+ hasCommand: boolean;
95
+ format: ExportFormat;
96
+ isNotebookSource: boolean;
97
+ }): string | null {
98
+ if (usesBrowserPrint) {
99
+ return "Uses the browser's print settings for page size and filename.";
100
+ }
101
+ if (hasCommand) {
102
+ return "Uses the current session state. The copied command exports the saved notebook.";
103
+ }
104
+ if (format !== "png" && !isNotebookSource) {
105
+ return "Save the notebook to copy an equivalent shell command.";
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function useExportDialogState(initialFormat?: ExportFormat) {
111
+ const filename = useFilename();
112
+ const [options, setOptions] = useAtom(exportOptionsAtom);
113
+ const [lastFormat, setLastFormat] = useAtom(lastExportFormatAtom);
114
+ const [format, setFormat] = useState<ExportFormat>(
115
+ initialFormat ?? lastFormat,
116
+ );
117
+ const runtime = isWasm() ? "wasm" : "server";
118
+ const requests = useRequestClient();
119
+
120
+ const availabilityRequest =
121
+ useAsyncData<ExportAvailabilityResponse | null>(async () => {
122
+ if (runtime === "wasm") {
123
+ return null;
124
+ }
125
+ return requests.getExportAvailability();
126
+ }, [runtime, requests]);
127
+ const availability = getExportAvailability(runtime, availabilityRequest);
128
+
129
+ const statusFor = (candidate: ExportFormat) =>
130
+ getExportFormatStatus({
131
+ format: candidate,
132
+ options,
133
+ runtime,
134
+ filename,
135
+ availability,
136
+ });
137
+ const status = statusFor(format);
138
+ const formats = EXPORT_FORMATS.map((candidate) => ({
139
+ format: candidate,
140
+ status: statusFor(candidate),
141
+ }));
142
+ const usesBrowserPrint = isBrowserPrintExport(runtime, format);
143
+ const definition = FORMAT_DEFINITIONS[format];
144
+ const isNotebookSource =
145
+ format === "script" && options.script.type === "source";
146
+ const { actionLabel, progressLabel } =
147
+ format === "script"
148
+ ? SCRIPT_EXPORT_COPY[options.script.type]
149
+ : {
150
+ actionLabel: definition.actionLabel,
151
+ progressLabel: definition.label,
152
+ };
153
+ const command = usesBrowserPrint
154
+ ? null
155
+ : getExportCommand({ format, filename, options });
156
+ const footerDescription = getFooterDescription({
157
+ usesBrowserPrint,
158
+ hasCommand: Boolean(command),
159
+ format,
160
+ isNotebookSource,
161
+ });
162
+
163
+ useEffect(() => {
164
+ if (initialFormat) {
165
+ setLastFormat(initialFormat);
166
+ }
167
+ }, [initialFormat, setLastFormat]);
168
+
169
+ const selectFormat = (value: string) => {
170
+ if (isExportFormat(value)) {
171
+ setFormat(value);
172
+ setLastFormat(value);
173
+ }
174
+ };
175
+
176
+ const updateOptions: UpdateExportOptions = (optionFormat, nextOptions) => {
177
+ setOptions((current) => {
178
+ const next = { ...current };
179
+ next[optionFormat] = {
180
+ ...current[optionFormat],
181
+ ...nextOptions,
182
+ };
183
+ return next;
184
+ });
185
+ };
186
+
187
+ return {
188
+ formats,
189
+ options,
190
+ selected: {
191
+ format,
192
+ status,
193
+ usesBrowserPrint,
194
+ actionLabel,
195
+ command,
196
+ footerDescription,
197
+ },
198
+ exportRequest: {
199
+ format,
200
+ options,
201
+ sourceFilename: getSourceFilename(runtime, filename),
202
+ available: status.available,
203
+ usesBrowserPrint,
204
+ progressLabel,
205
+ },
206
+ selectFormat,
207
+ updateOptions,
208
+ };
209
+ }
210
+
211
+ async function captureCurrentAppView(
212
+ dialogContainer: HTMLElement | null,
213
+ ): Promise<void> {
214
+ const app = document.getElementById("App");
215
+ if (!app) {
216
+ const message = "The current app view could not be captured.";
217
+ toast({
218
+ title: "Failed to download as PNG",
219
+ description: message,
220
+ variant: "danger",
221
+ });
222
+ throw new Error(message);
223
+ }
224
+
225
+ const previousVisibility = dialogContainer?.style.visibility;
226
+ const wasCaptureExcluded =
227
+ dialogContainer?.classList.contains("print:hidden");
228
+ const downloaded = await downloadHTMLAsImage({
229
+ element: app,
230
+ filename: document.title,
231
+ prepare: () => {
232
+ const cleanupPrinting = ADD_PRINTING_CLASS();
233
+ if (dialogContainer) {
234
+ dialogContainer.classList.add("print:hidden");
235
+ dialogContainer.style.visibility = "hidden";
236
+ }
237
+ return () => {
238
+ cleanupPrinting();
239
+ if (dialogContainer) {
240
+ if (!wasCaptureExcluded) {
241
+ dialogContainer.classList.remove("print:hidden");
242
+ }
243
+ dialogContainer.style.visibility = previousVisibility ?? "";
244
+ }
245
+ };
246
+ },
247
+ });
248
+ if (!downloaded) {
249
+ throw new Error("Failed to capture the current app view.");
250
+ }
251
+ }
252
+
253
+ function printCurrentView(onClose: () => void): void {
254
+ onClose();
255
+ requestAnimationFrame(() => {
256
+ setTimeout(() => {
257
+ window.print();
258
+ }, 0);
259
+ });
260
+ }
261
+
262
+ function useExportDialogAction({
263
+ format,
264
+ options,
265
+ sourceFilename,
266
+ available,
267
+ usesBrowserPrint,
268
+ progressLabel,
269
+ onClose,
270
+ }: {
271
+ format: ExportFormat;
272
+ options: ExportOptions;
273
+ sourceFilename: string;
274
+ available: boolean;
275
+ usesBrowserPrint: boolean;
276
+ progressLabel: string;
277
+ onClose: () => void;
278
+ }) {
279
+ const requests = useRequestClient();
280
+ const takeScreenshots = useEnrichCellOutputs();
281
+ const [isExporting, setIsExporting] = useState(false);
282
+ const dialogRef = useRef<HTMLDivElement>(null);
283
+ const mountedRef = useRef(true);
284
+
285
+ useEffect(() => {
286
+ mountedRef.current = true;
287
+ return () => {
288
+ mountedRef.current = false;
289
+ };
290
+ }, []);
291
+
292
+ const captureOutputs = async (
293
+ progress: Parameters<typeof takeScreenshots>[0]["progress"],
294
+ ) => {
295
+ await updateCellOutputsWithScreenshots({
296
+ takeScreenshots: () => takeScreenshots({ progress }),
297
+ updateCellOutputs: requests.updateCellOutputs,
298
+ });
299
+ };
300
+
301
+ const capturePNG = async () => {
302
+ const capture = () =>
303
+ captureCurrentAppView(dialogRef.current?.parentElement ?? null);
304
+ await runDuringPresentMode(capture);
305
+ };
306
+
307
+ const submit = async () => {
308
+ if (!available || isExporting) {
309
+ return;
310
+ }
311
+ if (usesBrowserPrint) {
312
+ printCurrentView(onClose);
313
+ return;
314
+ }
315
+
316
+ setIsExporting(true);
317
+ try {
318
+ await withLoadingToast(
319
+ `Exporting ${progressLabel}…`,
320
+ async (progress) => {
321
+ await exportNotebook({
322
+ format,
323
+ options,
324
+ requests,
325
+ sourceFilename,
326
+ htmlFiles: VirtualFileTracker.INSTANCE.filenames(),
327
+ captureOutputs: () => captureOutputs(progress),
328
+ capturePNG,
329
+ downloadFile: downloadExportedFile,
330
+ });
331
+ },
332
+ );
333
+ if (mountedRef.current) {
334
+ onClose();
335
+ }
336
+ } catch (error) {
337
+ // Most helpers toast actionable errors, but not all (e.g. updateCellOutputs).
338
+ Logger.error("Export failed", error);
339
+ } finally {
340
+ if (mountedRef.current) {
341
+ setIsExporting(false);
342
+ }
343
+ }
344
+ };
345
+
346
+ return { dialogRef, isExporting, submit };
347
+ }
348
+
349
+ export function useExportDialog({
350
+ initialFormat,
351
+ onClose,
352
+ }: {
353
+ initialFormat?: ExportFormat;
354
+ onClose: () => void;
355
+ }) {
356
+ const { exportRequest, selectFormat, ...state } =
357
+ useExportDialogState(initialFormat);
358
+ const action = useExportDialogAction({
359
+ ...exportRequest,
360
+ onClose,
361
+ });
362
+
363
+ return {
364
+ ...state,
365
+ ...action,
366
+ selectFormat: (value: string) => {
367
+ if (!action.isExporting) {
368
+ selectFormat(value);
369
+ }
370
+ },
371
+ };
372
+ }