@marimo-team/islands 0.19.5-dev40 → 0.19.5-dev43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +11 -8
- package/package.json +1 -1
- package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
- package/src/components/ai/ai-utils.ts +101 -0
- package/src/components/app-config/ai-config.tsx +56 -16
- package/src/components/app-config/user-config-form.tsx +29 -0
- package/src/components/chat/chat-panel.tsx +3 -3
- package/src/components/editor/actions/useCellActionButton.tsx +2 -2
- package/src/components/editor/actions/useNotebookActions.tsx +18 -10
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
- package/src/core/ai/model-registry.ts +21 -3
- package/src/core/export/hooks.ts +7 -11
- package/src/utils/__tests__/download.test.tsx +398 -2
- package/src/utils/download.ts +107 -6
- package/src/components/export/export-output-button.tsx +0 -14
|
@@ -426,14 +426,14 @@ const ChatInput: React.FC<ChatInputProps> = memo(
|
|
|
426
426
|
ChatInput.displayName = "ChatInput";
|
|
427
427
|
|
|
428
428
|
const ChatPanel = () => {
|
|
429
|
-
const
|
|
429
|
+
const aiConfigured = useAtomValue(aiEnabledAtom);
|
|
430
430
|
const { handleClick } = useOpenSettingsToTab();
|
|
431
431
|
|
|
432
|
-
if (!
|
|
432
|
+
if (!aiConfigured) {
|
|
433
433
|
return (
|
|
434
434
|
<PanelEmptyState
|
|
435
435
|
title="Chat with AI"
|
|
436
|
-
description="AI
|
|
436
|
+
description="No AI provider configured or model selected"
|
|
437
437
|
action={
|
|
438
438
|
<Button variant="outline" size="sm" onClick={() => handleClick("ai")}>
|
|
439
439
|
Edit AI settings
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
ZapIcon,
|
|
27
27
|
ZapOffIcon,
|
|
28
28
|
} from "lucide-react";
|
|
29
|
-
import { downloadCellOutput } from "@/components/export/export-output-button";
|
|
30
29
|
import { MultiIcon } from "@/components/icons/multi-icon";
|
|
31
30
|
import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
32
31
|
import {
|
|
@@ -54,6 +53,7 @@ import { useRequestClient } from "@/core/network/requests";
|
|
|
54
53
|
import type { CellConfig, RuntimeState } from "@/core/network/types";
|
|
55
54
|
import { canLinkToCell, createCellLink } from "@/utils/cell-urls";
|
|
56
55
|
import { copyToClipboard } from "@/utils/copy";
|
|
56
|
+
import { downloadCellOutputAsImage } from "@/utils/download";
|
|
57
57
|
import { MarkdownIcon, PythonIcon } from "../cell/code/icons";
|
|
58
58
|
import { useDeleteCellCallback } from "../cell/useDeleteCell";
|
|
59
59
|
import { useRunCell } from "../cell/useRunCells";
|
|
@@ -341,7 +341,7 @@ export function useCellActionButtons({ cell, closePopover }: Props) {
|
|
|
341
341
|
icon: <ImageIcon size={13} strokeWidth={1.5} />,
|
|
342
342
|
label: "Export output as PNG",
|
|
343
343
|
hidden: !hasOutput,
|
|
344
|
-
handle: () =>
|
|
344
|
+
handle: () => downloadCellOutputAsImage(cellId, "result"),
|
|
345
345
|
},
|
|
346
346
|
{
|
|
347
347
|
icon: <XCircleIcon size={13} strokeWidth={1.5} />,
|
|
@@ -134,6 +134,12 @@ export function useNotebookActions() {
|
|
|
134
134
|
const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
|
|
135
135
|
const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
|
|
136
136
|
|
|
137
|
+
const isServerSidePdfExportEnabled = getFeatureFlag("server_side_pdf_export");
|
|
138
|
+
// With server side pdf export, it doesn't matter what mode we are in,
|
|
139
|
+
// Default export uses browser print, which is better in present mode
|
|
140
|
+
const pdfDownloadEnabled =
|
|
141
|
+
isServerSidePdfExportEnabled || viewState.mode !== "present";
|
|
142
|
+
|
|
137
143
|
const renderCheckboxElement = (checked: boolean) => (
|
|
138
144
|
<div className="w-8 flex justify-end">
|
|
139
145
|
{checked && <CheckIcon size={14} />}
|
|
@@ -209,22 +215,24 @@ export function useNotebookActions() {
|
|
|
209
215
|
if (!app) {
|
|
210
216
|
return;
|
|
211
217
|
}
|
|
212
|
-
await downloadHTMLAsImage(
|
|
218
|
+
await downloadHTMLAsImage({
|
|
219
|
+
element: app,
|
|
220
|
+
filename: document.title,
|
|
221
|
+
});
|
|
213
222
|
},
|
|
214
223
|
},
|
|
215
224
|
{
|
|
216
225
|
icon: <FileIcon size={14} strokeWidth={1.5} />,
|
|
217
226
|
label: "Download as PDF",
|
|
218
|
-
disabled:
|
|
219
|
-
tooltip:
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
),
|
|
227
|
+
disabled: !pdfDownloadEnabled,
|
|
228
|
+
tooltip: pdfDownloadEnabled ? undefined : (
|
|
229
|
+
<span>
|
|
230
|
+
Only available in app view. <br />
|
|
231
|
+
Toggle with: {renderShortcut("global.hideCode", false)}
|
|
232
|
+
</span>
|
|
233
|
+
),
|
|
226
234
|
handle: async () => {
|
|
227
|
-
if (
|
|
235
|
+
if (isServerSidePdfExportEnabled) {
|
|
228
236
|
if (!filename) {
|
|
229
237
|
toastNotebookMustBeNamed();
|
|
230
238
|
return;
|
|
@@ -21,8 +21,17 @@ export interface AiModel extends AiModelType {
|
|
|
21
21
|
custom: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
interface KnownModelMaps {
|
|
25
|
+
/** Map of qualified model ID to model info */
|
|
26
|
+
modelMap: ReadonlyMap<QualifiedModelId, AiModel>;
|
|
27
|
+
/** Map of provider ID to first default model (supports chat or edit) */
|
|
28
|
+
defaultModelByProvider: ReadonlyMap<ProviderId, QualifiedModelId>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const getKnownModelMaps = once((): KnownModelMaps => {
|
|
25
32
|
const modelMap = new Map<QualifiedModelId, AiModel>();
|
|
33
|
+
const defaultModelByProvider = new Map<ProviderId, QualifiedModelId>();
|
|
34
|
+
|
|
26
35
|
for (const model of models) {
|
|
27
36
|
const modelId = model.model as ShortModelId;
|
|
28
37
|
const modelInfo: AiModel = {
|
|
@@ -33,12 +42,21 @@ const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
|
|
|
33
42
|
custom: false,
|
|
34
43
|
};
|
|
35
44
|
|
|
45
|
+
const supportsChatOrEdit =
|
|
46
|
+
modelInfo.roles.includes("chat") || modelInfo.roles.includes("edit");
|
|
47
|
+
|
|
36
48
|
for (const provider of modelInfo.providers) {
|
|
37
49
|
const qualifiedModelId: QualifiedModelId = `${provider}/${modelId}`;
|
|
38
50
|
modelMap.set(qualifiedModelId, modelInfo);
|
|
51
|
+
|
|
52
|
+
// Track first model per provider that supports chat or edit
|
|
53
|
+
if (supportsChatOrEdit && !defaultModelByProvider.has(provider)) {
|
|
54
|
+
defaultModelByProvider.set(provider, qualifiedModelId);
|
|
55
|
+
}
|
|
39
56
|
}
|
|
40
57
|
}
|
|
41
|
-
|
|
58
|
+
|
|
59
|
+
return { modelMap, defaultModelByProvider };
|
|
42
60
|
});
|
|
43
61
|
|
|
44
62
|
const getProviderMap = once(
|
|
@@ -125,7 +143,7 @@ export class AiModelRegistry {
|
|
|
125
143
|
}) {
|
|
126
144
|
const { displayedModels, customModels } = opts;
|
|
127
145
|
const hasDisplayedModels = displayedModels.size > 0;
|
|
128
|
-
const knownModelMap =
|
|
146
|
+
const knownModelMap = getKnownModelMaps().modelMap;
|
|
129
147
|
const customModelsMap = new Map<QualifiedModelId, AiModel>();
|
|
130
148
|
|
|
131
149
|
let modelsMap = new Map<QualifiedModelId, AiModel>();
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { toPng } from "html-to-image";
|
|
3
2
|
import { atom, useAtom, useAtomValue } from "jotai";
|
|
4
3
|
import type { MimeType } from "@/components/editor/Output";
|
|
5
4
|
import { toast } from "@/components/ui/use-toast";
|
|
6
5
|
import { appConfigAtom } from "@/core/config/config";
|
|
7
6
|
import { useInterval } from "@/hooks/useInterval";
|
|
7
|
+
import { getImageDataUrlForCell } from "@/utils/download";
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { Objects } from "@/utils/objects";
|
|
10
10
|
import { cellsRuntimeAtom } from "../cells/cells";
|
|
11
|
-
import {
|
|
11
|
+
import type { CellId } from "../cells/ids";
|
|
12
12
|
import { connectionAtom } from "../network/connection";
|
|
13
13
|
import { useRequestClient } from "../network/requests";
|
|
14
14
|
import type { UpdateCellOutputsRequest } from "../network/types";
|
|
@@ -131,16 +131,12 @@ export function useEnrichCellOutputs() {
|
|
|
131
131
|
// Capture screenshots
|
|
132
132
|
const results = await Promise.all(
|
|
133
133
|
cellsToCaptureScreenshot.map(async ([cellId]) => {
|
|
134
|
-
const outputElement = document.getElementById(
|
|
135
|
-
CellOutputId.create(cellId),
|
|
136
|
-
);
|
|
137
|
-
if (!outputElement) {
|
|
138
|
-
Logger.error(`Output element not found for cell ${cellId}`);
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
134
|
try {
|
|
143
|
-
const dataUrl = await
|
|
135
|
+
const dataUrl = await getImageDataUrlForCell(cellId);
|
|
136
|
+
if (!dataUrl) {
|
|
137
|
+
Logger.error(`Failed to capture screenshot for cell ${cellId}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
144
140
|
return [cellId, ["image/png", dataUrl]] as [
|
|
145
141
|
CellId,
|
|
146
142
|
["image/png", string],
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
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
|
+
}));
|
|
4
17
|
|
|
5
18
|
// Mock the toast module
|
|
6
19
|
const mockDismiss = vi.fn();
|
|
@@ -15,7 +28,23 @@ vi.mock("@/components/icons/spinner", () => ({
|
|
|
15
28
|
Spinner: () => "MockSpinner",
|
|
16
29
|
}));
|
|
17
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";
|
|
18
46
|
import { toast } from "@/components/ui/use-toast";
|
|
47
|
+
import { Logger } from "@/utils/Logger";
|
|
19
48
|
|
|
20
49
|
describe("withLoadingToast", () => {
|
|
21
50
|
beforeEach(() => {
|
|
@@ -30,6 +59,7 @@ describe("withLoadingToast", () => {
|
|
|
30
59
|
expect(toast).toHaveBeenCalledTimes(1);
|
|
31
60
|
expect(toast).toHaveBeenCalledWith({
|
|
32
61
|
title: "Loading...",
|
|
62
|
+
duration: Infinity,
|
|
33
63
|
});
|
|
34
64
|
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
35
65
|
expect(result).toBe("success");
|
|
@@ -94,3 +124,369 @@ describe("withLoadingToast", () => {
|
|
|
94
124
|
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
95
125
|
});
|
|
96
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
|
+
});
|