@marimo-team/islands 0.21.2-dev26 → 0.21.2-dev28
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 +376 -308
- package/package.json +1 -1
- 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/plugins/impl/DataTablePlugin.tsx +23 -2
- package/src/utils/copy.ts +18 -5
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { Table } from "@tanstack/react-table";
|
|
3
4
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getClipboardContent,
|
|
7
|
+
getPageIndexForRow,
|
|
8
|
+
getRawValue,
|
|
9
|
+
stringifyUnknownValue,
|
|
10
|
+
} from "../utils";
|
|
5
11
|
|
|
6
12
|
describe("getPageIndexForRow", () => {
|
|
7
13
|
it("should return null when row is on current page", () => {
|
|
@@ -77,3 +83,134 @@ describe("getPageIndexForRow", () => {
|
|
|
77
83
|
expect(getPageIndexForRow(999, 100, 10)).toBe(99);
|
|
78
84
|
});
|
|
79
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("stringifyUnknownValue", () => {
|
|
88
|
+
it("should stringify primitives", () => {
|
|
89
|
+
expect(stringifyUnknownValue({ value: "hello" })).toBe("hello");
|
|
90
|
+
expect(stringifyUnknownValue({ value: 42 })).toBe("42");
|
|
91
|
+
expect(stringifyUnknownValue({ value: true })).toBe("true");
|
|
92
|
+
expect(stringifyUnknownValue({ value: null })).toBe("null");
|
|
93
|
+
expect(stringifyUnknownValue({ value: undefined })).toBe("undefined");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should stringify null as empty string when flag is set", () => {
|
|
97
|
+
expect(
|
|
98
|
+
stringifyUnknownValue({ value: null, nullAsEmptyString: true }),
|
|
99
|
+
).toBe("");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should JSON-stringify plain objects", () => {
|
|
103
|
+
expect(stringifyUnknownValue({ value: { x: 1 } })).toBe('{"x":1}');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("getClipboardContent", () => {
|
|
108
|
+
it("should use rawValue for text when it differs from displayedValue", () => {
|
|
109
|
+
const displayed = {
|
|
110
|
+
_serialized_mime_bundle: {
|
|
111
|
+
mimetype: "text/html",
|
|
112
|
+
data: '<a href="https://example.com">42</a>',
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const result = getClipboardContent(42, displayed);
|
|
116
|
+
expect(result.text).toBe("42");
|
|
117
|
+
expect(result.html).toBe('<a href="https://example.com">42</a>');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should strip html for text when rawValue equals displayedValue", () => {
|
|
121
|
+
const mimeBundle = {
|
|
122
|
+
_serialized_mime_bundle: {
|
|
123
|
+
mimetype: "text/html",
|
|
124
|
+
data: "<b>bold</b>",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const result = getClipboardContent(mimeBundle, mimeBundle);
|
|
128
|
+
expect(result.text).toBe("bold");
|
|
129
|
+
expect(result.html).toBe("<b>bold</b>");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle undefined rawValue", () => {
|
|
133
|
+
const displayed = {
|
|
134
|
+
_serialized_mime_bundle: {
|
|
135
|
+
mimetype: "text/html",
|
|
136
|
+
data: "<b>hello</b>",
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const result = getClipboardContent(undefined, displayed);
|
|
140
|
+
expect(result.text).toBe("hello");
|
|
141
|
+
expect(result.html).toBe("<b>hello</b>");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should return no html for plain values", () => {
|
|
145
|
+
const result = getClipboardContent(undefined, "plain text");
|
|
146
|
+
expect(result.text).toBe("plain text");
|
|
147
|
+
expect(result.html).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should treat text/markdown as html since mo.md() data is rendered html", () => {
|
|
151
|
+
const displayed = {
|
|
152
|
+
_serialized_mime_bundle: {
|
|
153
|
+
mimetype: "text/markdown",
|
|
154
|
+
data: '<span class="markdown"><strong>Hello</strong></span>',
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const result = getClipboardContent(undefined, displayed);
|
|
158
|
+
expect(result.text).toBe("Hello");
|
|
159
|
+
expect(result.html).toBe(
|
|
160
|
+
'<span class="markdown"><strong>Hello</strong></span>',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should return no html for non-html mime bundles", () => {
|
|
165
|
+
const displayed = {
|
|
166
|
+
_serialized_mime_bundle: {
|
|
167
|
+
mimetype: "text/plain",
|
|
168
|
+
data: "just text",
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const result = getClipboardContent(undefined, displayed);
|
|
172
|
+
expect(result.text).toBe("just text");
|
|
173
|
+
expect(result.html).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should handle null rawValue as a real value", () => {
|
|
177
|
+
const displayed = {
|
|
178
|
+
_serialized_mime_bundle: {
|
|
179
|
+
mimetype: "text/html",
|
|
180
|
+
data: "<i>N/A</i>",
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const result = getClipboardContent(null, displayed);
|
|
184
|
+
expect(result.text).toBe("null");
|
|
185
|
+
expect(result.html).toBe("<i>N/A</i>");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function createMockTableWithMeta<TData>(rawData?: TData[]): Table<TData> {
|
|
190
|
+
return {
|
|
191
|
+
options: {
|
|
192
|
+
meta: { rawData },
|
|
193
|
+
},
|
|
194
|
+
} as unknown as Table<TData>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe("getRawValue", () => {
|
|
198
|
+
it("should return raw value when rawData is available", () => {
|
|
199
|
+
const table = createMockTableWithMeta([
|
|
200
|
+
{ a: 10, b: 20 },
|
|
201
|
+
{ a: 30, b: 40 },
|
|
202
|
+
]);
|
|
203
|
+
expect(getRawValue(table, 0, "a")).toBe(10);
|
|
204
|
+
expect(getRawValue(table, 1, "b")).toBe(40);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should return undefined when rawData is not set", () => {
|
|
208
|
+
const table = createMockTableWithMeta(undefined);
|
|
209
|
+
expect(getRawValue(table, 0, "a")).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should return undefined when row index is out of bounds", () => {
|
|
213
|
+
const table = createMockTableWithMeta([{ a: 1 }]);
|
|
214
|
+
expect(getRawValue(table, 5, "a")).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { DATA_CELL_ID } from "./cell-utils";
|
|
19
19
|
import { Filter } from "./filters";
|
|
20
20
|
import { selectedCellsAtom } from "./range-focus/atoms";
|
|
21
|
-
import {
|
|
21
|
+
import { getClipboardContent, getRawValue } from "./utils";
|
|
22
22
|
|
|
23
23
|
export const DataTableContextMenu = <TData,>({
|
|
24
24
|
contextMenuRef,
|
|
@@ -82,11 +82,15 @@ export const CellContextMenu = <TData,>({
|
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
const table = cell.getContext().table;
|
|
86
|
+
const displayedValue = cell.getValue();
|
|
87
|
+
const rawValue =
|
|
88
|
+
getRawValue(table, cell.row.index, cell.column.id) ?? displayedValue;
|
|
89
|
+
|
|
85
90
|
const handleCopyCell = () => {
|
|
86
91
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
copyToClipboard(stringValue);
|
|
92
|
+
const { text, html } = getClipboardContent(rawValue, displayedValue);
|
|
93
|
+
copyToClipboard(text, html);
|
|
90
94
|
} catch (error) {
|
|
91
95
|
Logger.error("Failed to copy context menu cell", error);
|
|
92
96
|
}
|
|
@@ -98,7 +102,7 @@ export const CellContextMenu = <TData,>({
|
|
|
98
102
|
const handleFilterCell = (operator: "in" | "not_in") => {
|
|
99
103
|
column.setFilterValue(
|
|
100
104
|
Filter.select({
|
|
101
|
-
options: [
|
|
105
|
+
options: [rawValue],
|
|
102
106
|
operator,
|
|
103
107
|
}),
|
|
104
108
|
);
|
|
@@ -52,6 +52,7 @@ interface DataTableProps<TData> extends Partial<DownloadActionProps> {
|
|
|
52
52
|
maxHeight?: number;
|
|
53
53
|
columns: ColumnDef<TData>[];
|
|
54
54
|
data: TData[];
|
|
55
|
+
rawData?: TData[]; // raw data for filtering/copying (present only if format_mapping is provided)
|
|
55
56
|
// Sorting
|
|
56
57
|
manualSorting?: boolean; // server-side sorting
|
|
57
58
|
sorting?: SortingState; // controlled sorting
|
|
@@ -103,6 +104,7 @@ const DataTableInternal = <TData,>({
|
|
|
103
104
|
maxHeight,
|
|
104
105
|
columns,
|
|
105
106
|
data,
|
|
107
|
+
rawData,
|
|
106
108
|
selection,
|
|
107
109
|
totalColumns,
|
|
108
110
|
totalRows,
|
|
@@ -197,6 +199,7 @@ const DataTableInternal = <TData,>({
|
|
|
197
199
|
],
|
|
198
200
|
data,
|
|
199
201
|
columns,
|
|
202
|
+
meta: { rawData },
|
|
200
203
|
getCoreRowModel: getCoreRowModel(),
|
|
201
204
|
// pagination
|
|
202
205
|
rowCount: totalRows === "too_many" ? undefined : totalRows,
|
|
@@ -403,7 +403,10 @@ describe("cell selection atoms", () => {
|
|
|
403
403
|
beforeEach(() => {
|
|
404
404
|
// Reset mocks before each test
|
|
405
405
|
vi.mocked(getCellValues).mockClear();
|
|
406
|
-
vi.mocked(getCellValues).mockReturnValue(
|
|
406
|
+
vi.mocked(getCellValues).mockReturnValue({
|
|
407
|
+
text: "mocked cell values",
|
|
408
|
+
html: undefined,
|
|
409
|
+
});
|
|
407
410
|
});
|
|
408
411
|
|
|
409
412
|
afterEach(() => {
|
|
@@ -424,7 +427,10 @@ describe("cell selection atoms", () => {
|
|
|
424
427
|
});
|
|
425
428
|
|
|
426
429
|
expect(getCellValues).toHaveBeenCalledWith(mockTable, selectedCells);
|
|
427
|
-
expect(copyToClipboard).toHaveBeenCalledWith(
|
|
430
|
+
expect(copyToClipboard).toHaveBeenCalledWith(
|
|
431
|
+
"mocked cell values",
|
|
432
|
+
undefined,
|
|
433
|
+
);
|
|
428
434
|
expect(onCopyComplete).toHaveBeenCalledWith();
|
|
429
435
|
expect(state.copiedCells).toEqual(selectedCells);
|
|
430
436
|
});
|
|
@@ -56,12 +56,14 @@ export function createMockRow(
|
|
|
56
56
|
export function createMockTable(
|
|
57
57
|
rows: Row<unknown>[],
|
|
58
58
|
columns: Column<unknown>[],
|
|
59
|
+
opts?: { rawData?: unknown[] },
|
|
59
60
|
): Table<unknown> {
|
|
60
61
|
return {
|
|
61
62
|
getRow: (id: string) => rows.find((row) => row.id === id),
|
|
62
63
|
getRowModel: () => ({ rows }),
|
|
63
64
|
getColumn: (columnId: string) => columns.find((col) => col.id === columnId),
|
|
64
65
|
getAllColumns: () => columns,
|
|
66
|
+
options: { meta: { rawData: opts?.rawData } },
|
|
65
67
|
} as unknown as Table<unknown>;
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -20,7 +20,8 @@ describe("getCellValues", () => {
|
|
|
20
20
|
it("should return empty string for empty selection", () => {
|
|
21
21
|
const mockTable = createMockTable([], []);
|
|
22
22
|
const result = getCellValues(mockTable, new Set());
|
|
23
|
-
expect(result).toBe("");
|
|
23
|
+
expect(result.text).toBe("");
|
|
24
|
+
expect(result.html).toBeUndefined();
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
it("should ignore select checkbox in tables", () => {
|
|
@@ -29,7 +30,8 @@ describe("getCellValues", () => {
|
|
|
29
30
|
const table = createMockTable([row], []);
|
|
30
31
|
|
|
31
32
|
const result = getCellValues(table, new Set());
|
|
32
|
-
expect(result).toBe("");
|
|
33
|
+
expect(result.text).toBe("");
|
|
34
|
+
expect(result.html).toBeUndefined();
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
it("should return single cell value", () => {
|
|
@@ -38,7 +40,8 @@ describe("getCellValues", () => {
|
|
|
38
40
|
const table = createMockTable([row], []);
|
|
39
41
|
|
|
40
42
|
const result = getCellValues(table, new Set(["0_0"]));
|
|
41
|
-
expect(result).toBe("test");
|
|
43
|
+
expect(result.text).toBe("test");
|
|
44
|
+
expect(result.html).toBeUndefined();
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
it("should return multiple cells from same row separated by tabs", () => {
|
|
@@ -48,7 +51,8 @@ describe("getCellValues", () => {
|
|
|
48
51
|
const table = createMockTable([row], []);
|
|
49
52
|
|
|
50
53
|
const result = getCellValues(table, new Set(["0_0", "0_1"]));
|
|
51
|
-
expect(result).toBe("value1\tvalue2");
|
|
54
|
+
expect(result.text).toBe("value1\tvalue2");
|
|
55
|
+
expect(result.html).toBeUndefined();
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
it("should return multiple rows separated by newlines", () => {
|
|
@@ -59,7 +63,8 @@ describe("getCellValues", () => {
|
|
|
59
63
|
const table = createMockTable([row1, row2], []);
|
|
60
64
|
|
|
61
65
|
const result = getCellValues(table, new Set(["0_0", "1_0"]));
|
|
62
|
-
expect(result).toBe("row1\nrow2");
|
|
66
|
+
expect(result.text).toBe("row1\nrow2");
|
|
67
|
+
expect(result.html).toBeUndefined();
|
|
63
68
|
});
|
|
64
69
|
|
|
65
70
|
it("should handle missing rows gracefully", () => {
|
|
@@ -69,7 +74,7 @@ describe("getCellValues", () => {
|
|
|
69
74
|
|
|
70
75
|
// Row "999" doesn't exist and is skipped
|
|
71
76
|
const result = getCellValues(table, new Set(["0_0", "999_0"]));
|
|
72
|
-
expect(result).toBe("test");
|
|
77
|
+
expect(result.text).toBe("test");
|
|
73
78
|
});
|
|
74
79
|
|
|
75
80
|
it("should include undefined for missing columns on existing rows", () => {
|
|
@@ -80,7 +85,7 @@ describe("getCellValues", () => {
|
|
|
80
85
|
|
|
81
86
|
// Column "999" doesn't exist but row.getValue() returns undefined
|
|
82
87
|
const result = getCellValues(table, new Set(["0_0", "0_1", "0_999"]));
|
|
83
|
-
expect(result).toBe("test1\ttest2\tundefined");
|
|
88
|
+
expect(result.text).toBe("test1\ttest2\tundefined");
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
it("should handle complex data types", () => {
|
|
@@ -91,7 +96,76 @@ describe("getCellValues", () => {
|
|
|
91
96
|
const table = createMockTable([row], []);
|
|
92
97
|
|
|
93
98
|
const result = getCellValues(table, new Set(["0_0", "0_1", "0_2"]));
|
|
94
|
-
expect(result).toBe('{"name":"test"}\tnull\tundefined');
|
|
99
|
+
expect(result.text).toBe('{"name":"test"}\tnull\tundefined');
|
|
100
|
+
expect(result.html).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should use raw values when rawData is available", () => {
|
|
104
|
+
const mimeBundle = {
|
|
105
|
+
_serialized_mime_bundle: {
|
|
106
|
+
mimetype: "text/html",
|
|
107
|
+
data: "<b>formatted_42</b>",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const cell1 = createMockCell("0_a", mimeBundle);
|
|
111
|
+
const cell2 = createMockCell("0_b", "displayed_b");
|
|
112
|
+
const row = createMockRow("0", [cell1, cell2]);
|
|
113
|
+
const table = createMockTable([row], [], {
|
|
114
|
+
rawData: [{ a: 42, b: "raw_b" }],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = getCellValues(table, new Set(["0_a", "0_b"]));
|
|
118
|
+
expect(result.text).toBe("42\traw_b");
|
|
119
|
+
expect(result.html).toBe(
|
|
120
|
+
"<table><tr><td><b>formatted_42</b></td><td>raw_b</td></tr></table>",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should fall back to displayed value when rawData is not set", () => {
|
|
125
|
+
const cell = createMockCell("0_name", "displayed");
|
|
126
|
+
const row = createMockRow("0", [cell]);
|
|
127
|
+
const table = createMockTable([row], []);
|
|
128
|
+
|
|
129
|
+
const result = getCellValues(table, new Set(["0_name"]));
|
|
130
|
+
expect(result.text).toBe("displayed");
|
|
131
|
+
expect(result.html).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should return html table when cells contain mime bundles", () => {
|
|
135
|
+
const mimeBundle = {
|
|
136
|
+
_serialized_mime_bundle: {
|
|
137
|
+
mimetype: "text/html",
|
|
138
|
+
data: '<a href="https://example.com">link</a>',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const cell1 = createMockCell("0_url", mimeBundle);
|
|
142
|
+
const cell2 = createMockCell("0_name", "plain text");
|
|
143
|
+
const row = createMockRow("0", [cell1, cell2]);
|
|
144
|
+
const table = createMockTable([row], []);
|
|
145
|
+
|
|
146
|
+
const result = getCellValues(table, new Set(["0_url", "0_name"]));
|
|
147
|
+
expect(result.text).toBe("link\tplain text");
|
|
148
|
+
expect(result.html).toBe(
|
|
149
|
+
'<table><tr><td><a href="https://example.com">link</a></td><td>plain text</td></tr></table>',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should escape html entities in non-mime cells within html table", () => {
|
|
154
|
+
const mimeBundle = {
|
|
155
|
+
_serialized_mime_bundle: {
|
|
156
|
+
mimetype: "text/html",
|
|
157
|
+
data: "<b>bold</b>",
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const cell1 = createMockCell("0_html", mimeBundle);
|
|
161
|
+
const cell2 = createMockCell("0_text", "a < b & c > d");
|
|
162
|
+
const row = createMockRow("0", [cell1, cell2]);
|
|
163
|
+
const table = createMockTable([row], []);
|
|
164
|
+
|
|
165
|
+
const result = getCellValues(table, new Set(["0_html", "0_text"]));
|
|
166
|
+
expect(result.html).toBe(
|
|
167
|
+
"<table><tr><td><b>bold</b></td><td>a < b & c > d</td></tr></table>",
|
|
168
|
+
);
|
|
95
169
|
});
|
|
96
170
|
});
|
|
97
171
|
|
|
@@ -167,8 +167,8 @@ const {
|
|
|
167
167
|
return state;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
const text = getCellValues(table, state.selectedCells);
|
|
171
|
-
copyToClipboard(text);
|
|
170
|
+
const { text, html } = getCellValues(table, state.selectedCells);
|
|
171
|
+
copyToClipboard(text, html);
|
|
172
172
|
onCopyComplete();
|
|
173
173
|
|
|
174
174
|
return {
|
|
@@ -2,17 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Table } from "@tanstack/react-table";
|
|
4
4
|
import { SELECT_COLUMN_ID } from "../types";
|
|
5
|
-
import {
|
|
5
|
+
import { getClipboardContent, getRawValue } from "../utils";
|
|
6
6
|
import type { SelectedCell } from "./atoms";
|
|
7
7
|
|
|
8
|
+
export interface CellValuesResult {
|
|
9
|
+
text: string;
|
|
10
|
+
html: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
/**
|
|
9
|
-
* Get the values of the selected cells
|
|
14
|
+
* Get the values of the selected cells, preferring raw (unformatted) values
|
|
15
|
+
* for plain text. If any cell contains HTML (e.g. a hyperlink), also builds
|
|
16
|
+
* an HTML table so rich content can be preserved on paste.
|
|
10
17
|
*/
|
|
11
18
|
export function getCellValues<TData>(
|
|
12
19
|
table: Table<TData>,
|
|
13
20
|
selectedCellIds: Set<string>,
|
|
14
|
-
):
|
|
15
|
-
const
|
|
21
|
+
): CellValuesResult {
|
|
22
|
+
const rows = new Map<string, { text: string; html?: string }[]>();
|
|
23
|
+
let hasHtml = false;
|
|
16
24
|
|
|
17
25
|
for (const cellId of selectedCellIds) {
|
|
18
26
|
if (cellId.includes(SELECT_COLUMN_ID)) {
|
|
@@ -26,17 +34,46 @@ export function getCellValues<TData>(
|
|
|
26
34
|
continue;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
const rawValue = getRawValue(table, row.index, columnId);
|
|
38
|
+
const { text, html } = getClipboardContent(
|
|
39
|
+
rawValue,
|
|
40
|
+
row.getValue(columnId),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (html) {
|
|
44
|
+
hasHtml = true;
|
|
45
|
+
}
|
|
46
|
+
const cells = rows.get(rowId) ?? [];
|
|
47
|
+
cells.push({ text, html });
|
|
48
|
+
rows.set(rowId, cells);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rowValues = [...rows.values()];
|
|
52
|
+
const tabSeparatedText = rowValues
|
|
53
|
+
.map((cells) => cells.map((c) => c.text).join("\t"))
|
|
54
|
+
.join("\n");
|
|
55
|
+
|
|
56
|
+
let htmlTable: string | undefined;
|
|
57
|
+
if (hasHtml) {
|
|
58
|
+
const htmlTableRows = rowValues
|
|
59
|
+
.map(
|
|
60
|
+
(cells) =>
|
|
61
|
+
`<tr>${cells.map((c) => `<td>${c.html ?? escapeHtml(c.text)}</td>`).join("")}</tr>`,
|
|
62
|
+
)
|
|
63
|
+
.join("");
|
|
64
|
+
htmlTable = `<table>${htmlTableRows}</table>`;
|
|
33
65
|
}
|
|
34
66
|
|
|
35
|
-
return
|
|
67
|
+
return {
|
|
68
|
+
text: tabSeparatedText,
|
|
69
|
+
html: htmlTable,
|
|
70
|
+
};
|
|
36
71
|
}
|
|
37
72
|
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
function escapeHtml(str: string): string {
|
|
74
|
+
const div = document.createElement("div");
|
|
75
|
+
div.textContent = str;
|
|
76
|
+
return div.innerHTML;
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
/**
|
|
@@ -74,7 +111,8 @@ export function getNumericValuesFromSelectedCells<TData>(
|
|
|
74
111
|
continue;
|
|
75
112
|
}
|
|
76
113
|
|
|
77
|
-
const value =
|
|
114
|
+
const value =
|
|
115
|
+
getRawValue(table, row.index, columnId) ?? row.getValue(columnId);
|
|
78
116
|
|
|
79
117
|
// Only accept numbers and strings
|
|
80
118
|
// Skip booleans, null, etc.
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { RowData } from "@tanstack/react-table";
|
|
3
4
|
import type { DataType } from "@/core/kernel/messages";
|
|
4
5
|
import { Objects } from "@/utils/objects";
|
|
5
6
|
|
|
7
|
+
declare module "@tanstack/react-table" {
|
|
8
|
+
interface TableMeta<TData extends RowData> {
|
|
9
|
+
rawData?: TData[]; // raw data for filtering/copying (present only if format_mapping is provided)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export type ColumnName = string;
|
|
7
14
|
|
|
8
15
|
export const ColumnHeaderStatsKeys = [
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { Table } from "@tanstack/react-table";
|
|
3
4
|
import type { TableData } from "@/plugins/impl/DataTablePlugin";
|
|
4
5
|
import { vegaLoadData } from "@/plugins/impl/vega/loader";
|
|
5
6
|
import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
|
|
7
|
+
import { getMimeValues } from "./mime-cell";
|
|
6
8
|
import { INDEX_COLUMN_NAME } from "./types";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -32,6 +34,21 @@ export async function loadTableData<T = object>(
|
|
|
32
34
|
return tableData;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Load both table and raw table data. Raw table data is typically when
|
|
39
|
+
* there is formatting applied to the table data.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadTableAndRawData<T>(
|
|
42
|
+
tableData: TableData<T>,
|
|
43
|
+
rawTableData?: TableData<T> | null,
|
|
44
|
+
): Promise<[T[], T[] | undefined]> {
|
|
45
|
+
if (rawTableData) {
|
|
46
|
+
return Promise.all([loadTableData(tableData), loadTableData(rawTableData)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [await loadTableData(tableData), undefined];
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Get the stable row ID for a row.
|
|
37
54
|
*
|
|
@@ -87,3 +104,73 @@ export function stringifyUnknownValue(opts: {
|
|
|
87
104
|
}
|
|
88
105
|
return String(value);
|
|
89
106
|
}
|
|
107
|
+
|
|
108
|
+
function stripHtml(html: string): string {
|
|
109
|
+
const div = document.createElement("div");
|
|
110
|
+
div.innerHTML = html;
|
|
111
|
+
const text = (div.textContent || div.innerText || "").trim();
|
|
112
|
+
return text || html;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const HTML_MIMETYPES = new Set(["text/html", "text/markdown"]);
|
|
116
|
+
|
|
117
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
118
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get clipboard-ready text and optional HTML for a cell.
|
|
123
|
+
*
|
|
124
|
+
* @param rawValue - The raw (unformatted) value, or undefined if not available.
|
|
125
|
+
* @param displayedValue - The displayed value (may be a mime bundle).
|
|
126
|
+
*/
|
|
127
|
+
export function getClipboardContent(
|
|
128
|
+
rawValue: unknown,
|
|
129
|
+
displayedValue: unknown,
|
|
130
|
+
): { text: string; html?: string } {
|
|
131
|
+
const mimeValues =
|
|
132
|
+
typeof displayedValue === "object" && displayedValue !== null
|
|
133
|
+
? getMimeValues(displayedValue)
|
|
134
|
+
: undefined;
|
|
135
|
+
|
|
136
|
+
let html: string | undefined;
|
|
137
|
+
if (mimeValues) {
|
|
138
|
+
// text/markdown from mo.md() contains rendered HTML
|
|
139
|
+
const htmlParts = mimeValues
|
|
140
|
+
.filter((v) => HTML_MIMETYPES.has(v.mimetype))
|
|
141
|
+
.map((v) => v.data);
|
|
142
|
+
html = htmlParts.length > 0 ? htmlParts.join("") : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let text: string;
|
|
146
|
+
if (rawValue !== undefined && rawValue !== displayedValue) {
|
|
147
|
+
text = stringifyUnknownValue({ value: rawValue });
|
|
148
|
+
} else if (mimeValues) {
|
|
149
|
+
text = mimeValues
|
|
150
|
+
.map((v) => (HTML_MIMETYPES.has(v.mimetype) ? stripHtml(v.data) : v.data))
|
|
151
|
+
.join(", ");
|
|
152
|
+
} else {
|
|
153
|
+
text = stringifyUnknownValue({ value: displayedValue });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { text, html };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the raw (unformatted) value for a row/column from the table,
|
|
161
|
+
* or undefined if raw data is not available.
|
|
162
|
+
*/
|
|
163
|
+
export function getRawValue<TData>(
|
|
164
|
+
table: Table<TData>,
|
|
165
|
+
rowIndex: number,
|
|
166
|
+
columnId: string,
|
|
167
|
+
): unknown {
|
|
168
|
+
const rawData = table.options.meta?.rawData;
|
|
169
|
+
if (rawData) {
|
|
170
|
+
const rawRow = rawData[rowIndex];
|
|
171
|
+
if (isRecord(rawRow)) {
|
|
172
|
+
return rawRow[columnId];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|