@marimo-team/islands 0.19.5-dev4 → 0.19.5-dev40

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 (48) hide show
  1. package/dist/{ConnectedDataExplorerComponent-D5KcOOzu.js → ConnectedDataExplorerComponent-DjQ_E5BA.js} +3 -3
  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 +280 -237
  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/app-config/user-config-form.tsx +34 -1
  17. package/src/components/chat/chat-panel.tsx +1 -1
  18. package/src/components/data-table/cell-utils.ts +10 -0
  19. package/src/components/editor/Output.tsx +21 -14
  20. package/src/components/editor/actions/useNotebookActions.tsx +44 -12
  21. package/src/components/editor/cell/cell-actions.tsx +6 -1
  22. package/src/components/editor/controls/Controls.tsx +1 -8
  23. package/src/components/editor/file-tree/file-explorer.tsx +4 -2
  24. package/src/components/editor/file-tree/file-viewer.tsx +9 -6
  25. package/src/components/editor/navigation/navigation.ts +39 -1
  26. package/src/components/editor/renderMimeIcon.tsx +2 -0
  27. package/src/core/codemirror/language/panel/panel.tsx +3 -0
  28. package/src/core/codemirror/language/panel/sql.tsx +6 -2
  29. package/src/core/config/config-schema.ts +5 -1
  30. package/src/core/config/config.ts +4 -0
  31. package/src/core/config/feature-flag.tsx +2 -0
  32. package/src/core/export/__tests__/hooks.test.ts +120 -1
  33. package/src/core/export/hooks.ts +41 -7
  34. package/src/core/islands/bridge.ts +1 -0
  35. package/src/core/lsp/__tests__/transport.test.ts +149 -0
  36. package/src/core/lsp/transport.ts +48 -0
  37. package/src/core/network/requests-lazy.ts +1 -0
  38. package/src/core/network/requests-network.ts +9 -0
  39. package/src/core/network/requests-static.ts +1 -0
  40. package/src/core/network/requests-toasting.tsx +1 -0
  41. package/src/core/network/types.ts +2 -0
  42. package/src/core/wasm/bridge.ts +1 -0
  43. package/src/css/app/Cell.css +0 -2
  44. package/src/plugins/layout/TexPlugin.tsx +7 -5
  45. package/src/utils/__tests__/download.test.tsx +96 -0
  46. package/src/utils/download.ts +54 -0
  47. package/src/utils/filenames.ts +3 -0
  48. package/dist/assets/__vite-browser-external-CgHmDpAZ.js +0 -1
@@ -125,3 +125,7 @@ export const snippetsEnabledAtom = atom<boolean>((get) => {
125
125
  const includeDefaultSnippets = config.snippets?.include_default_snippets;
126
126
  return customPaths.length > 0 || includeDefaultSnippets === true;
127
127
  });
128
+
129
+ export const disableFileDownloadsAtom = atom<boolean>((get) => {
130
+ return get(resolvedMarimoConfigAtom).server?.disable_file_downloads ?? false;
131
+ });
@@ -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 { useEnrichCellOutputs } from "../hooks";
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
+ });
@@ -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
- const cellsToOutput = await takeScreenshots();
64
- if (Object.keys(cellsToOutput).length > 0) {
65
- await updateCellOutputs({
66
- cellIdsToOutput: cellsToOutput,
67
- });
68
- }
66
+ await updateCellOutputsWithScreenshots(
67
+ takeScreenshots,
68
+ updateCellOutputs,
69
+ );
69
70
  await autoExportAsIPYNB({
70
71
  download: false,
71
72
  });
