@marimo-team/frontend 0.19.7-dev34 → 0.19.7-dev36
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/assets/{CellStatus-DLlfsrEI.js → CellStatus-DhGipVU-.js} +1 -1
- package/dist/assets/{JsonOutput-DvKIRGOg.js → JsonOutput-DtidtKaJ.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-DscugIeA.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
- package/dist/assets/{RenderHTML-DPkeBHFB.js → RenderHTML-Co6iQEvR.js} +1 -1
- package/dist/assets/{add-cell-with-ai-BHgqYu8P.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
- package/dist/assets/{add-database-form-DAMSmPZS.js → add-database-form-_pTLtiHN.js} +1 -1
- package/dist/assets/{agent-panel-C9codfcr.js → agent-panel-oLGqa4bG.js} +1 -1
- package/dist/assets/{ai-model-dropdown-DxImvtE1.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
- package/dist/assets/{app-config-button-D4NHNYdV.js → app-config-button-BiwYEPE8.js} +1 -1
- package/dist/assets/{cell-editor-BxhibLVM.js → cell-editor-B3U1SnYJ.js} +1 -1
- package/dist/assets/{cell-link-a1r84hCk.js → cell-link-_-mIiddP.js} +1 -1
- package/dist/assets/{cells-Mf-pdsEh.js → cells-BW_4R0Qw.js} +1 -1
- package/dist/assets/{chat-components-BpunBJu9.js → chat-components-ZGfi_TyH.js} +1 -1
- package/dist/assets/{chat-display-B0625p01.js → chat-display-B34MaCGM.js} +1 -1
- package/dist/assets/{chat-panel-CkHdco_X.js → chat-panel-fuQFRvFm.js} +1 -1
- package/dist/assets/{column-preview-C6jEPj3t.js → column-preview-B-dViv1i.js} +1 -1
- package/dist/assets/{command-BmWAYrdT.js → command-B5H3BrRg.js} +1 -1
- package/dist/assets/{command-palette-D1g3pU47.js → command-palette-KuNgJNix.js} +1 -1
- package/dist/assets/{common-kVa9xjZc.js → common-DxKcMlJZ.js} +1 -1
- package/dist/assets/{datasource-jW7Cq5OE.js → datasource-CpcDqjf_.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BWvUSpJI.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
- package/dist/assets/{documentation-panel-z2oxdgzR.js → documentation-panel-BK_YuaI4.js} +1 -1
- package/dist/assets/download-Bwa9P-Pz.js +6 -0
- package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
- package/dist/assets/{edit-page-UqaWbc_J.js → edit-page-DoQyHH-0.js} +4 -4
- package/dist/assets/{error-panel-BxmwSms7.js → error-panel-DulelhA-.js} +1 -1
- package/dist/assets/{file-explorer-panel-y7F8Uqi-.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
- package/dist/assets/{floating-outline-mEMcQGQK.js → floating-outline-Ni_RT38T.js} +1 -1
- package/dist/assets/{focus-ay4g-SB6.js → focus-CsiV5LZR.js} +1 -1
- package/dist/assets/{form-DFq7l6gy.js → form-B1n-e_X0.js} +1 -1
- package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
- package/dist/assets/{globals-Du9rkBEf.js → globals-ols5LsZP.js} +1 -1
- package/dist/assets/home-page--XVUAUCM.js +4 -0
- package/dist/assets/hooks-B1nUQK2T.js +1 -0
- package/dist/assets/{html-to-image-Cdx1xsbU.js → html-to-image-Cu1p0tCK.js} +2 -2
- package/dist/assets/{index-BSBPZDCV.js → index-BHikcWoK.js} +5 -5
- package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
- package/dist/assets/{kiosk-mode-Y_Rs0fTN.js → kiosk-mode-vMIC4Cbh.js} +1 -1
- package/dist/assets/{layout-Xf51uwDc.js → layout-KY92f2Sm.js} +3 -3
- package/dist/assets/{logs-panel-bOS3V2nr.js → logs-panel-DeNFSwJb.js} +1 -1
- package/dist/assets/{markdown-renderer-DSY-ElEE.js → markdown-renderer-Dpn5NCvn.js} +1 -1
- package/dist/assets/{mode-TsexQOfl.js → mode-BCr7l3Th.js} +1 -1
- package/dist/assets/{name-cell-input-B_OPYPFz.js → name-cell-input-D6uaGwg7.js} +1 -1
- package/dist/assets/{outline-panel-DkOJrPIL.js → outline-panel-DpbnlPhw.js} +1 -1
- package/dist/assets/{packages-panel-thVjzLK4.js → packages-panel-CBc59eNR.js} +1 -1
- package/dist/assets/{panels-D-yo_63g.js → panels-B0B71dYl.js} +1 -1
- package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
- package/dist/assets/{process-output-ccUMEFYE.js → process-output-TMO5uCHT.js} +1 -1
- package/dist/assets/{readonly-python-code-B1UBDfCT.js → readonly-python-code-CCwpyiLX.js} +1 -1
- package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
- package/dist/assets/run-page-Dug0EU2T.js +1 -0
- package/dist/assets/{scratchpad-panel-DPnpCcOn.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
- package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
- package/dist/assets/{session-panel-CjoiPf6W.js → session-panel-CR_CZBSy.js} +1 -1
- package/dist/assets/{snippets-panel-DeCtu_nU.js → snippets-panel-ypIwat9G.js} +1 -1
- package/dist/assets/{state-BXbqGYab.js → state-BrsyJBHJ.js} +1 -1
- package/dist/assets/{switch-NTEWSiVz.js → switch-Cch0bLxo.js} +1 -1
- package/dist/assets/table-C8uQmBAN.js +1 -0
- package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
- package/dist/assets/{textarea-Ddtm_ohJ.js → textarea-DSR2T2ft.js} +1 -1
- package/dist/assets/{tracing-panel-BowfBZDI.js → tracing-panel-57WGQhyd.js} +2 -2
- package/dist/assets/{tracing-Hw-L0vPw.js → tracing-vnJxVAtK.js} +1 -1
- package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
- package/dist/assets/{types-1gCn5Ky0.js → types-fTSozrNJ.js} +1 -1
- package/dist/assets/{useAddCell-M6_IuI4C.js → useAddCell-DRmuczCx.js} +1 -1
- package/dist/assets/{useCellActionButton-Ddy42rre.js → useCellActionButton-DwRoApVS.js} +1 -1
- package/dist/assets/{useDeleteCell-Ct7dOEmt.js → useDeleteCell-CR3IczUk.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-RSRpRVKD.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
- package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
- package/dist/assets/{useRunCells-BgXbioPv.js → useRunCells-C50mliNM.js} +1 -1
- package/dist/assets/{useSplitCell-BZ2kxLtm.js → useSplitCell-IQsKBoRj.js} +1 -1
- package/dist/assets/{utilities.esm-mPQPstBT.js → utilities.esm-DyYLtC1k.js} +2 -2
- package/dist/index.html +36 -36
- package/package.json +1 -1
- package/src/components/data-table/TableActions.tsx +5 -3
- package/src/components/data-table/download-actions.tsx +7 -2
- package/src/components/data-table/pagination.tsx +4 -4
- package/src/components/debug/indicator.tsx +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +4 -2
- package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +4 -4
- package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
- package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
- package/src/components/editor/controls/Controls.tsx +2 -2
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
- package/src/components/editor/file-tree/file-explorer.tsx +1 -1
- package/src/components/editor/header/status.tsx +1 -1
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
- package/src/components/home/components.tsx +1 -1
- package/src/components/static-html/static-banner.tsx +1 -1
- package/src/components/ui/dropdown-menu.tsx +1 -1
- package/src/components/ui/table.tsx +1 -1
- package/src/core/config/feature-flag.tsx +1 -1
- package/src/core/export/__tests__/hooks.test.ts +60 -58
- package/src/core/export/hooks.ts +71 -31
- package/src/css/app/print.css +0 -14
- package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
- package/src/utils/__tests__/download.test.tsx +5 -114
- package/src/utils/async-capture-tracker.ts +168 -0
- package/src/utils/download.ts +17 -57
- package/src/utils/html-to-image.ts +9 -12
- package/dist/assets/download-vBVDTXQk.js +0 -6
- package/dist/assets/home-page-Cg221ah6.js +0 -4
- package/dist/assets/hooks-D_OOStv3.js +0 -1
- package/dist/assets/run-page-CoCql9Xm.js +0 -1
- package/dist/assets/table-BSASHvkq.js +0 -1
- package/dist/assets/useNotebookActions-XKe-QMLa.js +0 -1
|
@@ -41,7 +41,11 @@ import { downloadAsHTML } from "@/core/static/download-html";
|
|
|
41
41
|
import { isStaticNotebook } from "@/core/static/static-state";
|
|
42
42
|
import { isWasm } from "@/core/wasm/utils";
|
|
43
43
|
import { cn } from "@/utils/cn";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
ADD_PRINTING_CLASS,
|
|
46
|
+
downloadBlob,
|
|
47
|
+
downloadHTMLAsImage,
|
|
48
|
+
} from "@/utils/download";
|
|
45
49
|
import { Filenames } from "@/utils/filenames";
|
|
46
50
|
import { FloatingOutline } from "../../chrome/panels/outline/floating-outline";
|
|
47
51
|
import { cellDomProps } from "../../common";
|
|
@@ -185,7 +189,12 @@ const ActionButtons: React.FC<{
|
|
|
185
189
|
if (!app) {
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
188
|
-
await downloadHTMLAsImage({
|
|
192
|
+
await downloadHTMLAsImage({
|
|
193
|
+
element: app,
|
|
194
|
+
filename: document.title,
|
|
195
|
+
// Add body.printing ONLY when converting the whole notebook to a screenshot
|
|
196
|
+
prepare: ADD_PRINTING_CLASS,
|
|
197
|
+
});
|
|
189
198
|
};
|
|
190
199
|
|
|
191
200
|
const handleDownloadAsHTML = async () => {
|
|
@@ -271,7 +280,7 @@ const ActionButtons: React.FC<{
|
|
|
271
280
|
<div
|
|
272
281
|
data-testid="notebook-actions-dropdown"
|
|
273
282
|
className={cn(
|
|
274
|
-
"right-0 top-0 z-50 m-4
|
|
283
|
+
"right-0 top-0 z-50 m-4 print:hidden flex gap-2",
|
|
275
284
|
// If the notebook is static, we have a banner at the top, so
|
|
276
285
|
// we can't use fixed positioning. Ideally this is sticky, but the
|
|
277
286
|
// current dom structure makes that difficult.
|
|
@@ -284,7 +293,7 @@ const ActionButtons: React.FC<{
|
|
|
284
293
|
<MoreHorizontalIcon className="w-4 h-4" />
|
|
285
294
|
</Button>
|
|
286
295
|
</DropdownMenuTrigger>
|
|
287
|
-
<DropdownMenuContent align="end" className="
|
|
296
|
+
<DropdownMenuContent align="end" className="print:hidden w-[220px]">
|
|
288
297
|
{actions}
|
|
289
298
|
</DropdownMenuContent>
|
|
290
299
|
</DropdownMenu>
|
|
@@ -80,7 +80,7 @@ export const OpenTutorialDropDown: React.FC = () => {
|
|
|
80
80
|
<CaretDownIcon className="w-3 h-3 ml-1" />
|
|
81
81
|
</Button>
|
|
82
82
|
</DropdownMenuTrigger>
|
|
83
|
-
<DropdownMenuContent side="bottom" align="end" className="
|
|
83
|
+
<DropdownMenuContent side="bottom" align="end" className="print:hidden">
|
|
84
84
|
{Objects.entries(TUTORIALS).map(
|
|
85
85
|
([tutorialId, [label, Icon, description]]) => (
|
|
86
86
|
<DropdownMenuItem
|
|
@@ -36,7 +36,7 @@ export const StaticBanner: React.FC = () => {
|
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
38
|
<div
|
|
39
|
-
className="px-4 py-2 bg-(--sky-2) border-b border-(--sky-7) text-(--sky-11) flex justify-between items-center gap-4
|
|
39
|
+
className="px-4 py-2 bg-(--sky-2) border-b border-(--sky-7) text-(--sky-11) flex justify-between items-center gap-4 print:hidden text-sm"
|
|
40
40
|
data-testid="static-notebook-banner"
|
|
41
41
|
>
|
|
42
42
|
<span>
|
|
@@ -83,7 +83,7 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
83
83
|
sideOffset={sideOffset}
|
|
84
84
|
className={cn(
|
|
85
85
|
menuContentCommon(),
|
|
86
|
-
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
86
|
+
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 print:hidden",
|
|
87
87
|
scrollable && "overflow-auto",
|
|
88
88
|
className,
|
|
89
89
|
)}
|
|
@@ -7,7 +7,7 @@ const Table = React.forwardRef<
|
|
|
7
7
|
HTMLTableElement,
|
|
8
8
|
React.HTMLAttributes<HTMLTableElement>
|
|
9
9
|
>(({ className, ...props }, ref) => (
|
|
10
|
-
<div className="w-full overflow-auto scrollbar-thin flex-1">
|
|
10
|
+
<div className="w-full overflow-auto scrollbar-thin flex-1 print:overflow-hidden">
|
|
11
11
|
<table
|
|
12
12
|
ref={ref}
|
|
13
13
|
className={cn("w-full caption-bottom text-sm", className)}
|
|
@@ -25,7 +25,7 @@ const defaultValues: ExperimentalFeatures = {
|
|
|
25
25
|
chat_modes: false,
|
|
26
26
|
cache_panel: false,
|
|
27
27
|
external_agents: import.meta.env.DEV,
|
|
28
|
-
server_side_pdf_export:
|
|
28
|
+
server_side_pdf_export: true,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
|
|
@@ -10,6 +10,7 @@ import { CellOutputId } from "@/core/cells/ids";
|
|
|
10
10
|
import type { CellRuntimeState } from "@/core/cells/types";
|
|
11
11
|
import { ProgressState } from "@/utils/progress";
|
|
12
12
|
import {
|
|
13
|
+
captureTracker,
|
|
13
14
|
updateCellOutputsWithScreenshots,
|
|
14
15
|
useEnrichCellOutputs,
|
|
15
16
|
} from "../hooks";
|
|
@@ -52,6 +53,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
52
53
|
beforeEach(() => {
|
|
53
54
|
vi.clearAllMocks();
|
|
54
55
|
store = createStore();
|
|
56
|
+
captureTracker.reset();
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
@@ -103,7 +105,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
103
105
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
104
106
|
|
|
105
107
|
const takeScreenshots = result.current;
|
|
106
|
-
const output = await takeScreenshots({ progress
|
|
108
|
+
const output = await takeScreenshots({ progress });
|
|
107
109
|
|
|
108
110
|
expect(output).toEqual({});
|
|
109
111
|
expect(document.getElementById).not.toHaveBeenCalled();
|
|
@@ -135,7 +137,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
135
137
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
136
138
|
|
|
137
139
|
const takeScreenshots = result.current;
|
|
138
|
-
const output = await takeScreenshots({ progress
|
|
140
|
+
const output = await takeScreenshots({ progress });
|
|
139
141
|
|
|
140
142
|
expect(document.getElementById).toHaveBeenCalledWith(
|
|
141
143
|
CellOutputId.create(cellId),
|
|
@@ -152,50 +154,6 @@ describe("useEnrichCellOutputs", () => {
|
|
|
152
154
|
});
|
|
153
155
|
});
|
|
154
156
|
|
|
155
|
-
it("should pass snappy=true to toPng with includeStyleProperties", async () => {
|
|
156
|
-
const cellId = "cell-1" as CellId;
|
|
157
|
-
const mockElement = document.createElement("div");
|
|
158
|
-
const mockDataUrl = "data:image/png;base64,mockImageData";
|
|
159
|
-
|
|
160
|
-
// Mock document.getElementById
|
|
161
|
-
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
162
|
-
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
163
|
-
|
|
164
|
-
setCellsRuntime(
|
|
165
|
-
createMockCellRuntimes({
|
|
166
|
-
[cellId]: {
|
|
167
|
-
output: {
|
|
168
|
-
channel: "output",
|
|
169
|
-
mimetype: "text/html",
|
|
170
|
-
data: "<div>Chart</div>",
|
|
171
|
-
timestamp: 0,
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
}),
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
178
|
-
|
|
179
|
-
const takeScreenshots = result.current;
|
|
180
|
-
const output = await takeScreenshots({ progress, snappy: true });
|
|
181
|
-
|
|
182
|
-
expect(document.getElementById).toHaveBeenCalledWith(
|
|
183
|
-
CellOutputId.create(cellId),
|
|
184
|
-
);
|
|
185
|
-
// When snappy=true, includeStyleProperties should be set
|
|
186
|
-
expect(toPng).toHaveBeenCalledWith(
|
|
187
|
-
mockElement,
|
|
188
|
-
expect.objectContaining({
|
|
189
|
-
filter: expect.any(Function),
|
|
190
|
-
onImageErrorHandler: expect.any(Function),
|
|
191
|
-
includeStyleProperties: expect.any(Array),
|
|
192
|
-
}),
|
|
193
|
-
);
|
|
194
|
-
expect(output).toEqual({
|
|
195
|
-
[cellId]: ["image/png", mockDataUrl],
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
157
|
it("should skip cells where output has not changed", async () => {
|
|
200
158
|
const cellId = "cell-1" as CellId;
|
|
201
159
|
const mockElement = document.createElement("div");
|
|
@@ -224,7 +182,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
224
182
|
|
|
225
183
|
// First call - should capture
|
|
226
184
|
let takeScreenshots = result.current;
|
|
227
|
-
let output = await takeScreenshots({ progress
|
|
185
|
+
let output = await takeScreenshots({ progress });
|
|
228
186
|
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
|
|
229
187
|
expect(toPng).toHaveBeenCalledTimes(1);
|
|
230
188
|
|
|
@@ -233,7 +191,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
233
191
|
|
|
234
192
|
// Second call with same output - should not capture again
|
|
235
193
|
takeScreenshots = result.current;
|
|
236
|
-
output = await takeScreenshots({ progress
|
|
194
|
+
output = await takeScreenshots({ progress });
|
|
237
195
|
expect(output).toEqual({}); // Empty because output hasn't changed
|
|
238
196
|
expect(toPng).toHaveBeenCalledTimes(1); // Still only 1 call
|
|
239
197
|
});
|
|
@@ -262,7 +220,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
262
220
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
263
221
|
|
|
264
222
|
const takeScreenshots = result.current;
|
|
265
|
-
const output = await takeScreenshots({ progress
|
|
223
|
+
const output = await takeScreenshots({ progress });
|
|
266
224
|
|
|
267
225
|
expect(output).toEqual({}); // Failed screenshot should be filtered out
|
|
268
226
|
expect(Logger.error).toHaveBeenCalledWith(
|
|
@@ -271,6 +229,50 @@ describe("useEnrichCellOutputs", () => {
|
|
|
271
229
|
);
|
|
272
230
|
});
|
|
273
231
|
|
|
232
|
+
it("should retry failed screenshots on next call", async () => {
|
|
233
|
+
const cellId = "cell-1" as CellId;
|
|
234
|
+
const mockElement = document.createElement("div");
|
|
235
|
+
const error = new Error("Screenshot failed");
|
|
236
|
+
const mockDataUrl = "data:image/png;base64,retrySuccess";
|
|
237
|
+
|
|
238
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
239
|
+
// First call fails, second call succeeds
|
|
240
|
+
vi.mocked(toPng)
|
|
241
|
+
.mockRejectedValueOnce(error)
|
|
242
|
+
.mockResolvedValueOnce(mockDataUrl);
|
|
243
|
+
|
|
244
|
+
setCellsRuntime(
|
|
245
|
+
createMockCellRuntimes({
|
|
246
|
+
[cellId]: {
|
|
247
|
+
output: {
|
|
248
|
+
channel: "output",
|
|
249
|
+
mimetype: "text/html",
|
|
250
|
+
data: "<div>Chart</div>",
|
|
251
|
+
timestamp: 0,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
|
|
258
|
+
wrapper,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// First call - screenshot fails
|
|
262
|
+
let takeScreenshots = result.current;
|
|
263
|
+
let output = await takeScreenshots({ progress });
|
|
264
|
+
expect(output).toEqual({});
|
|
265
|
+
expect(Logger.error).toHaveBeenCalled();
|
|
266
|
+
|
|
267
|
+
rerender();
|
|
268
|
+
|
|
269
|
+
// Second call - should retry since the first one failed
|
|
270
|
+
takeScreenshots = result.current;
|
|
271
|
+
output = await takeScreenshots({ progress });
|
|
272
|
+
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
|
|
273
|
+
expect(toPng).toHaveBeenCalledTimes(2);
|
|
274
|
+
});
|
|
275
|
+
|
|
274
276
|
it("should handle missing DOM elements", async () => {
|
|
275
277
|
const cellId = "cell-1" as CellId;
|
|
276
278
|
|
|
@@ -292,7 +294,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
292
294
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
293
295
|
|
|
294
296
|
const takeScreenshots = result.current;
|
|
295
|
-
const output = await takeScreenshots({ progress
|
|
297
|
+
const output = await takeScreenshots({ progress });
|
|
296
298
|
|
|
297
299
|
expect(output).toEqual({});
|
|
298
300
|
expect(Logger.error).toHaveBeenCalledWith(
|
|
@@ -341,7 +343,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
341
343
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
342
344
|
|
|
343
345
|
const takeScreenshots = result.current;
|
|
344
|
-
const output = await takeScreenshots({ progress
|
|
346
|
+
const output = await takeScreenshots({ progress });
|
|
345
347
|
|
|
346
348
|
expect(output).toEqual({
|
|
347
349
|
[cell1]: ["image/png", mockDataUrl1],
|
|
@@ -387,7 +389,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
387
389
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
388
390
|
|
|
389
391
|
const takeScreenshots = result.current;
|
|
390
|
-
const output = await takeScreenshots({ progress
|
|
392
|
+
const output = await takeScreenshots({ progress });
|
|
391
393
|
|
|
392
394
|
// Only the successful screenshot should be in the result
|
|
393
395
|
expect(output).toEqual({
|
|
@@ -429,13 +431,13 @@ describe("useEnrichCellOutputs", () => {
|
|
|
429
431
|
|
|
430
432
|
// First screenshot
|
|
431
433
|
let takeScreenshots = result.current;
|
|
432
|
-
let output = await takeScreenshots({ progress
|
|
434
|
+
let output = await takeScreenshots({ progress });
|
|
433
435
|
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl1] });
|
|
434
436
|
|
|
435
437
|
// Second call - same output, should not be captured
|
|
436
438
|
rerender();
|
|
437
439
|
takeScreenshots = result.current;
|
|
438
|
-
output = await takeScreenshots({ progress
|
|
440
|
+
output = await takeScreenshots({ progress });
|
|
439
441
|
expect(output).toEqual({});
|
|
440
442
|
|
|
441
443
|
// Third call - output changed, should be captured
|
|
@@ -454,7 +456,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
454
456
|
|
|
455
457
|
rerender();
|
|
456
458
|
takeScreenshots = result.current;
|
|
457
|
-
output = await takeScreenshots({ progress
|
|
459
|
+
output = await takeScreenshots({ progress });
|
|
458
460
|
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl2] });
|
|
459
461
|
expect(toPng).toHaveBeenCalledTimes(2);
|
|
460
462
|
});
|
|
@@ -494,7 +496,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
494
496
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
495
497
|
|
|
496
498
|
const takeScreenshots = result.current;
|
|
497
|
-
const output = await takeScreenshots({ progress
|
|
499
|
+
const output = await takeScreenshots({ progress });
|
|
498
500
|
|
|
499
501
|
// None of these should trigger screenshots
|
|
500
502
|
expect(output).toEqual({});
|
|
@@ -519,7 +521,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
519
521
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
520
522
|
|
|
521
523
|
const takeScreenshots = result.current;
|
|
522
|
-
const output = await takeScreenshots({ progress
|
|
524
|
+
const output = await takeScreenshots({ progress });
|
|
523
525
|
|
|
524
526
|
expect(output).toEqual({});
|
|
525
527
|
expect(document.getElementById).not.toHaveBeenCalled();
|
|
@@ -551,7 +553,7 @@ describe("useEnrichCellOutputs", () => {
|
|
|
551
553
|
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
552
554
|
|
|
553
555
|
const takeScreenshots = result.current;
|
|
554
|
-
const output = await takeScreenshots({ progress
|
|
556
|
+
const output = await takeScreenshots({ progress });
|
|
555
557
|
|
|
556
558
|
// Verify the exact return type structure
|
|
557
559
|
expect(output).toHaveProperty(cellId);
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import { useAtomValue } from "jotai";
|
|
3
3
|
import type { MimeType } from "@/components/editor/Output";
|
|
4
4
|
import { toast } from "@/components/ui/use-toast";
|
|
5
5
|
import { appConfigAtom } from "@/core/config/config";
|
|
6
6
|
import { useInterval } from "@/hooks/useInterval";
|
|
7
|
+
import { AsyncCaptureTracker } from "@/utils/async-capture-tracker";
|
|
7
8
|
import { getImageDataUrlForCell } from "@/utils/download";
|
|
8
9
|
import { Logger } from "@/utils/Logger";
|
|
9
10
|
import { Objects } from "@/utils/objects";
|
|
@@ -67,7 +68,6 @@ export function useAutoExport() {
|
|
|
67
68
|
const screenshotFn = () =>
|
|
68
69
|
takeScreenshots({
|
|
69
70
|
progress: ProgressState.indeterminate(),
|
|
70
|
-
snappy: true,
|
|
71
71
|
});
|
|
72
72
|
await updateCellOutputsWithScreenshots({
|
|
73
73
|
takeScreenshots: screenshotFn,
|
|
@@ -89,9 +89,6 @@ export function useAutoExport() {
|
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// We track cells that need screenshots, these will be exported to IPYNB
|
|
93
|
-
const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
|
|
94
|
-
|
|
95
92
|
// MIME types to capture screenshots for
|
|
96
93
|
const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
97
94
|
"text/html",
|
|
@@ -101,66 +98,109 @@ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
|
101
98
|
"application/vnd.vega.v6+json",
|
|
102
99
|
]);
|
|
103
100
|
|
|
104
|
-
type
|
|
101
|
+
type ScreenshotResult = ["image/png", string];
|
|
102
|
+
type ScreenshotResults = Record<CellId, ScreenshotResult>;
|
|
103
|
+
|
|
104
|
+
// Only marks cells as captured after successful screenshot.
|
|
105
|
+
export const captureTracker = new AsyncCaptureTracker<
|
|
106
|
+
CellId,
|
|
107
|
+
ScreenshotResult
|
|
108
|
+
>();
|
|
109
|
+
|
|
110
|
+
interface UseEnrichCellOutputsOptions {
|
|
111
|
+
progress: ProgressState;
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
/**
|
|
107
115
|
* Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
|
|
108
116
|
* @returns A map of cell IDs to their screenshots data.
|
|
109
117
|
*/
|
|
110
|
-
export function useEnrichCellOutputs()
|
|
111
|
-
|
|
118
|
+
export function useEnrichCellOutputs(): (
|
|
119
|
+
opts: UseEnrichCellOutputsOptions,
|
|
120
|
+
) => Promise<ScreenshotResults> {
|
|
112
121
|
const cellRuntimes = useAtomValue(cellsRuntimeAtom);
|
|
113
122
|
|
|
114
|
-
return async (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
return async (
|
|
124
|
+
opts: UseEnrichCellOutputsOptions,
|
|
125
|
+
): Promise<ScreenshotResults> => {
|
|
126
|
+
const { progress } = opts;
|
|
127
|
+
|
|
128
|
+
// Prune tracked state for cells that no longer exist
|
|
129
|
+
const currentCellIds = new Set(Objects.keys(cellRuntimes));
|
|
130
|
+
captureTracker.prune(currentCellIds);
|
|
122
131
|
|
|
123
132
|
const cellsToCaptureScreenshot: [CellId, unknown][] = [];
|
|
133
|
+
const inFlightWaiters: {
|
|
134
|
+
cellId: CellId;
|
|
135
|
+
promise: Promise<ScreenshotResult | undefined>;
|
|
136
|
+
}[] = [];
|
|
137
|
+
|
|
124
138
|
for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
|
|
125
139
|
const outputData = runtime.output?.data;
|
|
126
|
-
const outputHasChanged = richCellsOutput[cellId] !== outputData;
|
|
127
|
-
// Track latest output for this cell
|
|
128
|
-
trackedCellsOutput[cellId] = outputData;
|
|
129
140
|
if (
|
|
130
141
|
runtime.output?.mimetype &&
|
|
131
142
|
MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
|
|
132
|
-
outputData
|
|
133
|
-
outputHasChanged
|
|
143
|
+
outputData
|
|
134
144
|
) {
|
|
135
|
-
|
|
145
|
+
if (captureTracker.needsCapture(cellId, outputData)) {
|
|
146
|
+
cellsToCaptureScreenshot.push([cellId, outputData]);
|
|
147
|
+
} else {
|
|
148
|
+
// If already in-flight with the same value, await its result
|
|
149
|
+
const promise = captureTracker.waitForInFlight(cellId, outputData);
|
|
150
|
+
if (promise) {
|
|
151
|
+
inFlightWaiters.push({ cellId, promise });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
|
-
// Always update tracked outputs, this ensures data is fresh for the next run
|
|
139
|
-
setRichCellsOutput(trackedCellsOutput);
|
|
140
156
|
|
|
141
|
-
if (cellsToCaptureScreenshot.length === 0) {
|
|
157
|
+
if (cellsToCaptureScreenshot.length === 0 && inFlightWaiters.length === 0) {
|
|
142
158
|
return {};
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
// Start the progress bar for new captures only
|
|
162
|
+
if (cellsToCaptureScreenshot.length > 0) {
|
|
163
|
+
progress.addTotal(cellsToCaptureScreenshot.length);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Capture screenshots — each key gets its own AbortSignal so
|
|
167
|
+
// aborting one cell does not affect the others.
|
|
148
168
|
const results: ScreenshotResults = {};
|
|
149
|
-
for (const [cellId] of cellsToCaptureScreenshot) {
|
|
169
|
+
for (const [cellId, outputData] of cellsToCaptureScreenshot) {
|
|
170
|
+
const handle = captureTracker.startCapture(cellId, outputData);
|
|
150
171
|
try {
|
|
151
|
-
const dataUrl = await getImageDataUrlForCell(cellId
|
|
172
|
+
const dataUrl = await getImageDataUrlForCell(cellId);
|
|
173
|
+
if (handle.signal.aborted) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
152
176
|
if (!dataUrl) {
|
|
153
177
|
Logger.error(`Failed to capture screenshot for cell ${cellId}`);
|
|
178
|
+
handle.markFailed();
|
|
154
179
|
continue;
|
|
155
180
|
}
|
|
156
|
-
|
|
181
|
+
const result: ScreenshotResult = ["image/png", dataUrl];
|
|
182
|
+
results[cellId] = result;
|
|
183
|
+
handle.markCaptured(result);
|
|
157
184
|
} catch (error) {
|
|
158
185
|
Logger.error(`Error screenshotting cell ${cellId}:`, error);
|
|
186
|
+
handle.markFailed();
|
|
159
187
|
} finally {
|
|
160
188
|
progress.increment(1);
|
|
161
189
|
}
|
|
162
190
|
}
|
|
163
191
|
|
|
192
|
+
// Await in-flight captures started by concurrent callers
|
|
193
|
+
const settled = await Promise.allSettled(
|
|
194
|
+
inFlightWaiters.map(({ promise }) => promise),
|
|
195
|
+
);
|
|
196
|
+
for (const [i, { cellId }] of inFlightWaiters.entries()) {
|
|
197
|
+
const result =
|
|
198
|
+
settled[i].status === "fulfilled" ? settled[i].value : undefined;
|
|
199
|
+
if (result) {
|
|
200
|
+
results[cellId] = result;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
return results;
|
|
165
205
|
};
|
|
166
206
|
}
|
package/src/css/app/print.css
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
@reference "../globals.css";
|
|
2
2
|
|
|
3
|
-
/* Hide on print
|
|
4
|
-
To hide an element on print, add the class `no-print` to it.
|
|
5
|
-
When printing, the class `printing` is added to the body element to enable this rule. */
|
|
6
|
-
body.printing .no-print {
|
|
7
|
-
display: none !important;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
3
|
body.printing #App {
|
|
11
4
|
/* Full screen print */
|
|
12
5
|
height: fit-content !important;
|
|
13
6
|
}
|
|
14
7
|
|
|
15
|
-
/* When printing the output of a cell, this unset the max-height set by the notebook to capture the full output */
|
|
16
|
-
.printing-output {
|
|
17
|
-
max-height: none !important;
|
|
18
|
-
|
|
19
|
-
@apply bg-background;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
8
|
@media print {
|
|
23
9
|
* {
|
|
24
10
|
-webkit-print-color-adjust: exact;
|