Skip to content

Commit f2964e0

Browse files
authored
Merge branch 'develop' into user-type
2 parents a59f252 + ee6233b commit f2964e0

File tree

11 files changed

+198
-36
lines changed

11 files changed

+198
-36
lines changed

mathesar/imports/datafile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ def copy_datafile_to_table(
4040
f"{COLUMN_NAME_TEMPLATE}{i}" for i in range(len(next(reader)))
4141
]
4242
f.seek(0)
43+
processed_rows = ([None if val == '' else val for val in row] for row in reader)
4344
import_info = create_and_import_from_rows(
44-
reader,
45+
processed_rows,
4546
table_name,
4647
schema_oid,
4748
column_names,

mathesar_ui/src/components/sheet/clipboard/SheetClipboardHandler.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getErrorMessage } from '@mathesar/utils/errors';
66

77
import type SheetSelection from '../selection/SheetSelection';
88

9-
import { MIME_MATHESAR_SHEET_CLIPBOARD, MIME_PLAIN_TEXT } from './constants';
9+
import { MIME_PLAIN_TEXT } from './constants';
1010
import { type CopyingContext, getCopyContent } from './copy';
1111
import { type PastingContext, paste } from './paste';
1212

@@ -24,6 +24,32 @@ function shouldHandleEvent(): boolean {
2424
return document.activeElement?.hasAttribute('data-active-cell') ?? false;
2525
}
2626

27+
/**
28+
* Convert TSV data to an HTML table with embedded structured data.
29+
*
30+
* This creates a proper HTML table for compatibility with spreadsheet
31+
* and document applications, while embedding Mathesar's structured data
32+
* in a data attribute for internal paste operations.
33+
*
34+
* @param tsv - Tab-separated values
35+
* @param structuredData - JSON-encoded structured cell data
36+
*/
37+
function tsvToHtmlTable(tsv: string, structuredData: string): string {
38+
const rows = tsv.split('\n').filter((row) => row.length > 0);
39+
const tableRows = rows
40+
.map((row) => {
41+
const cells = row
42+
.split('\t')
43+
.map((cell) => `<td>${cell}</td>`)
44+
.join('');
45+
return `<tr>${cells}</tr>`;
46+
})
47+
.join('');
48+
return `<html><head></head><body><table data-mathesar-content="${encodeURIComponent(
49+
structuredData,
50+
)}"><tbody>${tableRows}</tbody></table></body></html>`;
51+
}
52+
2753
export class SheetClipboardHandler implements ClipboardHandler {
2854
private readonly deps: Dependencies;
2955

@@ -47,11 +73,10 @@ export class SheetClipboardHandler implements ClipboardHandler {
4773
this.deps.showToastInfo(
4874
get(_)('copied_cells', { values: { count: content.cellCount } }),
4975
);
76+
5077
event.clipboardData.setData(MIME_PLAIN_TEXT, content.tsv);
51-
event.clipboardData.setData(
52-
MIME_MATHESAR_SHEET_CLIPBOARD,
53-
content.structured,
54-
);
78+
const htmlContent = tsvToHtmlTable(content.tsv, content.structured);
79+
event.clipboardData.setData('text/html', htmlContent);
5580
} catch (e) {
5681
this.deps.showToastError(getErrorMessage(e));
5782
}
@@ -67,4 +92,72 @@ export class SheetClipboardHandler implements ClipboardHandler {
6792
this.deps.showToastError(getErrorMessage(e));
6893
}
6994
}
95+
96+
/**
97+
* Imperative copy method for use in context menus and other UI actions.
98+
* Uses the modern Clipboard API to write to the clipboard.
99+
*/
100+
async copy(): Promise<void> {
101+
try {
102+
const selection = get(this.deps.selection);
103+
const content = getCopyContent(selection, this.deps.copyingContext);
104+
const htmlContent = tsvToHtmlTable(content.tsv, content.structured);
105+
106+
const clipboardItems = [
107+
new ClipboardItem({
108+
[MIME_PLAIN_TEXT]: new Blob([content.tsv], {
109+
type: MIME_PLAIN_TEXT,
110+
}),
111+
'text/html': new Blob([htmlContent], {
112+
type: 'text/html',
113+
}),
114+
}),
115+
];
116+
117+
await navigator.clipboard.write(clipboardItems);
118+
119+
this.deps.showToastInfo(
120+
get(_)('copied_cells', { values: { count: content.cellCount } }),
121+
);
122+
} catch (e) {
123+
this.deps.showToastError(getErrorMessage(e));
124+
}
125+
}
126+
127+
/**
128+
* Imperative paste method for use in context menus and other UI actions.
129+
* Uses the modern Clipboard API to read from the clipboard.
130+
*/
131+
async paste(): Promise<void> {
132+
const context = this.deps.pastingContext;
133+
if (!context) return;
134+
135+
try {
136+
const clipboardItems = await navigator.clipboard.read();
137+
const item = clipboardItems[0];
138+
if (!item) return;
139+
140+
let htmlText = '';
141+
if (item.types.includes('text/html')) {
142+
const htmlBlob = await item.getType('text/html');
143+
htmlText = await htmlBlob.text();
144+
}
145+
146+
let plainText = '';
147+
if (item.types.includes(MIME_PLAIN_TEXT)) {
148+
const textBlob = await item.getType(MIME_PLAIN_TEXT);
149+
plainText = await textBlob.text();
150+
}
151+
152+
const dataTransfer = new DataTransfer();
153+
if (htmlText) {
154+
dataTransfer.setData('text/html', htmlText);
155+
}
156+
dataTransfer.setData(MIME_PLAIN_TEXT, plainText);
157+
158+
await paste(dataTransfer, this.deps.selection, context);
159+
} catch (e) {
160+
this.deps.showToastError(getErrorMessage(e));
161+
}
162+
}
70163
}
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
export const MIME_PLAIN_TEXT = 'text/plain';
2-
export const MIME_MATHESAR_SHEET_CLIPBOARD =
3-
'application/x-vnd.mathesar-sheet-clipboard';

