@marimo-team/islands 0.19.5-dev7 → 0.19.5

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 (54) hide show
  1. package/dist/{ConnectedDataExplorerComponent-D5KcOOzu.js → ConnectedDataExplorerComponent-Dr9GhOQj.js} +13 -13
  2. package/dist/assets/__vite-browser-external-DRa9CT_O.js +1 -0
  3. package/dist/assets/{worker-BR7KVExK.js → worker-SqntmiwV.js} +2 -2
  4. package/dist/{glide-data-editor-DsVDCmV2.js → glide-data-editor-zEomQJ3U.js} +2 -2
  5. package/dist/main.js +278 -234
  6. package/dist/{mermaid-DZjjc-kI.js → mermaid-D7wtYc6C.js} +2 -2
  7. package/dist/{spec-B1PGDiGh.js → spec-Cif4tBMJ.js} +1 -1
  8. package/dist/style.css +1 -1
  9. package/dist/{types-CbQF8CBX.js → types-BQOP2pRy.js} +1 -1
  10. package/dist/{useAsyncData-TLXJC7yx.js → useAsyncData-kqbhbSuf.js} +1 -1
  11. package/dist/{useDeepCompareMemoize-DVnEG7jx.js → useDeepCompareMemoize-B2QEm3jo.js} +1 -1
  12. package/dist/{useTheme-BllQjRdW.js → useTheme-CVr6Gb_R.js} +4 -1
  13. package/dist/{vega-component-B2QrGnW8.js → vega-component-DAeU1_cV.js} +3 -3
  14. package/package.json +1 -1
  15. package/src/__mocks__/requests.ts +1 -0
  16. package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
  17. package/src/components/ai/ai-utils.ts +101 -0
  18. package/src/components/app-config/ai-config.tsx +56 -16
  19. package/src/components/app-config/user-config-form.tsx +63 -1
  20. package/src/components/chat/chat-panel.tsx +4 -4
  21. package/src/components/data-table/cell-utils.ts +10 -0
  22. package/src/components/editor/Output.tsx +21 -14
  23. package/src/components/editor/actions/useCellActionButton.tsx +2 -2
  24. package/src/components/editor/actions/useNotebookActions.tsx +61 -21
  25. package/src/components/editor/cell/cell-actions.tsx +6 -1
  26. package/src/components/editor/controls/Controls.tsx +1 -8
  27. package/src/components/editor/file-tree/file-explorer.tsx +4 -2
  28. package/src/components/editor/file-tree/file-viewer.tsx +9 -6
  29. package/src/components/editor/navigation/navigation.ts +39 -1
  30. package/src/components/editor/renderMimeIcon.tsx +2 -0
  31. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
  32. package/src/core/ai/model-registry.ts +21 -3
  33. package/src/core/codemirror/language/panel/panel.tsx +3 -0
  34. package/src/core/codemirror/language/panel/sql.tsx +6 -2
  35. package/src/core/config/config-schema.ts +5 -1
  36. package/src/core/config/config.ts +4 -0
  37. package/src/core/config/feature-flag.tsx +2 -0
  38. package/src/core/export/__tests__/hooks.test.ts +120 -1
  39. package/src/core/export/hooks.ts +48 -18
  40. package/src/core/islands/bridge.ts +1 -0
  41. package/src/core/lsp/__tests__/transport.test.ts +149 -0
  42. package/src/core/lsp/transport.ts +48 -0
  43. package/src/core/network/requests-lazy.ts +1 -0
  44. package/src/core/network/requests-network.ts +9 -0
  45. package/src/core/network/requests-static.ts +1 -0
  46. package/src/core/network/requests-toasting.tsx +1 -0
  47. package/src/core/network/types.ts +2 -0
  48. package/src/core/wasm/bridge.ts +1 -0
  49. package/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx +1 -1
  50. package/src/utils/__tests__/download.test.tsx +492 -0
  51. package/src/utils/download.ts +161 -6
  52. package/src/utils/filenames.ts +3 -0
  53. package/dist/assets/__vite-browser-external-CgHmDpAZ.js +0 -1
  54. package/src/components/export/export-output-button.tsx +0 -14
