@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.
- package/dist/{any-language-editor-DlsjUw_l.js → any-language-editor-BRpxklRq.js} +1 -1
- package/dist/{copy-DIK6DiIA.js → copy-BjkXCUxP.js} +12 -2
- package/dist/{esm-BLobyqMs.js → esm-No_6eSQS.js} +1 -1
- package/dist/{glide-data-editor-pZyd9UJ_.js → glide-data-editor-858wsVkd.js} +1 -1
- package/dist/main.js +546 -399
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/app-config/user-config-form.tsx +5 -4
- package/src/components/data-table/__tests__/utils.test.ts +138 -1
- package/src/components/data-table/context-menu.tsx +9 -5
- package/src/components/data-table/data-table.tsx +3 -0
- package/src/components/data-table/range-focus/__tests__/atoms.test.ts +8 -2
- package/src/components/data-table/range-focus/__tests__/test-utils.ts +2 -0
- package/src/components/data-table/range-focus/__tests__/utils.test.ts +82 -8
- package/src/components/data-table/range-focus/atoms.ts +2 -2
- package/src/components/data-table/range-focus/utils.ts +50 -12
- package/src/components/data-table/types.ts +7 -0
- package/src/components/data-table/utils.ts +87 -0
- package/src/components/ui/range-slider.tsx +108 -1
- package/src/core/codemirror/lsp/notebook-lsp.ts +28 -2
- package/src/css/md.css +7 -0
- package/src/plugins/core/sanitize-html.ts +25 -18
- package/src/plugins/impl/DataTablePlugin.tsx +23 -2
- package/src/plugins/impl/SliderPlugin.tsx +1 -3
- package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +120 -0
- package/src/utils/__tests__/download.test.tsx +2 -2
- package/src/utils/copy.ts +18 -5
- package/src/utils/download.ts +4 -3
- 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={
|
|
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
|
|
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:
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 {
|
|
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
|
-
|
|
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}
|
|
@@ -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: "
|
|
441
|
-
description: "Failed
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
19
|
-
|
|
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
|
});
|
package/src/utils/download.ts
CHANGED
|
@@ -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: "
|
|
162
|
-
description:
|
|
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
|
},
|