mathesar_ui/src/components/sheet/clipboard/paste.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616

1717
import type SheetSelection from '../selection/SheetSelection';
1818

19-
import { MIME_MATHESAR_SHEET_CLIPBOARD, MIME_PLAIN_TEXT } from './constants';
19+
import { MIME_PLAIN_TEXT } from './constants';
2020
import {
2121
type StructuredCell,
2222
validateStructuredCellRows,
@@ -52,11 +52,27 @@ type PayloadCell = TsvCell | MathesarCell;
5252
type Payload = PayloadCell[][];
5353

5454
function getPayload(clipboardData: DataTransfer): Payload {
55-
const mathesarData = clipboardData.getData(MIME_MATHESAR_SHEET_CLIPBOARD);
56-
if (mathesarData) {
57-
const rows = validateStructuredCellRows(JSON.parse(mathesarData));
58-
if (rows.length === 0) throw new Error(get(_)('paste_error_empty'));
59-
return rows.map((row) => row.map((value) => ({ type: 'mathesar', value })));
55+
const htmlData = clipboardData.getData('text/html');
56+
if (htmlData) {
57+
const parser = new DOMParser();
58+
const doc = parser.parseFromString(htmlData, 'text/html');
59+
const table = doc.querySelector('table[data-mathesar-content]');
60+
if (table) {
61+
const content = table.getAttribute('data-mathesar-content');
62+
if (content) {
63+
try {
64+
const structuredData = decodeURIComponent(content);
65+
const rows = validateStructuredCellRows(JSON.parse(structuredData));
66+
if (rows.length > 0) {
67+
return rows.map((row) =>
68+
row.map((value) => ({ type: 'mathesar', value })),
69+
);
70+
}
71+
} catch {
72+
// fall through to plain text if structured data is malformed
73+
}
74+
}
75+
}
6076
}
6177

6278
const textData = clipboardData.getData(MIME_PLAIN_TEXT);

mathesar_ui/src/i18n/languages/en/dict.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"content": "Content",
146146
"content_of_first_row": "Content of First Row",
147147
"copied_cells": "Copied {count, plural, one {{count} cell} other {{count} cells}}",
148+
"copy": "Copy",
148149
"copy_and_paste_text": "Copy and Paste Text",
149150
"could_break_tables_views": "This could break existing tables and views.",
150151
"could_not_fetch_profile_error": "Could not fetch user profile details. Try refreshing your page.",

mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.scss

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,39 +34,45 @@ body > .new-item-highlighter {
3434
body > .new-item-highlighter__hint {
3535
position: absolute;
3636
z-index: var(--new-item-highlighter-z-index, 1000);
37-
--background: color-mix(in srgb, var(--color-bg-base), transparent 70%);
3837
inset: 0px auto auto 0px;
3938
display: flex;
4039
flex-direction: column;
4140
align-items: center;
4241
opacity: 0;
4342

43+
--background: var(--color-bg-tip);
44+
--foreground: var(--color-fg-tip);
45+
--strong: var(--color-bg-tip-strong);
46+
47+
&:hover {
48+
--background: var(--color-bg-tip-hover);
49+
--foreground: var(--color-fg-tip-hover);
50+
--strong: var(--color-bg-tip-strong-hover);
51+
}
52+
4453
@starting-style {
4554
opacity: 1;
4655
}
4756

4857
.message {
4958
background-color: var(--background);
50-
color: var(--color-fg-base);
59+
color: var(--foreground);
60+
border: 1px solid var(--strong);
5161
padding: 0.5rem;
5262
border-radius: 0.3rem;
5363
max-width: 15rem;
5464
text-align: center;
5565
cursor: pointer;
5666
}
5767

58-
&:hover {
59-
--background: black;
60-
}
61-
6268
svg {
6369
height: 1.5rem;
6470
width: 1.5rem;
6571
margin-bottom: -1px;
6672
cursor: pointer;
6773

6874
path {
69-
fill: var(--background);
75+
fill: var(--strong);
7076
}
7177
}
7278

mathesar_ui/src/systems/table-view/TableView.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
modalRecordView,
146146
tabularData: $tabularData,
147147
imperativeFilterController,
148+
clipboardHandler,
148149
beginSelectingCellRange,
149150
});
150151
}}

mathesar_ui/src/systems/table-view/context-menu/contextMenu.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
subMenu,
1010
} from '@mathesar/component-library';
1111
import { parseCellId } from '@mathesar/components/sheet/cellIds';
12+
import type { SheetClipboardHandler } from '@mathesar/components/sheet/clipboard/SheetClipboardHandler';
1213
import type { SheetCellDetails } from '@mathesar/components/sheet/selection';
1314
import type SheetSelection from '@mathesar/components/sheet/selection/SheetSelection';
1415
import type { ImperativeFilterController } from '@mathesar/pages/table/ImperativeFilterController';
@@ -17,13 +18,15 @@ import type RecordStore from '@mathesar/systems/record-view/RecordStore';
1718
import { takeFirstAndOnly } from '@mathesar/utils/iterUtils';
1819
import { match } from '@mathesar/utils/patternMatching';
1920