@@ -214,7 +214,7 @@ export const DataExplorerComponent = ({
214
214
  <VegaEmbed
215
215
  options={chartOptions(theme)}
216
216
  key={idx}
217
- spec={plot.spec}
217
+ spec={augmentSpecWithData(plot.spec, chartData)}
218
218
  />
219
219
  </HorizontalCarouselItem>
220
220
  ))}
@@ -0,0 +1,492 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { CellId } from "@/core/cells/ids";
4
+ import { CellOutputId } from "@/core/cells/ids";
5
+ import {
6
+ downloadByURL,
7
+ downloadCellOutputAsImage,
8
+ downloadHTMLAsImage,
9
+ getImageDataUrlForCell,
10
+ withLoadingToast,
11
+ } from "../download";
12
+
13
+ // Mock html-to-image
14
+ vi.mock("html-to-image", () => ({
15
+ toPng: vi.fn(),
16
+ }));
17
+
18
+ // Mock the toast module
19
+ const mockDismiss = vi.fn();
20
+ vi.mock("@/components/ui/use-toast", () => ({
21
+ toast: vi.fn(() => ({
22
+ dismiss: mockDismiss,
23
+ })),
24
+ }));
25
+
26
+ // Mock the Spinner component
27
+ vi.mock("@/components/icons/spinner", () => ({
28
+ Spinner: () => "MockSpinner",
29
+ }));
30
+
31
+ // Mock Logger
32
+ vi.mock("@/utils/Logger", () => ({
33
+ Logger: {
34
+ error: vi.fn(),
35
+ },
36
+ }));
37
+
38
+ // Mock Filenames
39
+ vi.mock("@/utils/filenames", () => ({
40
+ Filenames: {
41
+ toPNG: (name: string) => `${name}.png`,
42
+ },
43
+ }));
44
+
45
+ import { toPng } from "html-to-image";
46
+ import { toast } from "@/components/ui/use-toast";
47
+ import { Logger } from "@/utils/Logger";
48
+
49
+ describe("withLoadingToast", () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ it("should show a loading toast and dismiss on success", async () => {
55
+ const result = await withLoadingToast("Loading...", async () => {
56
+ return "success";
57
+ });
58
+
59
+ expect(toast).toHaveBeenCalledTimes(1);
60
+ expect(toast).toHaveBeenCalledWith({
61
+ title: "Loading...",
62
+ duration: Infinity,
63
+ });
64
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
65
+ expect(result).toBe("success");
66
+ });
67
+
68
+ it("should dismiss toast and rethrow on error", async () => {
69
+ const error = new Error("Operation failed");
70
+
71
+ await expect(
72
+ withLoadingToast("Loading...", async () => {
73
+ throw error;
74
+ }),
75
+ ).rejects.toThrow("Operation failed");
76
+
77
+ expect(toast).toHaveBeenCalledTimes(1);
78
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it("should return the value from the async function", async () => {
82
+ const expectedValue = { data: "test", count: 42 };
83
+
84
+ const result = await withLoadingToast("Processing...", async () => {
85
+ return expectedValue;
86
+ });
87
+
88
+ expect(result).toEqual(expectedValue);
89
+ });
90
+
91
+ it("should handle void functions", async () => {
92
+ let sideEffect = false;
93
+
94
+ await withLoadingToast("Saving...", async () => {
95
+ sideEffect = true;
96
+ });
97
+
98
+ expect(sideEffect).toBe(true);
99
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ it("should use the provided title in the toast", async () => {
103
+ const customTitle = "Downloading PDF...";
104
+
105
+ await withLoadingToast(customTitle, async () => "done");
106
+
107
+ expect(toast).toHaveBeenCalledWith(
108
+ expect.objectContaining({
109
+ title: customTitle,
110
+ }),
111
+ );
112
+ });
113
+
114
+ it("should wait for the async function to complete", async () => {
115
+ const events: string[] = [];
116
+
117
+ await withLoadingToast("Loading...", async () => {
118
+ events.push("start");
119
+ await new Promise((resolve) => setTimeout(resolve, 10));
120
+ events.push("end");
121
+ });
122
+
123
+ expect(events).toEqual(["start", "end"]);
124
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
125
+ });
126
+ });
127
+
128
+ describe("getImageDataUrlForCell", () => {
129
+ const mockDataUrl = "data:image/png;base64,mockbase64data";
130
+ let mockElement: HTMLElement;
131
+
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ mockElement = document.createElement("div");
135
+ mockElement.id = CellOutputId.create("cell-1" as CellId);
136
+ document.body.append(mockElement);
137
+ });
138
+
139
+ afterEach(() => {
140
+ mockElement.remove();
141
+ });
142
+
143
+ it("should return undefined if element is not found", async () => {
144
+ const result = await getImageDataUrlForCell("nonexistent" as CellId);
145
+
146
+ expect(result).toBeUndefined();
147
+ expect(Logger.error).toHaveBeenCalledWith(
148
+ "Output element not found for cell nonexistent",
149
+ );
150
+ });
151
+
152
+ it("should capture screenshot and return data URL", async () => {
153
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
154
+
155
+ const result = await getImageDataUrlForCell("cell-1" as CellId);
156
+
157
+ expect(result).toBe(mockDataUrl);
158
+ expect(toPng).toHaveBeenCalledWith(mockElement);
159
+ });
160
+
161
+ it("should add printing classes before capture", async () => {
162
+ vi.mocked(toPng).mockImplementation(async () => {
163
+ // Check classes are applied during capture
164
+ expect(mockElement.classList.contains("printing-output")).toBe(true);
165
+ expect(document.body.classList.contains("printing")).toBe(true);
166
+ expect(mockElement.style.overflow).toBe("auto");
167
+ return mockDataUrl;
168
+ });
169
+
170
+ await getImageDataUrlForCell("cell-1" as CellId);
171
+ });
172
+
173
+ it("should remove printing classes after capture", async () => {
174
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
175
+
176
+ await getImageDataUrlForCell("cell-1" as CellId);
177
+
178
+ expect(mockElement.classList.contains("printing-output")).toBe(false);
179
+ expect(document.body.classList.contains("printing")).toBe(false);
180
+ });
181
+
182
+ it("should restore original overflow style after capture", async () => {
183
+ mockElement.style.overflow = "hidden";
184
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
185
+
186
+ await getImageDataUrlForCell("cell-1" as CellId);
187
+
188
+ expect(mockElement.style.overflow).toBe("hidden");
189
+ });
190
+
191
+ it("should throw error on failure", async () => {
192
+ vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
193
+
194
+ await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow(
195
+ "Capture failed",
196
+ );
197
+ });
198
+
199
+ it("should cleanup even on failure", async () => {
200
+ mockElement.style.overflow = "scroll";
201
+ vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
202
+
203
+ await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow();
204
+
205
+ expect(mockElement.classList.contains("printing-output")).toBe(false);
206
+ expect(document.body.classList.contains("printing")).toBe(false);
207
+ expect(mockElement.style.overflow).toBe("scroll");
208
+ });
209
+
210
+ it("should maintain body.printing during concurrent captures", async () => {
211
+ // Create a second element
212
+ const mockElement2 = document.createElement("div");
213
+ mockElement2.id = CellOutputId.create("cell-2" as CellId);
214
+ document.body.append(mockElement2);
215
+
216
+ // Track body.printing state during each capture
217
+ const printingStateDuringCaptures: boolean[] = [];
218
+ let resolveFirst: () => void;
219
+ let resolveSecond: () => void;
220
+
221
+ const firstPromise = new Promise<void>((resolve) => {
222
+ resolveFirst = resolve;
223
+ });
224
+ const secondPromise = new Promise<void>((resolve) => {
225
+ resolveSecond = resolve;
226
+ });
227
+
228
+ vi.mocked(toPng).mockImplementation(async (element) => {
229
+ printingStateDuringCaptures.push(
230
+ document.body.classList.contains("printing"),
231
+ );
232
+
233
+ // Simulate async work - first capture takes longer
234
+ await (element.id.includes("cell-1") ? firstPromise : secondPromise);
235
+
236
+ // Check state again after waiting
237
+ printingStateDuringCaptures.push(
238
+ document.body.classList.contains("printing"),
239
+ );
240
+
241
+ return mockDataUrl;
242
+ });
243
+
244
+ // Start both captures concurrently
245
+ const capture1 = getImageDataUrlForCell("cell-1" as CellId);
246
+ const capture2 = getImageDataUrlForCell("cell-2" as CellId);
247
+
248
+ // Let second capture complete first
249
+ resolveSecond!();
250
+ await new Promise((r) => setTimeout(r, 0));
251
+
252
+ // body.printing should still be present because cell-1 is still capturing
253
+ expect(document.body.classList.contains("printing")).toBe(true);
254
+
255
+ // Now let first capture complete
256
+ resolveFirst!();
257
+ await Promise.all([capture1, capture2]);
258
+
259
+ // After all captures complete, body.printing should be removed
260
+ expect(document.body.classList.contains("printing")).toBe(false);
261
+
262
+ // All captures should have seen body.printing = true
263
+ expect(printingStateDuringCaptures.every(Boolean)).toBe(true);
264
+
265
+ mockElement2.remove();
266
+ });
267
+ });
268
+
269
+ describe("downloadHTMLAsImage", () => {
270
+ const mockDataUrl = "data:image/png;base64,mockbase64data";
271
+ let mockElement: HTMLElement;
272
+ let mockAppEl: HTMLElement;
273
+ let mockAnchor: HTMLAnchorElement;
274
+
275
+ beforeEach(() => {
276
+ vi.clearAllMocks();
277
+ mockElement = document.createElement("div");
278
+ mockAppEl = document.createElement("div");
279
+ mockAppEl.id = "App";
280
+ // Mock scrollTo since jsdom doesn't implement it
281
+ mockAppEl.scrollTo = vi.fn();
282
+ document.body.append(mockElement);
283
+ document.body.append(mockAppEl);
284
+
285
+ // Mock anchor element for download
286
+ mockAnchor = document.createElement("a");
287
+ vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
288
+ vi.spyOn(mockAnchor, "click").mockImplementation(() => {
289
+ // <noop></noop>
290
+ });
291
+ vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
292
+ // noop
293
+ });
294
+ });
295
+
296
+ afterEach(() => {
297
+ mockElement.remove();
298
+ mockAppEl.remove();
299
+ vi.restoreAllMocks();
300
+ });
301
+
302
+ it("should download image without prepare function", async () => {
303
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
304
+
305
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" });
306
+
307
+ expect(toPng).toHaveBeenCalledWith(mockElement);
308
+ expect(mockAnchor.href).toBe(mockDataUrl);
309
+ expect(mockAnchor.download).toBe("test.png");
310
+ expect(mockAnchor.click).toHaveBeenCalled();
311
+ });
312
+
313
+ it("should add body.printing class without prepare function", async () => {
314
+ vi.mocked(toPng).mockImplementation(async () => {
315
+ expect(document.body.classList.contains("printing")).toBe(true);
316
+ return mockDataUrl;
317
+ });
318
+
319
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" });
320
+ });
321
+
322
+ it("should remove body.printing class after download without prepare", async () => {
323
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
324
+
325
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" });
326
+
327
+ expect(document.body.classList.contains("printing")).toBe(false);
328
+ });
329
+
330
+ it("should use prepare function when provided", async () => {
331
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
332
+ const cleanup = vi.fn();
333
+ const prepare = vi.fn().mockReturnValue(cleanup);
334
+
335
+ await downloadHTMLAsImage({
336
+ element: mockElement,
337
+ filename: "test",
338
+ prepare,
339
+ });
340
+
341
+ expect(prepare).toHaveBeenCalledWith(mockElement);
342
+ expect(cleanup).toHaveBeenCalled();
343
+ });
344
+
345
+ it("should not add body.printing when prepare is provided", async () => {
346
+ let bodyPrintingDuringCapture = false;
347
+ vi.mocked(toPng).mockImplementation(async () => {
348
+ // Capture the state during toPng execution
349
+ bodyPrintingDuringCapture = document.body.classList.contains("printing");
350
+ return mockDataUrl;
351
+ });
352
+ const cleanup = vi.fn();
353
+ const prepare = vi.fn().mockReturnValue(cleanup);
354
+
355
+ await downloadHTMLAsImage({
356
+ element: mockElement,
357
+ filename: "test",
358
+ prepare,
359
+ });
360
+
361
+ // body.printing should NOT be added by downloadHTMLAsImage when prepare is provided
362
+ // (the prepare function is responsible for managing its own classes)
363
+ expect(bodyPrintingDuringCapture).toBe(false);
364
+ expect(document.body.classList.contains("printing")).toBe(false);
365
+ expect(prepare).toHaveBeenCalledWith(mockElement);
366
+ expect(cleanup).toHaveBeenCalled();
367
+ });
368
+
369
+ it("should show error toast on failure", async () => {
370
+ vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
371
+
372
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" });
373
+
374
+ expect(toast).toHaveBeenCalledWith({
375
+ title: "Error",
376
+ description: "Failed to download as PNG.",
377
+ variant: "danger",
378
+ });
379
+ });
380
+
381
+ it("should cleanup on failure", async () => {
382
+ vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
383
+
384
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" });
385
+
386
+ expect(document.body.classList.contains("printing")).toBe(false);
387
+ });
388
+ });
389
+
390
+ describe("downloadCellOutputAsImage", () => {
391
+ const mockDataUrl = "data:image/png;base64,mockbase64data";
392
+ let mockElement: HTMLElement;
393
+ let mockAppEl: HTMLElement;
394
+ let mockAnchor: HTMLAnchorElement;
395
+
396
+ beforeEach(() => {
397
+ vi.clearAllMocks();
398
+ mockElement = document.createElement("div");
399
+ mockElement.id = CellOutputId.create("cell-1" as CellId);
400
+ mockAppEl = document.createElement("div");
401
+ mockAppEl.id = "App";
402
+ // Mock scrollTo since jsdom doesn't implement it
403
+ mockAppEl.scrollTo = vi.fn();
404
+ document.body.append(mockElement);
405
+ document.body.append(mockAppEl);
406
+
407
+ mockAnchor = document.createElement("a");
408
+ vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
409
+ vi.spyOn(mockAnchor, "click").mockImplementation(() => {
410
+ // <noop></noop>
411
+ });
412
+ vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
413
+ // <noop></noop>
414
+ });
415
+ });
416
+
417
+ afterEach(() => {
418
+ mockElement.remove();
419
+ mockAppEl.remove();
420
+ vi.restoreAllMocks();
421
+ });
422
+
423
+ it("should return early if element not found", async () => {
424
+ await downloadCellOutputAsImage("nonexistent" as CellId, "test");
425
+
426
+ expect(toPng).not.toHaveBeenCalled();
427
+ expect(Logger.error).toHaveBeenCalledWith(
428
+ "Output element not found for cell nonexistent",
429
+ );
430
+ });
431
+
432
+ it("should download cell output as image", async () => {
433
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
434
+
435
+ await downloadCellOutputAsImage("cell-1" as CellId, "result");
436
+
437
+ expect(toPng).toHaveBeenCalledWith(mockElement);
438
+ expect(mockAnchor.download).toBe("result.png");
439
+ });
440
+
441
+ it("should apply cell-specific preparation", async () => {
442
+ vi.mocked(toPng).mockImplementation(async () => {
443
+ // Check that cell-specific classes are applied
444
+ expect(mockElement.classList.contains("printing-output")).toBe(true);
445
+ expect(document.body.classList.contains("printing")).toBe(true);
446
+ expect(mockElement.style.overflow).toBe("auto");
447
+ return mockDataUrl;
448
+ });
449
+
450
+ await downloadCellOutputAsImage("cell-1" as CellId, "result");
451
+ });
452
+
453
+ it("should cleanup after download", async () => {
454
+ mockElement.style.overflow = "visible";
455
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
456
+
457
+ await downloadCellOutputAsImage("cell-1" as CellId, "result");
458
+
459
+ expect(mockElement.classList.contains("printing-output")).toBe(false);
460
+ expect(document.body.classList.contains("printing")).toBe(false);
461
+ expect(mockElement.style.overflow).toBe("visible");
462
+ });
463
+ });
464
+
465
+ describe("downloadByURL", () => {
466
+ let mockAnchor: HTMLAnchorElement;
467
+
468
+ beforeEach(() => {
469
+ mockAnchor = document.createElement("a");
470
+ vi.spyOn(document, "createElement").mockReturnValue(mockAnchor);
471
+ vi.spyOn(mockAnchor, "click").mockImplementation(() => {
472
+ // <noop></noop>
473
+ });
474
+ vi.spyOn(mockAnchor, "remove").mockImplementation(() => {
475
+ // <noop></noop>
476
+ });
477
+ });
478
+
479
+ afterEach(() => {
480
+ vi.restoreAllMocks();
481
+ });
482
+
483
+ it("should create anchor, set attributes, click, and remove", () => {
484
+ downloadByURL("data:test", "filename.png");
485
+
486
+ expect(document.createElement).toHaveBeenCalledWith("a");
487
+ expect(mockAnchor.href).toBe("data:test");
488
+ expect(mockAnchor.download).toBe("filename.png");
489
+ expect(mockAnchor.click).toHaveBeenCalled();
490
+ expect(mockAnchor.remove).toHaveBeenCalled();
491
+ });
492
+ });
@@ -1,17 +1,140 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { toPng } from "html-to-image";
3
3
  import { toast } from "@/components/ui/use-toast";
