@marimo-team/islands 0.20.5-dev83 → 0.20.5-dev87
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 +52 -42
- package/package.json +1 -1
- package/src/components/editor/cell/cell-context-menu.tsx +2 -6
- package/src/components/editor/output/console/ConsoleOutput.tsx +51 -2
- package/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx +94 -3
- package/src/utils/__tests__/copy.test.ts +129 -0
- package/src/utils/copy.ts +43 -0
package/dist/main.js
CHANGED
|
@@ -70720,7 +70720,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
|
|
|
70720
70720
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
70721
70721
|
}
|
|
70722
70722
|
}
|
|
70723
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.20.5-
|
|
70723
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.20.5-dev87"), showCodeInRunModeAtom = atom(true);
|
|
70724
70724
|
atom(null);
|
|
70725
70725
|
var import_compiler_runtime$89 = require_compiler_runtime();
|
|
70726
70726
|
function useKeydownOnElement(e, r) {
|
|
@@ -100209,6 +100209,16 @@ ${c}
|
|
|
100209
100209
|
}, e[0] = c, e[1] = r, e[2] = d) : d = e[2], d;
|
|
100210
100210
|
}
|
|
100211
100211
|
var import_compiler_runtime$4 = require_compiler_runtime();
|
|
100212
|
+
function useDebouncedConsoleOutputs(e) {
|
|
100213
|
+
let [r, c] = (0, import_react.useState)(e), d = (0, import_react.useRef)(null);
|
|
100214
|
+
return e.length > 0 && r !== e && (d.current !== null && (clearTimeout(d.current), d.current = null), c(e)), (0, import_react.useEffect)(() => (e.length === 0 && d.current === null && (d.current = setTimeout(() => {
|
|
100215
|
+
d.current = null, c([]);
|
|
100216
|
+
}, 200)), () => {
|
|
100217
|
+
d.current !== null && (clearTimeout(d.current), d.current = null);
|
|
100218
|
+
}), [
|
|
100219
|
+
e
|
|
100220
|
+
]), r;
|
|
100221
|
+
}
|
|
100212
100222
|
const ConsoleOutput = (e) => {
|
|
100213
100223
|
let r = (0, import_compiler_runtime$4.c)(2), c;
|
|
100214
100224
|
return r[0] === e ? c = r[1] : (c = (0, import_jsx_runtime.jsx)(ErrorBoundary, {
|
|
@@ -100223,26 +100233,26 @@ ${c}
|
|
|
100223
100233
|
value: y,
|
|
100224
100234
|
setValue: S
|
|
100225
100235
|
}, r[0] = y, r[1] = w);
|
|
100226
|
-
let E = useInputHistory(w), { consoleOutputs: O, stale: M, cellName: I, cellId: z, onSubmitDebugger: G, onClear: q, onRefactorWithAI: IY, className: LY } = e, RY = O.length > 0,
|
|
100227
|
-
if (r[2] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel") ? (
|
|
100236
|
+
let E = useInputHistory(w), { consoleOutputs: O, stale: M, cellName: I, cellId: z, onSubmitDebugger: G, onClear: q, onRefactorWithAI: IY, className: LY } = e, RY = useDebouncedConsoleOutputs(O), zY = RY.length > 0, BY = useSelectAllContent(zY), VY = useOverflowDetection(c, zY), HY;
|
|
100237
|
+
if (r[2] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel") ? (HY = () => {
|
|
100228
100238
|
let e2 = c.current;
|
|
100229
100239
|
if (!e2) return;
|
|
100230
100240
|
let r2 = e2.scrollHeight - e2.clientHeight;
|
|
100231
100241
|
r2 - e2.scrollTop < 120 && (e2.scrollTop = r2);
|
|
100232
|
-
}, r[2] =
|
|
100233
|
-
let
|
|
100234
|
-
if (r[3] !== z || r[4] !== LY || r[5] !==
|
|
100242
|
+
}, r[2] = HY) : HY = r[2], (0, import_react.useLayoutEffect)(HY), !zY && isInternalCellName(I)) return null;
|
|
100243
|
+
let UY, WY, GY, KY, qY, JY, YY, XY, ZY, QY;
|
|
100244
|
+
if (r[3] !== z || r[4] !== LY || r[5] !== RY || r[6] !== zY || r[7] !== E || r[8] !== _ || r[9] !== VY || r[10] !== q || r[11] !== IY || r[12] !== G || r[13] !== BY || r[14] !== v || r[15] !== f || r[16] !== M || r[17] !== y || r[18] !== d) {
|
|
100235
100245
|
let e2 = [
|
|
100236
|
-
...
|
|
100237
|
-
].reverse(), w2 = e2.some(_temp$2),
|
|
100238
|
-
r[29] ===
|
|
100239
|
-
let
|
|
100240
|
-
|
|
100246
|
+
...RY
|
|
100247
|
+
].reverse(), w2 = e2.some(_temp$2), O2 = e2.findIndex(_temp2$2), I2;
|
|
100248
|
+
r[29] === RY ? I2 = r[30] : (I2 = () => RY.filter(_temp3$1).map(_temp4$1).join("\n"), r[29] = RY, r[30] = I2);
|
|
100249
|
+
let HY2 = I2;
|
|
100250
|
+
UY = "relative group", r[31] !== HY2 || r[32] !== zY || r[33] !== _ || r[34] !== VY || r[35] !== v || r[36] !== f || r[37] !== d ? (WY = zY && (0, import_jsx_runtime.jsxs)("div", {
|
|
100241
100251
|
className: "absolute top-1 right-4 z-10 opacity-0 group-hover:opacity-100 flex items-center gap-1 print:hidden",
|
|
100242
100252
|
children: [
|
|
100243
100253
|
(0, import_jsx_runtime.jsx)(CopyClipboardIcon, {
|
|
100244
100254
|
tooltip: "Copy console output",
|
|
100245
|
-
value:
|
|
100255
|
+
value: HY2,
|
|
100246
100256
|
className: "h-4 w-4"
|
|
100247
100257
|
}),
|
|
100248
100258
|
(0, import_jsx_runtime.jsx)(Tooltip, {
|
|
@@ -100259,7 +100269,7 @@ ${c}
|
|
|
100259
100269
|
})
|
|
100260
100270
|
})
|
|
100261
100271
|
}),
|
|
100262
|
-
(
|
|
100272
|
+
(VY || _) && (0, import_jsx_runtime.jsx)(Button, {
|
|
100263
100273
|
"aria-label": _ ? "Collapse output" : "Expand output",
|
|
100264
100274
|
className: "p-0 mb-px",
|
|
100265
100275
|
onClick: () => v(!_),
|
|
@@ -100278,16 +100288,16 @@ ${c}
|
|
|
100278
100288
|
})
|
|
100279
100289
|
})
|
|
100280
100290
|
]
|
|
100281
|
-
}), r[31] =
|
|
100282
|
-
let $Y2 = M && "marimo-output-stale", eX2 =
|
|
100283
|
-
r[39] !== LY || r[40] !== $Y2 || r[41] !== eX2 ? (
|
|
100291
|
+
}), r[31] = HY2, r[32] = zY, r[33] = _, r[34] = VY, r[35] = v, r[36] = f, r[37] = d, r[38] = WY) : WY = r[38], GY = M ? "This console output is stale" : void 0, KY = "console-output-area", qY = c, JY = BY, YY = 0;
|
|
100292
|
+
let $Y2 = M && "marimo-output-stale", eX2 = zY ? "p-5" : "p-3";
|
|
100293
|
+
r[39] !== LY || r[40] !== $Y2 || r[41] !== eX2 ? (XY = cn("console-output-area overflow-hidden rounded-b-lg flex flex-col-reverse w-full gap-1 focus:outline-hidden", $Y2, eX2, LY), r[39] = LY, r[40] = $Y2, r[41] = eX2, r[42] = XY) : XY = r[42], r[43] === _ ? ZY = r[44] : (ZY = _ ? {
|
|
100284
100294
|
maxHeight: "none"
|
|
100285
|
-
} : void 0, r[43] = _, r[44] =
|
|
100295
|
+
} : void 0, r[43] = _, r[44] = ZY), QY = e2.map((e3, r2) => {
|
|
100286
100296
|
if (e3.channel === "pdb") return null;
|
|
100287
100297
|
if (e3.channel === "stdin") {
|
|
100288
100298
|
invariant(typeof e3.data == "string", "Expected data to be a string");
|
|
100289
|
-
let c2 =
|
|
100290
|
-
return e3.response == null &&
|
|
100299
|
+
let c2 = RY.length - r2 - 1, d2 = e3.mimetype === "text/password";
|
|
100300
|
+
return e3.response == null && O2 === r2 ? (0, import_jsx_runtime.jsx)(StdInput, {
|
|
100291
100301
|
output: e3.data,
|
|
100292
100302
|
isPdb: w2,
|
|
100293
100303
|
isPassword: d2,
|
|
@@ -100310,36 +100320,36 @@ ${c}
|
|
|
100310
100320
|
wrapText: d
|
|
100311
100321
|
})
|
|
100312
100322
|
}, r2);
|
|
100313
|
-
}), r[3] = z, r[4] = LY, r[5] =
|
|
100314
|
-
} else
|
|
100315
|
-
let
|
|
100316
|
-
r[45] !== z || r[46] !== I ? (
|
|
100323
|
+
}), r[3] = z, r[4] = LY, r[5] = RY, r[6] = zY, r[7] = E, r[8] = _, r[9] = VY, r[10] = q, r[11] = IY, r[12] = G, r[13] = BY, r[14] = v, r[15] = f, r[16] = M, r[17] = y, r[18] = d, r[19] = UY, r[20] = WY, r[21] = GY, r[22] = KY, r[23] = qY, r[24] = JY, r[25] = YY, r[26] = XY, r[27] = ZY, r[28] = QY;
|
|
100324
|
+
} else UY = r[19], WY = r[20], GY = r[21], KY = r[22], qY = r[23], JY = r[24], YY = r[25], XY = r[26], ZY = r[27], QY = r[28];
|
|
100325
|
+
let $Y;
|
|
100326
|
+
r[45] !== z || r[46] !== I ? ($Y = (0, import_jsx_runtime.jsx)(NameCellContentEditable, {
|
|
100317
100327
|
value: I,
|
|
100318
100328
|
cellId: z,
|
|
100319
100329
|
className: "bg-(--slate-4) border-(--slate-4) hover:bg-(--slate-5) dark:border-(--sky-5) dark:bg-(--sky-6) dark:text-(--sky-12) text-(--slate-12) rounded-l rounded-br-lg absolute right-0 bottom-0 text-xs px-1.5 py-0.5 font-mono max-w-[75%] whitespace-nowrap overflow-hidden"
|
|
100320
|
-
}), r[45] = z, r[46] = I, r[47] =
|
|
100321
|
-
let $Y;
|
|
100322
|
-
r[48] !== QY || r[49] !== WY || r[50] !== GY || r[51] !== KY || r[52] !== qY || r[53] !== JY || r[54] !== YY || r[55] !== XY || r[56] !== ZY ? ($Y = (0, import_jsx_runtime.jsxs)("div", {
|
|
100323
|
-
title: WY,
|
|
100324
|
-
"data-testid": GY,
|
|
100325
|
-
ref: KY,
|
|
100326
|
-
...qY,
|
|
100327
|
-
tabIndex: JY,
|
|
100328
|
-
className: YY,
|
|
100329
|
-
style: XY,
|
|
100330
|
-
children: [
|
|
100331
|
-
ZY,
|
|
100332
|
-
QY
|
|
100333
|
-
]
|
|
100334
|
-
}), r[48] = QY, r[49] = WY, r[50] = GY, r[51] = KY, r[52] = qY, r[53] = JY, r[54] = YY, r[55] = XY, r[56] = ZY, r[57] = $Y) : $Y = r[57];
|
|
100330
|
+
}), r[45] = z, r[46] = I, r[47] = $Y) : $Y = r[47];
|
|
100335
100331
|
let eX;
|
|
100336
|
-
|
|
100337
|
-
|
|
100332
|
+
r[48] !== $Y || r[49] !== GY || r[50] !== KY || r[51] !== qY || r[52] !== JY || r[53] !== YY || r[54] !== XY || r[55] !== ZY || r[56] !== QY ? (eX = (0, import_jsx_runtime.jsxs)("div", {
|
|
100333
|
+
title: GY,
|
|
100334
|
+
"data-testid": KY,
|
|
100335
|
+
ref: qY,
|
|
100336
|
+
...JY,
|
|
100337
|
+
tabIndex: YY,
|
|
100338
|
+
className: XY,
|
|
100339
|
+
style: ZY,
|
|
100338
100340
|
children: [
|
|
100339
|
-
|
|
100341
|
+
QY,
|
|
100340
100342
|
$Y
|
|
100341
100343
|
]
|
|
100342
|
-
}), r[
|
|
100344
|
+
}), r[48] = $Y, r[49] = GY, r[50] = KY, r[51] = qY, r[52] = JY, r[53] = YY, r[54] = XY, r[55] = ZY, r[56] = QY, r[57] = eX) : eX = r[57];
|
|
100345
|
+
let tX;
|
|
100346
|
+
return r[58] !== UY || r[59] !== WY || r[60] !== eX ? (tX = (0, import_jsx_runtime.jsxs)("div", {
|
|
100347
|
+
className: UY,
|
|
100348
|
+
children: [
|
|
100349
|
+
WY,
|
|
100350
|
+
eX
|
|
100351
|
+
]
|
|
100352
|
+
}), r[58] = UY, r[59] = WY, r[60] = eX, r[61] = tX) : tX = r[61], tX;
|
|
100343
100353
|
}, StdInput = (e) => {
|
|
100344
100354
|
let r = (0, import_compiler_runtime$4.c)(25), { value: c, setValue: d, inputHistory: f, output: _, isPassword: v, isPdb: y, onSubmit: S, onClear: w } = e, { navigateUp: E, navigateDown: O, addToHistory: M } = f, I;
|
|
100345
100355
|
r[0] === _ ? I = r[1] : (I = renderText(_), r[0] = _, r[1] = I);
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ import { CellOutputId } from "@/core/cells/ids";
|
|
|
25
25
|
import { isOutputEmpty } from "@/core/cells/outputs";
|
|
26
26
|
import { goToDefinitionAtCursorPosition } from "@/core/codemirror/go-to-definition/utils";
|
|
27
27
|
import { sendToPanelManager } from "@/core/vscode/vscode-bindings";
|
|
28
|
-
import { copyToClipboard } from "@/utils/copy";
|
|
28
|
+
import { copyImageToClipboard, copyToClipboard } from "@/utils/copy";
|
|
29
29
|
import { getImageExtension } from "@/utils/filenames";
|
|
30
30
|
import { Logger } from "@/utils/Logger";
|
|
31
31
|
import type { ActionButton } from "../actions/types";
|
|
@@ -127,11 +127,7 @@ export const CellActionsContextMenu = ({
|
|
|
127
127
|
icon: <ClipboardCopyIcon size={13} strokeWidth={1.5} />,
|
|
128
128
|
handle: async () => {
|
|
129
129
|
if (imageRightClicked) {
|
|
130
|
-
|
|
131
|
-
const blob = await response.blob();
|
|
132
|
-
const item = new ClipboardItem({ [blob.type]: blob });
|
|
133
|
-
await navigator.clipboard
|
|
134
|
-
.write([item])
|
|
130
|
+
await copyImageToClipboard(imageRightClicked.src)
|
|
135
131
|
.then(() => {
|
|
136
132
|
toast({
|
|
137
133
|
title: "Copied image to clipboard",
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
ChevronsUpDownIcon,
|
|
7
7
|
WrapTextIcon,
|
|
8
8
|
} from "lucide-react";
|
|
9
|
-
import React, { useLayoutEffect } from "react";
|
|
9
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
10
|
import { ToggleButton } from "react-aria-components";
|
|
11
11
|
import { DebuggerControls } from "@/components/debugger/debugger-code";
|
|
12
12
|
import { CopyClipboardIcon } from "@/components/icons/copy-icon";
|
|
@@ -33,6 +33,52 @@ import { useWrapText } from "../useWrapText";
|
|
|
33
33
|
import { processOutput } from "./process-output";
|
|
34
34
|
import { RenderTextWithLinks } from "./text-rendering";
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Delay in ms before clearing console outputs.
|
|
38
|
+
* This prevents flickering when a cell re-runs and outputs are briefly cleared
|
|
39
|
+
* before new outputs arrive (e.g., plt.show() with a slider).
|
|
40
|
+
*/
|
|
41
|
+
export const CONSOLE_CLEAR_DEBOUNCE_MS = 200;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Debounces the clearing of console outputs.
|
|
45
|
+
* - Non-empty updates are applied immediately.
|
|
46
|
+
* - Transitions to empty are delayed by CONSOLE_CLEAR_DEBOUNCE_MS,
|
|
47
|
+
* giving new outputs a chance to arrive and replace the old ones
|
|
48
|
+
* without a visible flicker.
|
|
49
|
+
*/
|
|
50
|
+
function useDebouncedConsoleOutputs<T>(outputs: T[]): T[] {
|
|
51
|
+
const [debouncedOutputs, setDebouncedOutputs] = useState(outputs);
|
|
52
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
+
|
|
54
|
+
// Non-empty outputs: apply immediately and cancel any pending clear
|
|
55
|
+
if (outputs.length > 0 && debouncedOutputs !== outputs) {
|
|
56
|
+
if (timerRef.current !== null) {
|
|
57
|
+
clearTimeout(timerRef.current);
|
|
58
|
+
timerRef.current = null;
|
|
59
|
+
}
|
|
60
|
+
setDebouncedOutputs(outputs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Empty outputs: delay the clear so new outputs can arrive first
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (outputs.length === 0 && timerRef.current === null) {
|
|
66
|
+
timerRef.current = setTimeout(() => {
|
|
67
|
+
timerRef.current = null;
|
|
68
|
+
setDebouncedOutputs([]);
|
|
69
|
+
}, CONSOLE_CLEAR_DEBOUNCE_MS);
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
if (timerRef.current !== null) {
|
|
73
|
+
clearTimeout(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [outputs]);
|
|
78
|
+
|
|
79
|
+
return debouncedOutputs;
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
interface Props {
|
|
37
83
|
cellId: CellId;
|
|
38
84
|
cellName: string;
|
|
@@ -63,7 +109,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
|
|
|
63
109
|
setValue: setStdinValue,
|
|
64
110
|
});
|
|
65
111
|
const {
|
|
66
|
-
consoleOutputs,
|
|
112
|
+
consoleOutputs: rawConsoleOutputs,
|
|
67
113
|
stale,
|
|
68
114
|
cellName,
|
|
69
115
|
cellId,
|
|
@@ -73,6 +119,9 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
|
|
|
73
119
|
className,
|
|
74
120
|
} = props;
|
|
75
121
|
|
|
122
|
+
// Debounce clearing to prevent flickering when cells re-run
|
|
123
|
+
const consoleOutputs = useDebouncedConsoleOutputs(rawConsoleOutputs);
|
|
124
|
+
|
|
76
125
|
/* The debugger UI needs some work. For now just use the regular
|
|
77
126
|
/* console output. */
|
|
78
127
|
/* if (debuggerActive) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { SetupMocks } from "@/__mocks__/common";
|
|
6
6
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
7
|
import type { CellId } from "@/core/cells/ids";
|
|
8
8
|
import type { WithResponse } from "@/core/cells/types";
|
|
9
9
|
import type { OutputMessage } from "@/core/kernel/messages";
|
|
10
|
-
import { ConsoleOutput } from "../ConsoleOutput";
|
|
10
|
+
import { CONSOLE_CLEAR_DEBOUNCE_MS, ConsoleOutput } from "../ConsoleOutput";
|
|
11
11
|
|
|
12
12
|
SetupMocks.resizeObserver();
|
|
13
13
|
|
|
@@ -193,3 +193,94 @@ describe("ConsoleOutput pdb history", () => {
|
|
|
193
193
|
expect(input).toHaveValue("");
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
describe("ConsoleOutput debounced clearing", () => {
|
|
198
|
+
beforeEach(() => {
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
vi.useRealTimers();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const createOutput = (
|
|
207
|
+
data: string,
|
|
208
|
+
channel = "stdout",
|
|
209
|
+
): WithResponse<OutputMessage> => ({
|
|
210
|
+
channel: channel as "stdout" | "stderr",
|
|
211
|
+
mimetype: "text/plain",
|
|
212
|
+
data,
|
|
213
|
+
timestamp: 0,
|
|
214
|
+
response: undefined,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const defaultProps = {
|
|
218
|
+
cellId: "cell-1" as CellId,
|
|
219
|
+
cellName: "test_cell",
|
|
220
|
+
consoleOutputs: [] as WithResponse<OutputMessage>[],
|
|
221
|
+
stale: false,
|
|
222
|
+
debuggerActive: false,
|
|
223
|
+
onSubmitDebugger: vi.fn(),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
it("should keep old outputs visible when cleared, then show new outputs immediately", () => {
|
|
227
|
+
const outputs1 = [createOutput("hello world")];
|
|
228
|
+
|
|
229
|
+
const { rerender } = renderWithProvider(
|
|
230
|
+
<ConsoleOutput {...defaultProps} consoleOutputs={outputs1} />,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Old output is visible
|
|
234
|
+
expect(screen.getByText("hello world")).toBeInTheDocument();
|
|
235
|
+
|
|
236
|
+
// Clear outputs (simulates cell re-run)
|
|
237
|
+
rerender(
|
|
238
|
+
<TooltipProvider>
|
|
239
|
+
<ConsoleOutput {...defaultProps} consoleOutputs={[]} />
|
|
240
|
+
</TooltipProvider>,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Old output should still be visible during debounce period
|
|
244
|
+
expect(screen.getByText("hello world")).toBeInTheDocument();
|
|
245
|
+
|
|
246
|
+
// New outputs arrive before debounce fires
|
|
247
|
+
const outputs2 = [createOutput("new output")];
|
|
248
|
+
rerender(
|
|
249
|
+
<TooltipProvider>
|
|
250
|
+
<ConsoleOutput {...defaultProps} consoleOutputs={outputs2} />
|
|
251
|
+
</TooltipProvider>,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// New output should be shown immediately
|
|
255
|
+
expect(screen.getByText("new output")).toBeInTheDocument();
|
|
256
|
+
expect(screen.queryByText("hello world")).not.toBeInTheDocument();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should clear outputs after debounce period if no new outputs arrive", () => {
|
|
260
|
+
const outputs1 = [createOutput("old output")];
|
|
261
|
+
|
|
262
|
+
const { rerender } = renderWithProvider(
|
|
263
|
+
<ConsoleOutput {...defaultProps} consoleOutputs={outputs1} />,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(screen.getByText("old output")).toBeInTheDocument();
|
|
267
|
+
|
|
268
|
+
// Clear outputs
|
|
269
|
+
rerender(
|
|
270
|
+
<TooltipProvider>
|
|
271
|
+
<ConsoleOutput {...defaultProps} consoleOutputs={[]} />
|
|
272
|
+
</TooltipProvider>,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Still visible during debounce
|
|
276
|
+
expect(screen.getByText("old output")).toBeInTheDocument();
|
|
277
|
+
|
|
278
|
+
// Advance past debounce period
|
|
279
|
+
act(() => {
|
|
280
|
+
vi.advanceTimersByTime(CONSOLE_CLEAR_DEBOUNCE_MS + 1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Now the output should be cleared
|
|
284
|
+
expect(screen.queryByText("old output")).not.toBeInTheDocument();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { copyImageToClipboard, isSafari } from "../copy";
|
|
4
|
+
|
|
5
|
+
describe("isSafari", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns true for Safari on macOS", () => {
|
|
11
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
12
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
|
13
|
+
);
|
|
14
|
+
expect(isSafari()).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns true for Safari on iOS", () => {
|
|
18
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
19
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
20
|
+
);
|
|
21
|
+
expect(isSafari()).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns false for Chrome", () => {
|
|
25
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
27
|
+
);
|
|
28
|
+
expect(isSafari()).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false for Chrome on iOS (CriOS)", () => {
|
|
32
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
33
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1",
|
|
34
|
+
);
|
|
35
|
+
expect(isSafari()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns false for Firefox on iOS (FxiOS)", () => {
|
|
39
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
40
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/604.1",
|
|
41
|
+
);
|
|
42
|
+
expect(isSafari()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false for Edge on iOS (EdgiOS)", () => {
|
|
46
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
47
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0.0.0 Mobile/15E148 Safari/604.1",
|
|
48
|
+
);
|
|
49
|
+
expect(isSafari()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns false for Firefox on desktop", () => {
|
|
53
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
54
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
55
|
+
);
|
|
56
|
+
expect(isSafari()).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("copyImageToClipboard", () => {
|
|
61
|
+
let writeMock: ReturnType<typeof vi.fn>;
|
|
62
|
+
let clipboardItemSpy: ReturnType<typeof vi.fn>;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
writeMock = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
Object.assign(navigator, {
|
|
67
|
+
clipboard: { write: writeMock },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ClipboardItem is not available in jsdom, so we mock it
|
|
71
|
+
clipboardItemSpy = vi.fn().mockImplementation((data) => ({ data }));
|
|
72
|
+
vi.stubGlobal("ClipboardItem", clipboardItemSpy);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
vi.unstubAllGlobals();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses blob type from response on non-Safari browsers", async () => {
|
|
81
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
82
|
+
"Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const fakeBlob = new Blob(["fake"], { type: "image/jpeg" });
|
|
86
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
87
|
+
new Response(fakeBlob, {
|
|
88
|
+
headers: { "Content-Type": "image/jpeg" },
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await copyImageToClipboard("https://example.com/image.jpg");
|
|
93
|
+
|
|
94
|
+
expect(writeMock).toHaveBeenCalledOnce();
|
|
95
|
+
// Non-Safari path: awaits blob, uses blob.type as key
|
|
96
|
+
const arg = clipboardItemSpy.mock.calls[0][0];
|
|
97
|
+
expect(arg).toHaveProperty("image/jpeg");
|
|
98
|
+
expect(arg["image/jpeg"].type).toBe("image/jpeg");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("uses image/png on Safari", async () => {
|
|
102
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
103
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const fakeBlob = new Blob(["fake"], { type: "image/png" });
|
|
107
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeBlob));
|
|
108
|
+
|
|
109
|
+
await copyImageToClipboard("https://example.com/image.png");
|
|
110
|
+
|
|
111
|
+
expect(writeMock).toHaveBeenCalledOnce();
|
|
112
|
+
// Safari path: uses "image/png" key with a Promise<Blob>
|
|
113
|
+
expect(clipboardItemSpy).toHaveBeenCalledWith({
|
|
114
|
+
"image/png": expect.any(Promise),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("propagates fetch errors", async () => {
|
|
119
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
|
120
|
+
"Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
copyImageToClipboard("https://example.com/image.png"),
|
|
127
|
+
).rejects.toThrow("Network error");
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/utils/copy.ts
CHANGED
|
@@ -21,3 +21,46 @@ export async function copyToClipboard(text: string) {
|
|
|
21
21
|
window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns true if the current browser is Safari.
|
|
27
|
+
*
|
|
28
|
+
* Safari requires special handling for clipboard operations because it
|
|
29
|
+
* drops the user-activation context during async operations like fetch.
|
|
30
|
+
*/
|
|
31
|
+
export function isSafari(): boolean {
|
|
32
|
+
const ua = navigator.userAgent;
|
|
33
|
+
// Safari includes "Safari" but not "Chrome"/"Chromium" in its UA string.
|
|
34
|
+
// iOS in-app browsers (CriOS, FxiOS, EdgiOS) also include "Safari"
|
|
35
|
+
// but are excluded by checking for their specific tokens.
|
|
36
|
+
return (
|
|
37
|
+
/safari/i.test(ua) && !/chrome|chromium|crios|fxios|edgios|opios/i.test(ua)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Copies an image to the clipboard from a URL.
|
|
43
|
+
*
|
|
44
|
+
* On Safari, the ClipboardItem is constructed synchronously with a
|
|
45
|
+
* Promise<Blob> to preserve the user-activation context, which Safari
|
|
46
|
+
* drops during async operations like fetch. This means we must assume
|
|
47
|
+
* the MIME type (image/png) since we can't inspect the response first.
|
|
48
|
+
*
|
|
49
|
+
* On other browsers, we await the fetch and use the actual MIME type.
|
|
50
|
+
*/
|
|
51
|
+
export async function copyImageToClipboard(imageSrc: string): Promise<void> {
|
|
52
|
+
let item: ClipboardItem;
|
|
53
|
+
if (isSafari()) {
|
|
54
|
+
// Safari drops user-activation context during await, so we must
|
|
55
|
+
// construct the ClipboardItem synchronously with a Promise<Blob>.
|
|
56
|
+
item = new ClipboardItem({
|
|
57
|
+
"image/png": fetch(imageSrc).then((response) => response.blob()),
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
const response = await fetch(imageSrc);
|
|
61
|
+
const blob = await response.blob();
|
|
62
|
+
item = new ClipboardItem({ [blob.type]: blob });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await navigator.clipboard.write([item]);
|
|
66
|
+
}
|