@@ -85,6 +86,15 @@ export function useAutoExport() {
85
86
  // We track cells that need screenshots, these will be exported to IPYNB
86
87
  const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
87
88
 
89
+ // MIME types to capture screenshots for
90
+ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
91
+ "text/html",
92
+ "application/vnd.vegalite.v5+json",
93
+ "application/vnd.vega.v5+json",
94
+ "application/vnd.vegalite.v6+json",
95
+ "application/vnd.vega.v6+json",
96
+ ]);
97
+
88
98
  /**
89
99
  * Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
90
100
  * @returns A map of cell IDs to their screenshots data.
@@ -103,7 +113,8 @@ export function useEnrichCellOutputs() {
103
113
  // Track latest output for this cell
104
114
  trackedCellsOutput[cellId] = outputData;
105
115
  if (
106
- runtime.output?.mimetype === "text/html" &&
116
+ runtime.output?.mimetype &&
117
+ MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
107
118
  outputData &&
108
119
  outputHasChanged
109
120
  ) {
@@ -148,3 +159,26 @@ export function useEnrichCellOutputs() {
148
159
  );
149
160
  };
150
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;
@@ -41,6 +41,8 @@ describe("ReconnectingWebSocketTransport", () => {
41
41
  this.connect = vi.fn().mockResolvedValue(undefined);
42
42
  this.close = vi.fn();
43
43
  this.sendData = vi.fn().mockResolvedValue({ result: "success" });
44
+ this.subscribe = vi.fn();
45
+ this.unsubscribe = vi.fn();
44
46
  });
45
47
  });
46
48
 
@@ -287,4 +289,151 @@ describe("ReconnectingWebSocketTransport", () => {
287
289
  "Reconnect callback failed",
288
290
  );
289
291
  });
292
+
293
+ describe("subscribe", () => {
294
+ it("should track subscriptions", () => {
295
+ const getWsUrl = vi.fn(() => mockWsUrl);
296
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
297
+
298
+ const handler = vi.fn();
299
+ transport.subscribe("notification", handler);
300
+
301
+ expect((transport as any).pendingSubscriptions).toHaveLength(1);
302
+ expect((transport as any).pendingSubscriptions[0]).toEqual({
303
+ event: "notification",
304
+ handler,
305
+ });
306
+ });
307
+
308
+ it("should register handler on delegate if it exists", async () => {
309
+ const getWsUrl = vi.fn(() => mockWsUrl);
310
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
311
+
312
+ await transport.connect();
313
+
314
+ const handler = vi.fn();
315
+ transport.subscribe("notification", handler);
316
+
317
+ const delegate = (transport as any).delegate;
318
+ expect(delegate.subscribe).toHaveBeenCalledWith("notification", handler);
319
+ });
320
+
321
+ it("should register pending subscriptions when delegate is created", async () => {
322
+ const getWsUrl = vi.fn(() => mockWsUrl);
323
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
324
+
325
+ const handler1 = vi.fn();
326
+ const handler2 = vi.fn();
327
+ transport.subscribe("notification", handler1);
328
+ transport.subscribe("response", handler2);
329
+
330
+ await transport.connect();
331
+
332
+ const delegate = (transport as any).delegate;
333
+ expect(delegate.subscribe).toHaveBeenCalledWith("notification", handler1);
334
+ expect(delegate.subscribe).toHaveBeenCalledWith("response", handler2);
335
+ });
336
+
337
+ it("should re-register subscriptions on reconnection", async () => {
338
+ const getWsUrl = vi.fn(() => mockWsUrl);
339
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
340
+
341
+ // Add subscription before connection
342
+ const handler = vi.fn();
343
+ transport.subscribe("notification", handler);
344
+
345
+ // First connection
346
+ await transport.connect();
347
+ const firstDelegate = (transport as any).delegate;
348
+ expect(firstDelegate.subscribe).toHaveBeenCalledWith(
349
+ "notification",
350
+ handler,
351
+ );
352
+
353
+ // Clear mock calls
354
+ firstDelegate.subscribe.mockClear();
355
+
356
+ // Simulate connection loss
357
+ mockConnection.readyState = WebSocket.CLOSED;
358
+
359
+ // Reconnect by sending data
360
+ const data: any = { method: "test", params: [] };
361
+ await transport.sendData(data, 5000);
362
+
363
+ // New delegate should have been created
364
+ const secondDelegate = (transport as any).delegate;
365
+ expect(secondDelegate).not.toBe(firstDelegate);
366
+
367
+ // Subscription should be re-registered on new delegate
368
+ expect(secondDelegate.subscribe).toHaveBeenCalledWith(
369
+ "notification",
370
+ handler,
371
+ );
372
+ });
373
+ });
374
+
375
+ describe("unsubscribe", () => {
376
+ it("should remove subscription from tracking", () => {
377
+ const getWsUrl = vi.fn(() => mockWsUrl);
378
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
379
+
380
+ const handler = vi.fn();
381
+ transport.subscribe("notification", handler);
382
+ expect((transport as any).pendingSubscriptions).toHaveLength(1);
383
+
384
+ transport.unsubscribe("notification", handler);
385
+ expect((transport as any).pendingSubscriptions).toHaveLength(0);
386
+ });
387
+
388
+ it("should unregister from delegate if it exists", async () => {
389
+ const getWsUrl = vi.fn(() => mockWsUrl);
390
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
391
+
392
+ await transport.connect();
393
+
394
+ const handler = vi.fn();
395
+ transport.subscribe("notification", handler);
396
+
397
+ const delegate = (transport as any).delegate;
398
+ delegate.unsubscribe.mockClear();
399
+
400
+ transport.unsubscribe("notification", handler);
401
+
402
+ expect(delegate.unsubscribe).toHaveBeenCalledWith(
403
+ "notification",
404
+ handler,
405
+ );
406
+ });
407
+
408
+ it("should not re-register unsubscribed handlers on reconnection", async () => {
409
+ const getWsUrl = vi.fn(() => mockWsUrl);
410
+ const transport = new ReconnectingWebSocketTransport({ getWsUrl });
411
+
412
+ const handler1 = vi.fn();
413
+ const handler2 = vi.fn();
414
+ transport.subscribe("notification", handler1);
415
+ transport.subscribe("response", handler2);
416
+
417
+ await transport.connect();
418
+
419
+ // Unsubscribe handler1
420
+ transport.unsubscribe("notification", handler1);
421
+
422
+ // Simulate connection loss
423
+ mockConnection.readyState = WebSocket.CLOSED;
424
+
425
+ // Reconnect by sending data
426
+ const data: any = { method: "test", params: [] };
427
+ await transport.sendData(data, 5000);
428
+
429
+ const newDelegate = (transport as any).delegate;
430
+
431
+ // Only handler2 should be registered on the new delegate
432
+ expect(newDelegate.subscribe).not.toHaveBeenCalledWith(
433
+ "notification",
434
+ handler1,
435
+ );
436
+ expect(newDelegate.subscribe).toHaveBeenCalledWith("response", handler2);
437
+ });
438
+ });
290
439
  });
@@ -23,6 +23,11 @@ export interface ReconnectingWebSocketTransportOptions {
23
23
  onReconnect?: () => Promise<void>;
24
24
  }
25
25
 
26
+ interface Subscription {
27
+ event: "pending" | "notification" | "response" | "error";
28
+ handler: Parameters<Transport["subscribe"]>[1];
29
+ }
30
+
26
31
  /**
27
32
  * A WebSocket transport that automatically reconnects when the connection is lost.
28
33
  * This handles cases like computer sleep/wake or network interruptions.
@@ -33,6 +38,7 @@ export class ReconnectingWebSocketTransport extends Transport {
33
38
  private connectionPromise: Promise<void> | undefined;
34
39
  private isClosed = false;
35
40
  private hasConnectedBefore = false;
41
+ private pendingSubscriptions: Subscription[] = [];
36
42
 
37
43
  constructor(options: ReconnectingWebSocketTransportOptions) {
38
44
  super();
@@ -55,6 +61,12 @@ export class ReconnectingWebSocketTransport extends Transport {
55
61
 
56
62
  // Create a new delegate
57
63
  this.delegate = new WebSocketTransport(this.options.getWsUrl());
64
+
65
+ // Re-register all pending subscriptions on the new delegate
66
+ for (const { event, handler } of this.pendingSubscriptions) {
67
+ this.delegate.subscribe(event, handler);
68
+ }
69
+
58
70
  return this.delegate;
59
71
  }
60
72
 
@@ -134,6 +146,42 @@ export class ReconnectingWebSocketTransport extends Transport {
134
146
  this.connectionPromise = undefined;
135
147
  }
136
148
 
149
+ override subscribe(...args: Parameters<Transport["subscribe"]>): void {
150
+ // Register handler on parent Transport
151
+ super.subscribe(...args);
152
+
153
+ const [event, handler] = args;
154
+
155
+ // Track the subscription
156
+ this.pendingSubscriptions.push({ event, handler });
157
+
158
+ // Also register on delegate if it exists
159
+ if (this.delegate) {
160
+ this.delegate.subscribe(event, handler);
161
+ }
162
+ }
163
+
164
+ override unsubscribe(
165
+ ...args: Parameters<Transport["unsubscribe"]>
166
+ ): import("events").EventEmitter | undefined {
167
+ // Unregister from parent
168
+ const result = super.unsubscribe(...args);
169
+
170
+ const [event, handler] = args;
171
+
172
+ // Remove from pending subscriptions
173
+ this.pendingSubscriptions = this.pendingSubscriptions.filter(
174
+ (sub) => !(sub.event === event && sub.handler === handler),
175
+ );
176
+
177
+ // Also unregister from delegate if it exists
178
+ if (this.delegate) {
179
+ this.delegate.unsubscribe(event, handler);
180
+ }
181
+
182
+ return result;
183
+ }
184
+
137
185
  override async sendData(
138
186
  data: JSONRPCRequestData,
139
187
  timeout: number | null | undefined,
@@ -50,6 +50,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
50
50
  // Export operations start a connection
51
51
  exportAsHTML: "startConnection",
52
52
  exportAsMarkdown: "startConnection",
53
+ exportAsPDF: "startConnection",
53
54
  readCode: "startConnection",
54
55
  sendCopy: "throwError",
55
56
 
@@ -381,6 +381,15 @@ export function createNetworkRequests(): EditRequests & RunRequests {
381
381
  })
382
382
  .then(handleResponse);
383
383
  },
384
+ exportAsPDF: async (request) => {
385
+ return getClient()
386
+ .POST("/api/export/pdf", {
387
+ body: request,
388
+ parseAs: "blob",
389
+ params: getParams(),
390
+ })
391
+ .then(handleResponse);
392
+ },
384
393
  autoExportAsHTML: async (request) => {
385
394
  return getClient()
386
395
  .POST("/api/export/auto_export/html", {
@@ -76,6 +76,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
76
76
  shutdownSession: throwNotInEditMode,
77
77
  exportAsHTML: throwNotInEditMode,
78
78
  exportAsMarkdown: throwNotInEditMode,
79
+ exportAsPDF: throwNotInEditMode,
79
80
  autoExportAsHTML: throwNotInEditMode,
80
81
  autoExportAsMarkdown: throwNotInEditMode,
81
82
  autoExportAsIPYNB: throwNotInEditMode,
@@ -61,6 +61,7 @@ export function createErrorToastingRequests(
61
61
  shutdownSession: "Failed to shutdown session",
62
62
  exportAsHTML: "Failed to export HTML",
63
63
  exportAsMarkdown: "Failed to export Markdown",
64
+ exportAsPDF: "Failed to export PDF",
64
65
  autoExportAsHTML: "", // No toast
65
66
  autoExportAsMarkdown: "", // No toast
66
67
  autoExportAsIPYNB: "", // No toast
@@ -21,6 +21,7 @@ export type ExportAsHTMLRequest = schemas["ExportAsHTMLRequest"];
21
21
  export type ExportAsMarkdownRequest = schemas["ExportAsMarkdownRequest"];
22
22
  export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
23
23
  export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
24
+ export type ExportAsPDFRequest = schemas["ExportAsPDFRequest"];
24
25
  export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
25
26
  export type FileCreateRequest = schemas["FileCreateRequest"];
26
27
  export type FileCreateResponse = schemas["FileCreateResponse"];
@@ -173,6 +174,7 @@ export interface EditRequests {
173
174
  // Export requests
174
175
  exportAsHTML: (request: ExportAsHTMLRequest) => Promise<string>;
175
176
  exportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<string>;
177
+ exportAsPDF: (request: ExportAsPDFRequest) => Promise<Blob>;
176
178
  autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
177
179
  autoExportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<null>;
178
180
  autoExportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<null>;
@@ -586,6 +586,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
586
586
  getWorkspaceFiles = throwNotImplemented;
587
587
  getRunningNotebooks = throwNotImplemented;
588
588
  shutdownSession = throwNotImplemented;
589
+ exportAsPDF = throwNotImplemented;
589
590
  autoExportAsHTML = throwNotImplemented;
590
591
  autoExportAsMarkdown = throwNotImplemented;
591
592
  autoExportAsIPYNB = throwNotImplemented;
@@ -249,7 +249,6 @@
249
249
  }
250
250
 
251
251
  /* Borderless styles for Cell */
