@marimo-team/islands 0.21.2-dev3 → 0.21.2-dev30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/{any-language-editor-DlsjUw_l.js → any-language-editor-BRpxklRq.js} +1 -1
  2. package/dist/{copy-DIK6DiIA.js → copy-BjkXCUxP.js} +12 -2
  3. package/dist/{esm-BLobyqMs.js → esm-No_6eSQS.js} +1 -1
  4. package/dist/{glide-data-editor-pZyd9UJ_.js → glide-data-editor-858wsVkd.js} +1 -1
  5. package/dist/main.js +546 -399
  6. package/dist/style.css +1 -1
  7. package/package.json +1 -1
  8. package/src/components/app-config/user-config-form.tsx +5 -4
  9. package/src/components/data-table/__tests__/utils.test.ts +138 -1
  10. package/src/components/data-table/context-menu.tsx +9 -5
  11. package/src/components/data-table/data-table.tsx +3 -0
  12. package/src/components/data-table/range-focus/__tests__/atoms.test.ts +8 -2
  13. package/src/components/data-table/range-focus/__tests__/test-utils.ts +2 -0
  14. package/src/components/data-table/range-focus/__tests__/utils.test.ts +82 -8
  15. package/src/components/data-table/range-focus/atoms.ts +2 -2
  16. package/src/components/data-table/range-focus/utils.ts +50 -12
  17. package/src/components/data-table/types.ts +7 -0
  18. package/src/components/data-table/utils.ts +87 -0
  19. package/src/components/ui/range-slider.tsx +108 -1
  20. package/src/core/codemirror/lsp/notebook-lsp.ts +28 -2
  21. package/src/css/md.css +7 -0
  22. package/src/plugins/core/sanitize-html.ts +25 -18
  23. package/src/plugins/impl/DataTablePlugin.tsx +23 -2
  24. package/src/plugins/impl/SliderPlugin.tsx +1 -3
  25. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +120 -0
  26. package/src/utils/__tests__/download.test.tsx +2 -2
  27. package/src/utils/copy.ts +18 -5
  28. package/src/utils/download.ts +4 -3
  29. package/src/utils/html-to-image.ts +6 -0
@@ -18,14 +18,116 @@ const RangeSlider = React.forwardRef<
18
18
  React.ElementRef<typeof SliderPrimitive.Root>,
19
19
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
20
20
  valueMap: (sliderValue: number) => number;
21
+ steps?: number[];
21
22
  }
