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

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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.21.2-dev3",
3
+ "version": "0.21.2-dev31",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
4
  import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
5
+ import { merge } from "lodash-es";
5
6
  import {
6
7
  AlertTriangleIcon,
7
8
  BrainIcon,
@@ -287,10 +288,10 @@ export const UserConfigForm: React.FC = () => {
287
288
  dirtyValues.ai = setAiModels(values.ai, dirtyValues.ai);
288
289
  }
289
290
 
290
- await saveUserConfig({ config: dirtyValues }).then(() => {
291
- // Update local state with form values
292
- setConfig((prev) => ({ ...prev, ...values }));
293
- });
291
+ await saveUserConfig({ config: dirtyValues });
292
+ // Only apply the changed keys; this avoids stale request responses
293
+ // overwriting newer config changes.
294
+ setConfig((prev) => merge({}, prev, dirtyValues));
294
295
  };
295
296
  const onSubmit = useDebouncedCallback(onSubmitNotDebounced, FORM_DEBOUNCE);
296
297
 
@@ -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 { getPageIndexForRow } from "../utils";
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 { stringifyUnknownValue } from "./utils";
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 value = cell.getValue();
88
- const stringValue = stringifyUnknownValue({ value });
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: [cell.getValue()],
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("mocked cell values");
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("mocked cell values");
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 &lt; b &amp; c &gt; 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 { stringifyUnknownValue } from "../utils";
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
- ): string {
15
- const rowValues = new Map<string, string[]>();
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 cellValue = row.getValue(columnId);
30
- const values = rowValues.get(rowId) ?? [];
31
- values.push(stringifyUnknownValue({ value: cellValue }));
32
- rowValues.set(rowId, values);
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 getTabSeparatedValues([...rowValues.values()]);
67
+ return {
68
+ text: tabSeparatedText,
69
+ html: htmlTable,
70
+ };
36
71
  }
37
72
 
38
- export function getTabSeparatedValues(values: string[][]) {
39
- return values.map((row) => row.join("\t")).join("\n");
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 = row.getValue(columnId);
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
+ }