21+
import { copyCells } from './entries/copyCells';
2022
import { deleteColumn } from './entries/deleteColumn';
2123
import { deleteRecords } from './entries/deleteRecords';
2224
import { duplicateRecord } from './entries/duplicateRecord';
2325
import { modifyFilters } from './entries/modifyFilters';
2426
import { modifyGrouping } from './entries/modifyGrouping';
2527
import { modifySorting } from './entries/modifySorting';
2628
import { openTable } from './entries/openTable';
29+
import { pasteCells } from './entries/pasteCells';
2730
import { selectCellRange } from './entries/selectCellRange';
2831
import { setNull } from './entries/setNull';
2932
import { viewLinkedRecord } from './entries/viewLinkedRecord';
@@ -36,6 +39,7 @@ export function openTableCellContextMenu({
3639
modalRecordView,
3740
tabularData,
3841
imperativeFilterController,
42+
clipboardHandler,
3943
beginSelectingCellRange,
4044
}: {
4145
targetCell: SheetCellDetails;
@@ -44,6 +48,7 @@ export function openTableCellContextMenu({
4448
modalRecordView: ModalController<RecordStore> | undefined;
4549
tabularData: TabularData;
4650
imperativeFilterController: ImperativeFilterController | undefined;
51+
clipboardHandler: SheetClipboardHandler;
4752
beginSelectingCellRange: () => void;
4853
}): 'opened' | 'empty' {
4954
const { selection } = tabularData;
@@ -97,6 +102,13 @@ export function openTableCellContextMenu({
97102
}
98103

99104
function* getEntriesForMultipleCells(cellIds: string[]) {
105+
yield* copyCells({
106+
clipboardHandler,
107+
});
108+
yield* pasteCells({
109+
selection,
110+
clipboardHandler,
111+
});
100112
yield* setNull({ tabularData, cellIds });
101113
yield* selectCellRange({ beginSelectingCellRange });
102114
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { get } from 'svelte/store';
2+
import { _ } from 'svelte-i18n';
3+
4+
import type { SheetClipboardHandler } from '@mathesar/components/sheet/clipboard';
5+
import { iconCopyMajor } from '@mathesar/icons';
6+
import { buttonMenuEntry } from '@mathesar-component-library';
7+
8+
export function* copyCells(p: { clipboardHandler: SheetClipboardHandler }) {
9+
yield buttonMenuEntry({
10+
icon: iconCopyMajor,
11+
label: get(_)('copy'),
12+
onClick: () => {
13+
void p.clipboardHandler.copy();
14+
},
15+
});
16+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type Writable, get } from 'svelte/store';
2+
import { _ } from 'svelte-i18n';
3+
4+
import type { SheetClipboardHandler } from '@mathesar/components/sheet/clipboard/SheetClipboardHandler';
5+
import type SheetSelection from '@mathesar/components/sheet/selection/SheetSelection';
6+
import { iconPaste } from '@mathesar/icons';
7+
import { buttonMenuEntry } from '@mathesar-component-library';
8+
9+
export function* pasteCells(p: {
10+
selection: Writable<SheetSelection>;
11+
clipboardHandler: SheetClipboardHandler;
12+
}) {
13+
const canPaste = get(p.selection).pasteOperation !== 'none';
14+
15+
if (!canPaste) {
16+
yield buttonMenuEntry({
17+
icon: iconPaste,
18+
label: get(_)('paste'),
19+
disabled: true,
20+
onClick: () => {},
21+
});
22+
return;
23+
}
24+
25+
yield buttonMenuEntry({
26+
icon: iconPaste,
27+
label: get(_)('paste'),
28+
onClick: () => {
29+
void p.clipboardHandler.paste();
30+
},
31+
});
32+
}

0 commit comments

Comments
 (0)