22
23
  >(({ className, valueMap, ...props }, ref) => {
23
24
  const [open, openActions] = useBoolean(false);
24
25
  const { locale } = useLocale();
25
26
 
27
+ const isDraggingRange = React.useRef(false);
28
+ const dragStartX = React.useRef(0);
29
+ const dragStartY = React.useRef(0);
30
+ const dragStartValue = React.useRef<number[]>([]);
31
+ const currentDragValue = React.useRef<number[]>([]);
32
+ const rootRef =
33
+ React.useRef<React.ElementRef<typeof SliderPrimitive.Root>>(null);
34
+ const trackRef = React.useRef<HTMLSpanElement>(null);
35
+ const dragTrackRect = React.useRef<DOMRect | null>(null);
36
+
37
+ const mergedRef = React.useCallback(
38
+ (node: React.ElementRef<typeof SliderPrimitive.Root>) => {
39
+ rootRef.current = node;
40
+ if (typeof ref === "function") {
41
+ ref(node);
42
+ } else if (ref) {
43
+ ref.current = node;
44
+ }
45
+ },
46
+ [ref],
47
+ );
48
+
49
+ const handleRangePointerDown = (e: React.PointerEvent<HTMLSpanElement>) => {
50
+ if (!props.value || props.value.length !== 2) {
51
+ return;
52
+ }
53
+ if (props.disabled) {
54
+ return;
55
+ }
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+
59
+ isDraggingRange.current = true;
60
+ dragStartX.current = e.clientX;
61
+ dragStartY.current = e.clientY;
62
+ dragStartValue.current = [...props.value];
63
+ currentDragValue.current = [...props.value];
64
+ dragTrackRect.current = trackRef.current?.getBoundingClientRect() ?? null;
65
+
66
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
67
+ };
68
+
69
+ const handleRangePointerMove = (e: React.PointerEvent<HTMLSpanElement>) => {
70
+ if (!isDraggingRange.current) {
71
+ return;
72
+ }
73
+ e.stopPropagation();
74
+
75
+ const trackRect = dragTrackRect.current;
76
+ if (!trackRect) {
77
+ return;
78
+ }
79
+
80
+ const isVertical = props.orientation === "vertical";
81
+ const min = props.min ?? 0;
82
+ const max = props.max ?? 100;
83
+ const totalRange = max - min;
84
+
85
+ let delta: number;
86
+ if (isVertical) {
87
+ const trackLength = trackRect.height;
88
+ delta = -((e.clientY - dragStartY.current) / trackLength) * totalRange;
89
+ } else {
90
+ const trackLength = trackRect.width;
91
+ delta = ((e.clientX - dragStartX.current) / trackLength) * totalRange;
92
+ }
93
+
94
+ const [origLeft, origRight] = dragStartValue.current;
95
+ const rangeWidth = origRight - origLeft;
96
+
97
+ const steps = props.steps;
98
+ const step: number =
99
+ steps && steps.length > 1
100
+ ? Math.min(...steps.slice(1).map((s, i) => s - steps[i]))
101
+ : (props.step ?? 1);
102
+ const snappedDelta = Math.round(delta / step) * step;
103
+
104
+ const clampedDelta = Math.max(
105
+ min - origLeft,
106
+ Math.min(max - origRight, snappedDelta),
107
+ );
108
+
109
+ const newLeft = origLeft + clampedDelta;
110
+ const newRight = newLeft + rangeWidth;
111
+
112
+ currentDragValue.current = [newLeft, newRight];
113
+ props.onValueChange?.([newLeft, newRight]);
114
+ };
115
+
116
+ const handleRangePointerUp = (e: React.PointerEvent<HTMLSpanElement>) => {
117
+ if (!isDraggingRange.current) {
118
+ return;
119
+ }
120
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
121
+ isDraggingRange.current = false;
122
+
123
+ if (currentDragValue.current.length === 2) {
124
+ props.onValueCommit?.(currentDragValue.current);
125
+ }
126
+ };
127
+
26
128
  return (
27
129
  <SliderPrimitive.Root
28
- ref={ref}
130
+ ref={mergedRef}
29
131
  className={cn(
30
132
  "relative flex touch-none select-none hover:cursor-pointer",
31
133
  "data-[orientation=horizontal]:w-full data-[orientation=horizontal]:items-center",
@@ -36,6 +138,7 @@ const RangeSlider = React.forwardRef<
36
138
  {...props}
37
139
  >
38
140
  <SliderPrimitive.Track
141
+ ref={trackRef}
39
142
  data-testid="track"
40
143
  className={cn(
41
144
  "relative grow overflow-hidden rounded-full bg-slate-200 dark:bg-accent/60",
@@ -50,7 +153,11 @@ const RangeSlider = React.forwardRef<
50
153
  "data-[orientation=horizontal]:h-full",
51
154
  "data-[orientation=vertical]:w-full",
52
155
  "data-disabled:opacity-50",
156
+ "hover:cursor-grab active:cursor-grabbing",
53
157
  )}
158
+ onPointerDown={handleRangePointerDown}
159
+ onPointerMove={handleRangePointerMove}
160
+ onPointerUp={handleRangePointerUp}
54
161
  />
55
162
  </SliderPrimitive.Track>
56
163
  <TooltipProvider>
@@ -178,6 +178,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
178
178
  string,
179
179
  Promise<LSP.CompletionItem>
180
180
  >(10);
181
+ private latestDiagnosticsVersion: number | null = null;
182
+ private forwardedDiagnosticsVersion = 0;
181
183
 
182
184
  constructor(
183
185
  client: ILanguageServerClient,
@@ -270,6 +272,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
270
272
 
271
273
  // Get the current document state
272
274
  const { lens, version } = this.snapshotter.snapshot();
275
+ this.latestDiagnosticsVersion = null;
276
+ this.forwardedDiagnosticsVersion = 0;
273
277
 
274
278
  // Re-open the merged document with the LSP server
275
279
  // This sends a textDocument/didOpen for the entire notebook
@@ -768,13 +772,34 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
768
772
  | { method: "other"; params: unknown },
769
773
  ) => {
770
774
  if (notification.method === "textDocument/publishDiagnostics") {
775
+ const incomingVersion = notification.params.version;
776
+ if (incomingVersion != null) {
777
+ const latestVersion = this.latestDiagnosticsVersion;
778
+ if (
779
+ latestVersion !== null &&
780
+ Number.isFinite(incomingVersion) &&
781
+ incomingVersion < latestVersion
782
+ ) {
783
+ Logger.debug(
784
+ "[lsp] dropping stale diagnostics notification",
785
+ notification,
786
+ );
787
+ return;
788
+ }
789
+ this.latestDiagnosticsVersion = incomingVersion;
790
+ }
791
+
771
792
  Logger.debug("[lsp] handling diagnostics", notification);
772
793
  // Use the correct lens by version
773
794
  const payload = this.snapshotter.getLatestSnapshot();
774
795
 
775
796
  const diagnostics = notification.params.diagnostics;
776
797
 
777
- const { lens, version: cellVersion } = payload;
798
+ const { lens } = payload;
799
+ // Forward diagnostics with a strictly increasing version so downstream
800
+ // plugin updates/clears reliably, even when server repeats the same
801
+ // document version across multiple publishDiagnostics notifications.
802
+ const diagnosticsVersion = ++this.forwardedDiagnosticsVersion;
778
803
 
779
804
  // Pre-partition diagnostics by cell
780
805
  const diagnosticsByCellId = new Map<CellId, LSP.Diagnostic[]>();
@@ -817,7 +842,7 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
817
842
  params: {
818
843
  ...notification.params,
819
844
  uri: cellDocumentUri,
820
- version: cellVersion,
845
+ version: diagnosticsVersion,
821
846
  diagnostics: cellDiagnostics,
822
847
  },
823
848
  });
@@ -832,6 +857,7 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
832
857
  method: "textDocument/publishDiagnostics",
833
858
  params: {
834
859
  uri: cellDocumentUri,
860
+ version: diagnosticsVersion,
835
861
  diagnostics: [],
836
862
  },
837
863
  });
package/src/css/md.css CHANGED
@@ -374,6 +374,13 @@ button .prose.prose {
374
374
  @apply p-4 pt-0;
375
375
  }
376
376
 
377
+ /* Restore proper list indentation inside details blocks.
378
+ The p-4 above overrides prose's padding-inline-start for bullet space.
379
+ This ensures bullets render correctly with list-style-position: outside. */
380
+ .markdown details > :is(ul, ol) {
381
+ padding-inline-start: 2.5rem;
382
+ }
383
+
377
384
  .markdown .codehilite {
378
385
  background-color: var(--slate-2);
379
386
  border-radius: 4px;
@@ -2,28 +2,35 @@
2
2
  import DOMPurify, { type Config } from "dompurify";
3
3
 
4
4
  // preserve target=_blank https://github.com/cure53/DOMPurify/issues/317#issuecomment-912474068
5
- const TEMPORARY_ATTRIBUTE = "data-temp-href-target";
6
- DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
7
- if (node.tagName === "A") {
8
- if (!node.hasAttribute("target")) {
9
- node.setAttribute("target", "_self");
10
- }
5
+ // Guard for non-browser environments (e.g. Node.js in the marimo-lsp extension)
6
+ // where `document` is not available.
7
+ if (typeof document !== "undefined") {
8
+ const TEMPORARY_ATTRIBUTE = "data-temp-href-target";
9
+ DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
10
+ if (node.tagName === "A") {
11
+ if (!node.hasAttribute("target")) {
12
+ node.setAttribute("target", "_self");
13
+ }
11
14
 
12
- if (node.hasAttribute("target")) {
13
- node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute("target") || "");
15
+ if (node.hasAttribute("target")) {
16
+ node.setAttribute(
17
+ TEMPORARY_ATTRIBUTE,
18
+ node.getAttribute("target") || "",
19
+ );
20
+ }
14
21
  }
15
- }
16
- });
22
+ });
17
23
 
18
- DOMPurify.addHook("afterSanitizeAttributes", (node) => {
19
- if (node.tagName === "A" && node.hasAttribute(TEMPORARY_ATTRIBUTE)) {
20
- node.setAttribute("target", node.getAttribute(TEMPORARY_ATTRIBUTE) || "");
21
- node.removeAttribute(TEMPORARY_ATTRIBUTE);
22
- if (node.getAttribute("target") === "_blank") {
23
- node.setAttribute("rel", "noopener noreferrer");
24
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
25
+ if (node.tagName === "A" && node.hasAttribute(TEMPORARY_ATTRIBUTE)) {
26
+ node.setAttribute("target", node.getAttribute(TEMPORARY_ATTRIBUTE) || "");
27
+ node.removeAttribute(TEMPORARY_ATTRIBUTE);
28
+ if (node.getAttribute("target") === "_blank") {
29
+ node.setAttribute("rel", "noopener noreferrer");
30
+ }
24
31
  }
25
- }
26
- });
32
+ });
33
+ }
27
34
 