252
-
253
252
  &.borderless {
254
253
  border-color: transparent;
255
254
 
@@ -258,7 +257,6 @@
258
257
  }
259
258
 
260
259
  /* Apply the original styles */
261
- &:hover,
262
260
  &:focus {
263
261
  border: 1px solid var(--gray-4);
264
262
  }
@@ -115,16 +115,18 @@ const TexComponent = ({
115
115
  // isn't a simple way to do that in Python without bringing in a new
116
116
  // dependency.
117
117
  //
118
- // The number of children is always 1 (the LaTeX) or 3 redundant ||(, ||)
119
- // delimiters as the first and third child, another marimo-tex tag as the
120
- // second. Only try to render latex in the former case.
118
+ // When nested, the inner marimo-tex should not render because the outer
119
+ // marimo-tex's textContent includes the nested delimiters (||(||(x||)||))
120
+ // and will render correctly with displayMode: true. We detect this by
121
+ // checking if the parent element is also a marimo-tex.
122
+ const isNested = host.parentElement?.tagName.toLowerCase() === "marimo-tex";
121
123
 
122
124
  // Re-render when the text content changes.
123
125
  useLayoutEffect(() => {
124
- if (ref.current) {
126
+ if (ref.current && !isNested) {
125
127
  renderLatex(ref.current, currentTex);
126
128
  }
127
- }, [currentTex]);
129
+ }, [currentTex, isNested]);
128
130
 
129
131
  return <span ref={ref} />;
130
132
  };