@marimo-team/islands 0.19.7-dev20 → 0.19.7-dev23
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
CHANGED
|
@@ -72913,7 +72913,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
|
|
|
72913
72913
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
72914
72914
|
}
|
|
72915
72915
|
}
|
|
72916
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-
|
|
72916
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev23"), showCodeInRunModeAtom = atom(true);
|
|
72917
72917
|
atom(null);
|
|
72918
72918
|
var import_compiler_runtime$89 = require_compiler_runtime();
|
|
72919
72919
|
function useKeydownOnElement(e, r) {
|
package/package.json
CHANGED
|
@@ -35,14 +35,12 @@ const LogsPanel: React.FC = () => {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
|
-
|
|
38
|
+
<div className="flex flex-col h-full overflow-auto">
|
|
39
39
|
<div className="flex flex-row justify-start px-2 py-1">
|
|
40
40
|
<ClearButton dataTestId="clear-logs-button" onClick={clearLogs} />
|
|
41
41
|
</div>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
</div>
|
|
45
|
-
</>
|
|
42
|
+
<LogViewer logs={logs} className="min-w-[300px]" />
|
|
43
|
+
</div>
|
|
46
44
|
);
|
|
47
45
|
};
|
|
48
46
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { captureIframeAsImage } from "../iframe";
|
|
4
|
+
|
|
5
|
+
// Mock html-to-image
|
|
6
|
+
vi.mock("html-to-image", () => ({
|
|
7
|
+
toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("captureIframeAsImage", () => {
|
|
11
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
// Mock devicePixelRatio
|
|
15
|
+
Object.defineProperty(window, "devicePixelRatio", {
|
|
16
|
+
value: 1,
|
|
17
|
+
writable: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Mock canvas for placeholder generation
|
|
21
|
+
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
|
22
|
+
const element = originalCreateElement(tagName);
|
|
23
|
+
if (tagName === "canvas") {
|
|
24
|
+
const mockCtx = {
|
|
25
|
+
scale: vi.fn(),
|
|
26
|
+
fillStyle: "",
|
|
27
|
+
fillRect: vi.fn(),
|
|
28
|
+
strokeStyle: "",
|
|
29
|
+
strokeRect: vi.fn(),
|
|
30
|
+
font: "",
|
|
31
|
+
textAlign: "",
|
|
32
|
+
textBaseline: "",
|
|
33
|
+
fillText: vi.fn(),
|
|
34
|
+
measureText: vi.fn().mockReturnValue({ width: 10 }),
|
|
35
|
+
};
|
|
36
|
+
vi.spyOn(element as HTMLCanvasElement, "getContext").mockReturnValue(
|
|
37
|
+
mockCtx as unknown as CanvasRenderingContext2D,
|
|
38
|
+
);
|
|
39
|
+
vi.spyOn(element as HTMLCanvasElement, "toDataURL").mockReturnValue(
|
|
40
|
+
"data:image/png;base64,placeholder",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return element;
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return null when element has no iframe", async () => {
|
|
53
|
+
const element = document.createElement("div");
|
|
54
|
+
element.innerHTML = "<p>No iframe here</p>";
|
|
55
|
+
|
|
56
|
+
const result = await captureIframeAsImage(element);
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should return placeholder for external iframe", async () => {
|
|
61
|
+
const element = document.createElement("div");
|
|
62
|
+
element.innerHTML = '<iframe src="https://external.com/page"></iframe>';
|
|
63
|
+
|
|
64
|
+
const result = await captureIframeAsImage(element);
|
|
65
|
+
|
|
66
|
+
expect(result).not.toBeNull();
|
|
67
|
+
expect(result).toMatch(/^data:image\/png;base64,/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should return placeholder for cross-origin iframe", async () => {
|
|
71
|
+
const element = document.createElement("div");
|
|
72
|
+
element.innerHTML =
|
|
73
|
+
'<iframe src="https://www.openstreetmap.org/export/embed.html"></iframe>';
|
|
74
|
+
|
|
75
|
+
const result = await captureIframeAsImage(element);
|
|
76
|
+
|
|
77
|
+
expect(result).not.toBeNull();
|
|
78
|
+
expect(result).toMatch(/^data:image\/png;base64,/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return null for about:blank iframe without body", async () => {
|
|
82
|
+
const element = document.createElement("div");
|
|
83
|
+
const iframe = document.createElement("iframe");
|
|
84
|
+
iframe.src = "about:blank";
|
|
85
|
+
element.append(iframe);
|
|
86
|
+
|
|
87
|
+
// The iframe has no body accessible in jsdom
|
|
88
|
+
const result = await captureIframeAsImage(element);
|
|
89
|
+
|
|
90
|
+
// In jsdom, contentDocument may not be accessible
|
|
91
|
+
expect(result).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should handle iframe with relative same-origin src", async () => {
|
|
95
|
+
const element = document.createElement("div");
|
|
96
|
+
element.innerHTML = '<iframe src="./@file/123.html"></iframe>';
|
|
97
|
+
|
|
98
|
+
// Same-origin relative URL, but contentDocument not accessible in jsdom
|
|
99
|
+
const result = await captureIframeAsImage(element);
|
|
100
|
+
|
|
101
|
+
// Returns null because jsdom can't access contentDocument for file URLs
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should detect external URL from various formats", async () => {
|
|
106
|
+
const externalUrls = [
|
|
107
|
+
"https://example.com",
|
|
108
|
+
"http://external.org/path",
|
|
109
|
+
"https://sub.domain.com:8080/page",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const url of externalUrls) {
|
|
113
|
+
const element = document.createElement("div");
|
|
114
|
+
element.innerHTML = `<iframe src="${url}"></iframe>`;
|
|
115
|
+
|
|
116
|
+
const result = await captureIframeAsImage(element);
|
|
117
|
+
|
|
118
|
+
expect(result).not.toBeNull();
|
|
119
|
+
expect(result).toMatch(/^data:image\/png;base64,/);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should not treat same-origin URLs as external", async () => {
|
|
124
|
+
// Same-origin URLs - these should try to capture, not return placeholder
|
|
125
|
+
const sameOriginUrls = ["/local/path", "./relative/path", "../parent/path"];
|
|
126
|
+
|
|
127
|
+
for (const url of sameOriginUrls) {
|
|
128
|
+
const element = document.createElement("div");
|
|
129
|
+
element.innerHTML = `<iframe src="${url}"></iframe>`;
|
|
130
|
+
|
|
131
|
+
const result = await captureIframeAsImage(element);
|
|
132
|
+
|
|
133
|
+
// In jsdom, these return null because contentDocument isn't accessible
|
|
134
|
+
// but they should NOT return a placeholder (which would indicate external detection)
|
|
135
|
+
// The key is they don't trigger the external URL path
|
|
136
|
+
expect(result).toBeNull();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/utils/download.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Filenames } from "@/utils/filenames";
|
|
|
7
7
|
import { Paths } from "@/utils/paths";
|
|
8
8
|
import { prettyError } from "./errors";
|
|
9
9
|
import { toPng } from "./html-to-image";
|
|
10
|
+
import { captureIframeAsImage } from "./iframe";
|
|
10
11
|
import { Logger } from "./Logger";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -104,6 +105,12 @@ export async function getImageDataUrlForCell(
|
|
|
104
105
|
if (!element) {
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
108
|
+
|
|
109
|
+
const iframeDataUrl = await captureIframeAsImage(element);
|
|
110
|
+
if (iframeDataUrl) {
|
|
111
|
+
return iframeDataUrl;
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
const cleanup = prepareCellElementForScreenshot(element, enablePrintMode);
|
|
108
115
|
|
|
109
116
|
try {
|
|
@@ -125,6 +132,13 @@ export async function downloadCellOutputAsImage(
|
|
|
125
132
|
return;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
// Cell outputs that are iframes
|
|
136
|
+
const iframeDataUrl = await captureIframeAsImage(element);
|
|
137
|
+
if (iframeDataUrl) {
|
|
138
|
+
downloadByURL(iframeDataUrl, Filenames.toPNG(filename));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
await downloadHTMLAsImage({
|
|
129
143
|
element,
|
|
130
144
|
filename,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { toPng } from "./html-to-image";
|
|
4
|
+
|
|
5
|
+
const PLACEHOLDER_WIDTH = 320;
|
|
6
|
+
const PLACEHOLDER_HEIGHT = 180;
|
|
7
|
+
|
|
8
|
+
function isExternalUrl(src: string | null): string | null {
|
|
9
|
+
if (!src || src === "about:blank") {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const resolved = new URL(src, window.location.href);
|
|
14
|
+
if (resolved.origin === window.location.origin) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return resolved.href;
|
|
18
|
+
} catch {
|
|
19
|
+
return src;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wrapText(
|
|
24
|
+
ctx: CanvasRenderingContext2D,
|
|
25
|
+
text: string,
|
|
26
|
+
maxWidth: number,
|
|
27
|
+
): string[] {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
let current = "";
|
|
30
|
+
|
|
31
|
+
for (const char of text) {
|
|
32
|
+
const test = current + char;
|
|
33
|
+
if (ctx.measureText(test).width <= maxWidth) {
|
|
34
|
+
current = test;
|
|
35
|
+
} else {
|
|
36
|
+
if (current) {
|
|
37
|
+
lines.push(current);
|
|
38
|
+
}
|
|
39
|
+
current = char;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (current) {
|
|
43
|
+
lines.push(current);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createPlaceholderImage(url: string | null): string {
|
|
50
|
+
const scale = window.devicePixelRatio || 1;
|
|
51
|
+
const canvas = document.createElement("canvas");
|
|
52
|
+
canvas.width = PLACEHOLDER_WIDTH * scale;
|
|
53
|
+
canvas.height = PLACEHOLDER_HEIGHT * scale;
|
|
54
|
+
|
|
55
|
+
const ctx = canvas.getContext("2d");
|
|
56
|
+
if (!ctx) {
|
|
57
|
+
return canvas.toDataURL("image/png");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ctx.scale(scale, scale);
|
|
61
|
+
|
|
62
|
+
// Background
|
|
63
|
+
ctx.fillStyle = "#f3f4f6";
|
|
64
|
+
ctx.fillRect(0, 0, PLACEHOLDER_WIDTH, PLACEHOLDER_HEIGHT);
|
|
65
|
+
|
|
66
|
+
// Border
|
|
67
|
+
ctx.strokeStyle = "#d1d5db";
|
|
68
|
+
ctx.strokeRect(0.5, 0.5, PLACEHOLDER_WIDTH - 1, PLACEHOLDER_HEIGHT - 1);
|
|
69
|
+
|
|
70
|
+
// Text
|
|
71
|
+
ctx.fillStyle = "#6b7280";
|
|
72
|
+
ctx.font = "8px sans-serif";
|
|
73
|
+
ctx.textAlign = "center";
|
|
74
|
+
ctx.textBaseline = "middle";
|
|
75
|
+
|
|
76
|
+
const lineHeight = 14;
|
|
77
|
+
const padding = 16;
|
|
78
|
+
const maxWidth = PLACEHOLDER_WIDTH - padding * 2;
|
|
79
|
+
|
|
80
|
+
const message = "External iframe";
|
|
81
|
+
const urlLines = url ? wrapText(ctx, url, maxWidth) : [];
|
|
82
|
+
const totalLines = 1 + urlLines.length;
|
|
83
|
+
const totalHeight = totalLines * lineHeight;
|
|
84
|
+
let y = (PLACEHOLDER_HEIGHT - totalHeight) / 2 + lineHeight / 2;
|
|
85
|
+
|
|
86
|
+
ctx.fillText(message, PLACEHOLDER_WIDTH / 2, y);
|
|
87
|
+
y += lineHeight;
|
|
88
|
+
|
|
89
|
+
for (const line of urlLines) {
|
|
90
|
+
ctx.fillText(line, PLACEHOLDER_WIDTH / 2, y);
|
|
91
|
+
y += lineHeight;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return canvas.toDataURL("image/png");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Capture an iframe as a PNG image. We need to do this because external iframes are not supported by html-to-image.
|
|
99
|
+
* @param element - The element to capture the iframe from
|
|
100
|
+
* @returns The image data URL of the iframe, or a placeholder image if the iframe is external
|
|
101
|
+
*/
|
|
102
|
+
export async function captureIframeAsImage(
|
|
103
|
+
element: HTMLElement,
|
|
104
|
+
): Promise<string | null> {
|
|
105
|
+
const iframe = element.querySelector("iframe");
|
|
106
|
+
if (!iframe) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if the iframe itself is external
|
|
111
|
+
const externalUrl = isExternalUrl(iframe.getAttribute("src"));
|
|
112
|
+
if (externalUrl) {
|
|
113
|
+
return createPlaceholderImage(externalUrl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Try to access iframe document and check for nested external iframes
|
|
117
|
+
let doc: Document;
|
|
118
|
+
try {
|
|
119
|
+
const d = iframe.contentDocument || iframe.contentWindow?.document;
|
|
120
|
+
if (!d?.body) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
doc = d;
|
|
124
|
+
} catch {
|
|
125
|
+
return createPlaceholderImage(null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check for nested external iframes
|
|
129
|
+
for (const nested of doc.querySelectorAll("iframe")) {
|
|
130
|
+
const nestedExternal = isExternalUrl(nested.getAttribute("src"));
|
|
131
|
+
if (nestedExternal) {
|
|
132
|
+
return createPlaceholderImage(nestedExternal);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Capture the iframe content
|
|
137
|
+
try {
|
|
138
|
+
return await toPng(doc.body);
|
|
139
|
+
} catch {
|
|
140
|
+
return createPlaceholderImage(null);
|
|
141
|
+
}
|
|
142
|
+
}
|