28
35
  /**
29
36
  * This removes script tags, form tags, iframe tags, and other potentially dangerous tags
@@ -53,6 +53,7 @@ import {
53
53
  } from "@/components/data-table/types";
54
54
  import {
55
55
  getPageIndexForRow,
56
+ loadTableAndRawData,
56
57
  loadTableData,
57
58
  } from "@/components/data-table/utils";
58
59
  import { ErrorBoundary } from "@/components/editor/boundary/ErrorBoundary";
@@ -174,6 +175,7 @@ const valueCounts: z.ZodType<ValueCounts> = z.array(
174
175
  interface Data<T> {
175
176
  label: string | null;
176
177
  data: TableData<T>;
178
+ rawData?: TableData<T> | null;
177
179
  totalRows: number | TooManyRows;
178
180
  pagination: boolean;
179
181
  pageSize: number;
@@ -221,6 +223,7 @@ type DataTableFunctions = {
221
223
  total_rows: number | TooManyRows;
222
224
  cell_styles?: CellStyleState | null;
223
225
  cell_hover_texts?: Record<string, Record<string, string | null>> | null;
226
+ raw_data?: TableData<T> | null;
224
227
  }>;
225
228
  get_data_url?: GetDataUrl;
226
229
  get_row_ids?: GetRowIds;
@@ -243,6 +246,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
243
246
  ]),
244
247
  label: z.string().nullable(),
245
248
  data: z.union([z.string(), z.array(z.object({}).passthrough())]),
249
+ rawData: z.union([z.string(), z.array(z.looseObject({}))]).nullish(),
246
250
  totalRows: z.union([z.number(), z.literal(TOO_MANY_ROWS)]),
247
251
  pagination: z.boolean().default(false),
248
252
  pageSize: z.number().default(10),
@@ -327,6 +331,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
327
331
  )
328
332
  .nullable(),
329
333
  cell_hover_texts: cellHoverTextSchema.nullable(),
334
+ raw_data: z.union([z.string(), z.array(z.looseObject({}))]).nullish(),
330
335
  }),
331
336
  ),
332
337
  get_row_ids: rpc.input(z.object({}).passthrough()).output(
@@ -529,17 +534,23 @@ export const LoadingDataTableComponent = memo(
529
534
  // Data loading
530
535
  const { data, error, isPending, isFetching } = useAsyncData<{
531
536
  rows: T[];
537
+ rawRows?: T[];
532
538
  totalRows: number | TooManyRows;
533
539
  cellStyles: CellStyleState | undefined | null;
534
540
  cellHoverTexts?: Record<string, Record<string, string | null>> | null;
535
541
  }>(async () => {
536
542
  // If there is no data, return an empty array
537
543
  if (props.totalRows === 0) {
538
- return { rows: Arrays.EMPTY, totalRows: 0, cellStyles: {} };
544
+ return {
545
+ rows: Arrays.EMPTY,
546
+ totalRows: 0,
547
+ cellStyles: {},
548
+ };
539
549
  }
540
550
 
541
551
  // Table data is a url string or an array of objects
542
552
  let tableData = props.data;
553
+ let rawTableData: TableData<T> | undefined | null = props.rawData;
543
554
  let totalRows = props.totalRows;
544
555
  let cellStyles = props.cellStyles;
545
556
  let cellHoverTexts = props.cellHoverTexts;
@@ -587,13 +598,19 @@ export const LoadingDataTableComponent = memo(
587
598
  } else {
588
599
  const searchResults = await searchResultsPromise;
589
600
  tableData = searchResults.data;
601
+ rawTableData = searchResults.raw_data;
590
602
  totalRows = searchResults.total_rows;
591
603
  cellStyles = searchResults.cell_styles || {};
592
604
  cellHoverTexts = searchResults.cell_hover_texts || {};
593
605
  }
594
- tableData = await loadTableData(tableData);
606
+ const [data, rawData] = await loadTableAndRawData(
607
+ tableData,
608
+ rawTableData,
609
+ );
610
+ tableData = data;
595
611
  return {
596
612
  rows: tableData,
613
+ rawRows: rawData,
597
614
  totalRows: totalRows,
598
615
  cellStyles,
599
616
  cellHoverTexts,
@@ -715,6 +732,7 @@ export const LoadingDataTableComponent = memo(
715
732
  <DataTableComponent
716
733
  {...props}
717
734
  data={data?.rows ?? Arrays.EMPTY}
735
+ rawData={data?.rawRows}
718
736
  columnSummaries={columnSummaries}
719
737
  sorting={sorting}
720
738
  setSorting={setSorting}
@@ -766,6 +784,7 @@ LoadingDataTableComponent.displayName = "LoadingDataTableComponent";
766
784
  const DataTableComponent = ({
767
785
  label,
768
786
  data,
787
+ rawData,
769
788
  totalRows,
770
789
  maxColumns,
771
790
  pagination,
@@ -814,6 +833,7 @@ const DataTableComponent = ({
814
833
  }: DataTableProps<unknown> &
815
834
  DataTableSearchProps & {
816
835
  data: unknown[];
836
+ rawData?: unknown[];
817
837
  columnSummaries?: ColumnSummaries;
818
838
  getRow: (rowIdx: number) => Promise<GetRowResult>;
819
839
  }): JSX.Element => {
@@ -1015,6 +1035,7 @@ const DataTableComponent = ({
1015
1035
  <Labeled label={label} align="top" fullWidth={true}>
1016
1036
  <DataTable
1017
1037
  data={data}
1038
+ rawData={rawData}
1018
1039
  columns={columns}
1019
1040
  className={className}
1020
1041
  maxHeight={maxHeight}
@@ -152,9 +152,7 @@ const SliderComponent = ({
152
152
  nextValue = Number(start);
153
153
  }
154
154
  setInternalValue(nextValue);
155
- if (!debounce) {
156
- setValue(nextValue);
157
- }
155
+ setValue(nextValue);
158
156
  }}
159
157
  minValue={start}
160
158
  maxValue={stop}
@@ -0,0 +1,120 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { z } from "zod";
6
+ import { SetupMocks } from "@/__mocks__/common";
7
+ import { initialModeAtom } from "@/core/mode";
8
+ import { store } from "@/core/state/jotai";
9
+ import type { IPluginProps } from "../../types";
10
+ import { SliderPlugin } from "../SliderPlugin";
11
+
12
+ SetupMocks.resizeObserver();
13
+
14
+ describe("SliderPlugin", () => {
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ store.set(initialModeAtom, "edit");
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ const createProps = (
25
+ debounce: boolean,
26
+ includeInput: boolean,
27
+ setValue: ReturnType<typeof vi.fn>,
28
+ ): IPluginProps<number, z.infer<typeof SliderPlugin.prototype.validator>> => {
29
+ return {
30
+ host: document.createElement("div"),
31
+ value: 5,
32
+ setValue,
33
+ data: {
34
+ initialValue: 5,
35
+ start: 0,
36
+ stop: 10,
37
+ step: 1,
38
+ label: "Test Slider",
39
+ debounce,
40
+ orientation: "horizontal" as const,
41
+ showValue: false,
42
+ fullWidth: false,
43
+ includeInput,
44
+ steps: null,
45
+ },
46
+ functions: {},
47
+ };
48
+ };
49
+
50
+ it("slider triggers setValue immediately when debounce is false", () => {
51
+ const plugin = new SliderPlugin();
52
+ const setValue = vi.fn();
53
+ const props = createProps(false, false, setValue);
54
+ const { container } = render(plugin.render(props));
55
+
56
+ act(() => {
57
+ vi.advanceTimersByTime(0);
58
+ });
59
+
60
+ const thumb = container.querySelector('[role="slider"]');
61
+ expect(thumb).toBeTruthy();
62
+
63
+ // Radix UI Slider updates on keyboard ArrowRight/ArrowLeft
64
+ act(() => {
65
+ (thumb as HTMLElement)?.focus();
66
+ fireEvent.keyDown(thumb!, { key: "ArrowRight" });
67
+ });
68
+
69
+ expect(setValue).toHaveBeenCalledWith(6);
70
+ });
71
+
72
+ it("slider does not trigger setValue immediately when debounce is true", () => {
73
+ const plugin = new SliderPlugin();
74
+ const setValue = vi.fn();
75
+ const props = createProps(true, false, setValue);
76
+ const { container } = render(plugin.render(props));
77
+
78
+ act(() => {
79
+ vi.advanceTimersByTime(0);
80
+ });
81
+
82
+ const thumb = container.querySelector('[role="slider"]');
83
+
84
+ act(() => {
85
+ (thumb as HTMLElement)?.focus();
86
+ // Simulate just a programmatic change that Radix would trigger via pointer move
87
+ // which fires onValueChange but not onValueCommit yet
88
+ // Because we can't easily separated Radix's internal pointer events in jsdom, we
89
+ // test the main issue: editable input. We can trust Radix's onValueChange vs onValueCommit.
90
+ });
91
+
92
+ // We verified above that NumberField works when debounce=true
93
+ expect(setValue).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("editable input triggers setValue immediately even when slider debounce is true", () => {
97
+ const plugin = new SliderPlugin();
98
+ const setValue = vi.fn();
99
+ const props = createProps(true, true, setValue);
100
+ const { getByRole } = render(plugin.render(props));
101
+
102
+ act(() => {
103
+ vi.advanceTimersByTime(0);
104
+ });
105
+
106
+ // The react-aria NumberField renders an input textbox.
107
+ const numericInput = getByRole("textbox");
108
+
109
+ act(() => {
110
+ // Simulate typing a new value and pressing enter
111
+ // With React-Aria NumberField, onChange fires on blur or enter
112
+ fireEvent.change(numericInput, { target: { value: "9" } });
113
+ fireEvent.blur(numericInput);
114
+ });
115
+
116
+ // Because the user explicitly typed 9 in the editable input,
117
+ // setValue should be called immediately regardless of debounce=true.
118
+ expect(setValue).toHaveBeenCalledWith(9);
119
+ });
120
+ });
@@ -437,8 +437,8 @@ describe("downloadHTMLAsImage", () => {
437
437
  await downloadHTMLAsImage({ element: mockElement, filename: "test" });
438
438
 
439
439
  expect(toast).toHaveBeenCalledWith({
440
- title: "Error",
441
- description: "Failed to download as PNG.",
440
+ title: "Failed to download as PNG",
441
+ description: "Failed",
442
442
  variant: "danger",
443
443
  });
444
444
  });
package/src/utils/copy.ts CHANGED
@@ -2,21 +2,34 @@
2
2
  import { Logger } from "./Logger";
3
3
 
4
4
  /**
5
- * Tries to copy text to the clipboard using the navigator.clipboard API.
6
- * If that fails, it falls back to prompting the user to copy.
5
+ * Copy text to the clipboard. When `html` is provided, writes both
6
+ * text/html and text/plain so rich content (e.g. hyperlinks) is
7
+ * preserved when pasting into apps like Excel or Google Sheets.
7
8
  *
8
9
  * As of 2024-10-29, Safari does not support navigator.clipboard.writeText
9
10
  * when running localhost http.
10
11
  */