4
+ import { type CellId, CellOutputId } from "@/core/cells/ids";
5
+ import { getRequestClient } from "@/core/network/requests";
4
6
  import { Filenames } from "@/utils/filenames";
7
+ import { Paths } from "@/utils/paths";
8
+ import { prettyError } from "./errors";
9
+ import { Logger } from "./Logger";
5
10
 
6
- export async function downloadHTMLAsImage(
7
- element: HTMLElement,
11
+ /**
12
+ * Show a loading toast while an async operation is in progress.
13
+ * Automatically dismisses the toast when the operation completes or fails.
14
+ */
15
+ export async function withLoadingToast<T>(
16
+ title: string,
17
+ fn: () => Promise<T>,
18
+ ): Promise<T> {
19
+ const loadingToast = toast({
20
+ title,
21
+ duration: Infinity,
22
+ });
23
+ try {
24
+ const result = await fn();
25
+ loadingToast.dismiss();
26
+ return result;
27
+ } catch (error) {
28
+ loadingToast.dismiss();
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ function findElementForCell(cellId: CellId): HTMLElement | undefined {
34
+ const element = document.getElementById(CellOutputId.create(cellId));
35
+ if (!element) {
36
+ Logger.error(`Output element not found for cell ${cellId}`);
37
+ return;
38
+ }
39
+ return element;
40
+ }
41
+
42
+ /**
43
+ * Reference counter for body.printing class to handle concurrent screenshot captures.
44
+ * Only adds the class when count goes 0→1, only removes when count goes 1→0.
45
+ */
46
+ let bodyPrintingRefCount = 0;
47
+
48
+ function acquireBodyPrinting() {
49
+ bodyPrintingRefCount++;
50
+ if (bodyPrintingRefCount === 1) {
51
+ document.body.classList.add("printing");
52
+ }
53
+ }
54
+
55
+ function releaseBodyPrinting() {
56
+ bodyPrintingRefCount--;
57
+ if (bodyPrintingRefCount === 0) {
58
+ document.body.classList.remove("printing");
59
+ }
60
+ }
61
+
62
+ /*
63
+ * Prepare a cell element for screenshot capture.
64
+ * Returns a cleanup function that should be called when the screenshot is complete.
65
+ */
66
+ function prepareCellElementForScreenshot(element: HTMLElement) {
67
+ element.classList.add("printing-output");
68
+ acquireBodyPrinting();
69
+ const originalOverflow = element.style.overflow;
70
+ element.style.overflow = "auto";
71
+
72
+ return () => {
73
+ element.classList.remove("printing-output");
74
+ releaseBodyPrinting();
75
+ element.style.overflow = originalOverflow;
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Capture a cell output as a PNG data URL.
81
+ */
82
+ export async function getImageDataUrlForCell(
83
+ cellId: CellId,
84
+ ): Promise<string | undefined> {
85
+ const element = findElementForCell(cellId);
86
+ if (!element) {
87
+ return;
88
+ }
89
+ const cleanup = prepareCellElementForScreenshot(element);
90
+
91
+ try {
92
+ return await toPng(element);
93
+ } finally {
94
+ cleanup();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Download a cell output as a PNG image file.
100
+ */
101
+ export async function downloadCellOutputAsImage(
102
+ cellId: CellId,
8
103
  filename: string,
9
104
  ) {
105
+ const element = findElementForCell(cellId);
106
+ if (!element) {
107
+ return;
108
+ }
109
+
110
+ await downloadHTMLAsImage({
111
+ element,
112
+ filename,
113
+ prepare: prepareCellElementForScreenshot,
114
+ });
115
+ }
116
+
117
+ export async function downloadHTMLAsImage(opts: {
118
+ element: HTMLElement;
119
+ filename: string;
120
+ prepare?: (element: HTMLElement) => () => void;
121
+ }) {
122
+ const { element, filename, prepare } = opts;
123
+
10
124
  // Capture current scroll position
11
125
  const appEl = document.getElementById("App");
12
126
  const currentScrollY = appEl?.scrollTop ?? 0;
13
- // Add classnames for printing
14
- document.body.classList.add("printing");
127
+
128
+ let cleanup: (() => void) | undefined;
129
+ if (prepare) {
130
+ // Let the prepare function handle adding classes (e.g., body.printing)
131
+ cleanup = prepare(element);
132
+ } else {
133
+ // When no prepare function is provided (e.g., downloading full notebook),
134
+ // add body.printing ourselves
135
+ document.body.classList.add("printing");
136
+ }
137
+
15
138
  try {
16
139
  // Get screenshot
17
140
  const dataUrl = await toPng(element);
@@ -23,8 +146,10 @@ export async function downloadHTMLAsImage(
23
146
  variant: "danger",
24
147
  });
25
148
  } finally {
26
- // Remove classnames for printing
27
- document.body.classList.remove("printing");
149
+ cleanup?.();
150
+ if (document.body.classList.contains("printing")) {
151
+ document.body.classList.remove("printing");
152
+ }
28
153
  // Restore scroll position
29
154
  requestAnimationFrame(() => {
30
155
  appEl?.scrollTo(0, currentScrollY);
@@ -45,3 +170,33 @@ export function downloadBlob(blob: Blob, filename: string) {
45
170
  downloadByURL(url, filename);
46
171
  URL.revokeObjectURL(url);
47
172
  }
173
+
174
+ /**
175
+ * Download the current notebook as a PDF file.
176
+ *
177
+ * WebPDF only requires Chromium to be installed.
178
+ * Standard PDF requires Pandoc & TeX (~few GBs) but is of higher quality.
179
+ */
180
+ export async function downloadAsPDF(opts: {
181
+ filename: string;
182
+ webpdf: boolean;
183
+ }) {
184
+ const client = getRequestClient();
185
+ const { filename, webpdf } = opts;
186
+
187
+ try {
188
+ const pdfBlob = await client.exportAsPDF({
189
+ webpdf,
190
+ });
191
+
192
+ const filenameWithoutPath = Paths.basename(filename);
193
+ downloadBlob(pdfBlob, Filenames.toPDF(filenameWithoutPath));
194
+ } catch (error) {
195
+ toast({
196
+ title: "Failed to download",
197
+ description: prettyError(error),
198
+ variant: "danger",
199
+ });
200
+ throw error;
201
+ }
202
+ }
@@ -9,6 +9,9 @@ export const Filenames = {
9
9
  toPNG: (filename: string): string => {
10
10
  return Filenames.replace(filename, "png");
11
11
  },
12
+ toPDF: (filename: string): string => {
13
+ return Filenames.replace(filename, "pdf");
14
+ },
12
15
  toPY: (filename: string): string => {
13
16
  return Filenames.replace(filename, "py");
14
17
  },
@@ -1 +0,0 @@
1
- import{t as e}from"./worker-BR7KVExK.js";var t=e(((e,t)=>{t.exports={}}));export default t();