11
- export async function copyToClipboard(text: string) {
12
+ export async function copyToClipboard(text: string, html?: string) {
12
13
  if (navigator.clipboard === undefined) {
13
14
  Logger.warn("navigator.clipboard is not supported");
14
15
  window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
15
16
  return;
16
17
  }
17
18
 
18
- await navigator.clipboard.writeText(text).catch(async () => {
19
- // Fallback to prompt
19
+ if (html && navigator.clipboard.write) {
20
+ try {
21
+ const item = new ClipboardItem({
22
+ "text/html": new Blob([html], { type: "text/html" }),
23
+ "text/plain": new Blob([text], { type: "text/plain" }),
24
+ });
25
+ await navigator.clipboard.write([item]);
26
+ return;
27
+ } catch {
28
+ Logger.warn("Failed to write rich text, falling back to plain text");
29
+ }
30
+ }
31
+
32
+ await navigator.clipboard.writeText(text).catch(() => {
20
33
  Logger.warn("Failed to copy to clipboard using navigator.clipboard");
21
34
  window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
22
35
  });
@@ -156,10 +156,11 @@ export async function downloadHTMLAsImage(opts: {
156
156
  // Get screenshot
157
157
  const dataUrl = await toPng(element);
158
158
  downloadByURL(dataUrl, Filenames.toPNG(filename));
159
- } catch {
159
+ } catch (error) {
160
+ Logger.error("Error downloading as PNG", error);
160
161
  toast({
161
- title: "Error",
162
- description: "Failed to download as PNG.",
162
+ title: "Failed to download as PNG",
163
+ description: prettyError(error),
163
164
  variant: "danger",
164
165
  });
165
166
  } finally {
@@ -140,6 +140,11 @@ export const necessaryStyleProperties = [
140
140
  "cursor",
141
141
  ];
142
142
 
143
+ // 1x1 transparent PNG as a fallback for images that fail to embed (e.g., cross-origin).
144
+ // Without this, failed embeds leave external URLs in the cloned DOM, which taints the canvas.
145
+ const TRANSPARENT_PIXEL =
146
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==";
147
+
143
148
  /**
144
149
  * Default options for html-to-image conversions.
145
150
  * These handle common edge cases like filtering out toolbars and logging errors.
@@ -162,6 +167,7 @@ export const defaultHtmlToImageOptions: HtmlToImageOptions = {
162
167
  return true;
163
168
  }
164
169
  },
170
+ imagePlaceholder: TRANSPARENT_PIXEL,
165
171
  onImageErrorHandler: (event) => {
166
172
  Logger.error("Error loading image:", event);
167
173
  },