diff --git a/.vscode/launch.json b/.vscode/launch.json
index e12ad6fbfd..c21ece4b2e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -48,7 +48,9 @@
"--inspect-brk",
"./node_modules/.bin/jest",
"${relativeFile}",
- "--runInBand"
+ "--runInBand",
+ "--config",
+ "./jest.config.js"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts
index 08e70059a5..32388713b9 100644
--- a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts
+++ b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts
@@ -5,6 +5,7 @@ import { clear, copy, paste, RangeType } from '@teable-group/openapi';
import { useTableId, useViewId } from '@teable-group/sdk';
import { useToast } from '@teable-group/ui-lib';
import { useCallback } from 'react';
+import { extractTableHeader, serializerHtml } from '@/features/app/utils/clipboard';
import { SelectionRegionType } from '../../../grid';
import type { CombinedSelection } from '../../../grid/managers';
@@ -15,8 +16,6 @@ const rangeTypes = {
[SelectionRegionType.None]: undefined,
};
-const copyMark = 'TeableCopyContent:';
-
export const useSelectionOperation = () => {
const tableId = useTableId();
const viewId = useViewId();
@@ -35,8 +34,6 @@ export const useSelectionOperation = () => {
const { toast } = useToast();
- const copyHeaderKey = 'teable_copy_header';
-
const doCopy = useCallback(
async (selection: CombinedSelection) => {
if (!viewId || !tableId) {
@@ -56,8 +53,12 @@ export const useSelectionOperation = () => {
});
const { content, header } = data;
- await navigator.clipboard.writeText(`${copyMark}${content}`);
- sessionStorage.setItem(copyHeaderKey, JSON.stringify(header));
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ ['text/plain']: new Blob([content], { type: 'text/plain' }),
+ ['text/html']: new Blob([serializerHtml(content, header)], { type: 'text/html' }),
+ }),
+ ]);
toaster.update({ id: toaster.id, title: 'Copied success!' });
},
[tableId, toast, viewId, copyReq]
@@ -72,14 +73,18 @@ export const useSelectionOperation = () => {
title: 'Pasting...',
});
const ranges = selection.ranges;
- const content = await navigator.clipboard.readText();
- const usingHeader = content.startsWith(copyMark);
- const headerStr = sessionStorage.getItem(copyHeaderKey);
- const header = headerStr && usingHeader ? JSON.parse(headerStr) : undefined;
+ const clipboardContent = await navigator.clipboard.read();
+ const text = await (await clipboardContent[0].getType('text/plain')).text();
+ const html = await (await clipboardContent[0].getType('text/html')).text();
+ const header = extractTableHeader(html);
+ if (header.error) {
+ toaster.update({ id: toaster.id, title: header.error });
+ return;
+ }
await pasteReq({
- content: usingHeader ? content.split(copyMark)[1] : content,
+ content: text,
cell: ranges[0],
- header,
+ header: header.result,
});
toaster.update({ id: toaster.id, title: 'Pasted success!' });
},
diff --git a/apps/nextjs-app/src/features/app/utils/clipboard.spec.ts b/apps/nextjs-app/src/features/app/utils/clipboard.spec.ts
new file mode 100644
index 0000000000..c8bb0c41a4
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/utils/clipboard.spec.ts
@@ -0,0 +1,137 @@
+import {
+ CellValueType,
+ DbFieldType,
+ FieldType,
+} from '../../../../../../packages/core/src/models/field/constant';
+import { extractTableHeader, isTeableHTML, serializerHtml } from './clipboard';
+
+jest.mock('@teable-group/core', () => {
+ return {
+ __esModule: true,
+ IFieldVo: {},
+ fieldVoSchema: {
+ safeParse: () => ({ success: true }),
+ },
+ };
+});
+
+describe('clipboard', () => {
+ const html =
+ '
Name | Count | Status |
John | 20 | light |
Tom | 30 | medium |
Bob | 40 | heavy |
';
+
+ const expectedHeader = [
+ {
+ id: 'fldziUf9QuQjkbfMuG5',
+ name: 'Name',
+ isPrimary: true,
+ columnMeta: {
+ viwE0sl0GqGdWaBqwFi: {
+ order: 0.5,
+ },
+ },
+ dbFieldName: 'Name_fldziUf9QuQjkbfMuG5',
+ dbFieldType: DbFieldType.Text,
+ type: FieldType.SingleLineText,
+ options: {},
+ cellValueType: CellValueType.String,
+ },
+ {
+ id: 'fldpsQvHI4ugP2luizP',
+ name: 'Count',
+ columnMeta: {
+ viwE0sl0GqGdWaBqwFi: {
+ order: 1,
+ },
+ },
+ dbFieldName: 'Count_fldpsQvHI4ugP2luizP',
+ dbFieldType: DbFieldType.Real,
+ type: FieldType.Number,
+ options: {
+ formatting: {
+ type: 'decimal',
+ precision: 0,
+ },
+ },
+ cellValueType: CellValueType.Number,
+ },
+ {
+ id: 'fldGTKfZvXNXeMJ6nqu',
+ name: 'Status',
+ columnMeta: {
+ viwE0sl0GqGdWaBqwFi: {
+ order: 2,
+ },
+ },
+ dbFieldName: 'Status_fldGTKfZvXNXeMJ6nqu',
+ dbFieldType: DbFieldType.Text,
+ options: {
+ choices: [
+ {
+ name: 'light',
+ id: 'cho2caYhPrI',
+ color: 'grayBright',
+ },
+ {
+ name: 'medium',
+ id: 'chor2ob8aU7',
+ color: 'yellowBright',
+ },
+ {
+ name: 'heavy',
+ id: 'choArPr57sO',
+ color: 'tealBright',
+ },
+ ],
+ },
+ type: FieldType.SingleSelect,
+ cellValueType: CellValueType.String,
+ },
+ ];
+ it('extractTableHeader should extract table header from HTML', () => {
+ const { result } = extractTableHeader(html);
+ expect(result).toEqual(expectedHeader);
+ });
+
+ it('extractTableHeader should return undefined from non-teable HTML', () => {
+ const { result } = extractTableHeader('');
+ expect(result).toEqual(undefined);
+ });
+
+ it('serializerHtml should serializer table from data and header of table', () => {
+ const data = 'John\t20\tlight\nTom\t30\tmedium\nBob\t40\theavy';
+ const result = serializerHtml(data, expectedHeader);
+ expect(result).toEqual(html);
+ });
+
+ describe('isTeableHtml', () => {
+ it('returns true for HTML with table tagged as teable', () => {
+ const html = `
+
+ `;
+ expect(isTeableHTML(html)).toBe(true);
+ });
+
+ it('returns false for HTML without table', () => {
+ const html = `
+ No Table
+ `;
+ expect(isTeableHTML(html)).toBe(false);
+ });
+
+ it('returns false if table lacks marker attribute', () => {
+ const html = `
+
+ `;
+ expect(isTeableHTML(html)).toBe(false);
+ });
+
+ it('handles invalid HTML gracefully', () => {
+ const html = ``;
+ expect(isTeableHTML(html)).toBe(false);
+ });
+ });
+});
diff --git a/apps/nextjs-app/src/features/app/utils/clipboard.ts b/apps/nextjs-app/src/features/app/utils/clipboard.ts
new file mode 100644
index 0000000000..e409bb9a9d
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/utils/clipboard.ts
@@ -0,0 +1,81 @@
+import { fieldVoSchema, type IFieldVo } from '@teable-group/core';
+import { mapValues } from 'lodash';
+import { fromZodError } from 'zod-validation-error';
+
+const teableHtmlMarker = 'data-teable-html-marker';
+
+export const serializerHtml = (data: string, headers: IFieldVo[]) => {
+ const records = data.split('\n');
+ const bodyContent = records
+ .map((record) => {
+ const cells = record.split('\t');
+ return `
${cells.map((cell) => `${cell} | `).join('')}
`;
+ })
+ .join('');
+ const headerContent = headers
+ .map((header, index) => {
+ const attrs = Object.entries(header)
+ .map(([key, value]) => `${key}="${encodeURIComponent(JSON.stringify(value))}"`)
+ .join(' ');
+ return `
${header.name} | `;
+ })
+ .join('');
+
+ return `
${headerContent}
${bodyContent}
`;
+};
+
+export const extractTableHeader = (html: string) => {
+ if (!isTeableHTML(html)) {
+ return { result: undefined };
+ }
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const table = doc.querySelector('table');
+ const headerRow = table?.querySelector('thead tr');
+ const headerCells = headerRow?.querySelectorAll('td') || [];
+
+ const headers = Array.from(headerCells);
+ let error = '';
+ const result = headers.map((cell) => {
+ const id = cell.getAttribute('id');
+ const name = cell.getAttribute('name');
+ const isPrimary = cell.getAttribute('isPrimary');
+ const columnMeta = cell.getAttribute('columnMeta');
+ const dbFieldName = cell.getAttribute('dbFieldName');
+ const dbFieldType = cell.getAttribute('dbFieldType');
+ const type = cell.getAttribute('type');
+ const options = cell.getAttribute('options');
+ const cellValueType = cell.getAttribute('cellValueType');
+ const fieldVo = mapValues(
+ {
+ id,
+ name,
+ isPrimary,
+ columnMeta,
+ dbFieldName,
+ dbFieldType,
+ type,
+ options,
+ cellValueType,
+ },
+ (value) => {
+ const encodeValue = value ? decodeURIComponent(value) : undefined;
+ return encodeValue ? JSON.parse(encodeValue) : undefined;
+ }
+ );
+ const validate = fieldVoSchema.safeParse(fieldVo);
+ if (validate.success) {
+ return fieldVo;
+ }
+ error = fromZodError(validate.error).message;
+ return undefined;
+ }) as IFieldVo[];
+ return error ? { result: undefined, error } : { result };
+};
+
+export const isTeableHTML = (html: string) => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const table = doc.querySelector('table');
+ return Boolean(table?.getAttribute(teableHtmlMarker));
+};
diff --git a/packages/core/.eslintrc.cjs b/packages/core/.eslintrc.js
similarity index 92%
rename from packages/core/.eslintrc.cjs
rename to packages/core/.eslintrc.js
index 1a8d71ac02..dcfcdc13d5 100644
--- a/packages/core/.eslintrc.cjs
+++ b/packages/core/.eslintrc.js
@@ -2,6 +2,7 @@
* Specific eslint rules for this workspace, learn how to compose
* @link https://github.com/teable-group/teable/tree/main/packages/eslint-config-bases
*/
+require('@teable-group/eslint-config-bases/patch/modern-module-resolution');
const { getDefaultIgnorePatterns } = require('@teable-group/eslint-config-bases/helpers');
diff --git a/packages/core/package.json b/packages/core/package.json
index fa8afc818c..ca42c6dae1 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,6 +53,7 @@
"eslint": "8.51.0",
"get-tsconfig": "4.2.0",
"jest": "29.7.0",
+ "prettier": "3.0.3",
"rimraf": "5.0.5",
"size-limit": "9.0.0",
"ts-jest": "29.1.1",
diff --git a/packages/core/src/models/field/derivate/long-text.field.spec.ts b/packages/core/src/models/field/derivate/long-text.field.spec.ts
index 97e8c8bf86..ae9bfb05e2 100644
--- a/packages/core/src/models/field/derivate/long-text.field.spec.ts
+++ b/packages/core/src/models/field/derivate/long-text.field.spec.ts
@@ -57,6 +57,7 @@ describe('LongTextFieldCore', () => {
it('should validate value', () => {
expect(field.validateCellValue('1.234').success).toBe(true);
expect(field.validateCellValue(1.234).success).toBe(false);
+ expect(field.validateCellValue(null).success).toBe(true);
expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);
expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);
diff --git a/packages/core/src/models/field/derivate/long-text.field.ts b/packages/core/src/models/field/derivate/long-text.field.ts
index a5ff51aea5..e8552d371d 100644
--- a/packages/core/src/models/field/derivate/long-text.field.ts
+++ b/packages/core/src/models/field/derivate/long-text.field.ts
@@ -61,8 +61,8 @@ export class LongTextFieldCore extends FieldCore {
validateCellValue(value: unknown) {
if (this.isMultipleCellValue) {
- return z.array(longTextCelValueSchema).nonempty().optional().safeParse(value);
+ return z.array(longTextCelValueSchema).nonempty().nullable().safeParse(value);
}
- return longTextCelValueSchema.optional().safeParse(value);
+ return longTextCelValueSchema.nullable().safeParse(value);
}
}
diff --git a/packages/core/src/models/field/derivate/single-line-text.field.spec.ts b/packages/core/src/models/field/derivate/single-line-text.field.spec.ts
index 20e79fe8a4..929ca21d39 100644
--- a/packages/core/src/models/field/derivate/single-line-text.field.spec.ts
+++ b/packages/core/src/models/field/derivate/single-line-text.field.spec.ts
@@ -63,6 +63,7 @@ describe('SingleLineTextFieldCore', () => {
it('should validate value', () => {
expect(field.validateCellValue('1.234').success).toBe(true);
expect(field.validateCellValue(1.234).success).toBe(false);
+ expect(field.validateCellValue(null).success).toBe(true);
expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);
expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);
diff --git a/packages/core/src/models/field/derivate/single-line-text.field.ts b/packages/core/src/models/field/derivate/single-line-text.field.ts
index 77e5580de2..79cf0930eb 100644
--- a/packages/core/src/models/field/derivate/single-line-text.field.ts
+++ b/packages/core/src/models/field/derivate/single-line-text.field.ts
@@ -66,8 +66,8 @@ export class SingleLineTextFieldCore extends FieldCore {
validateCellValue(value: unknown) {
if (this.isMultipleCellValue) {
- return z.array(singleLineTextCelValueSchema).nonempty().optional().safeParse(value);
+ return z.array(singleLineTextCelValueSchema).nonempty().nullable().safeParse(value);
}
- return singleLineTextCelValueSchema.optional().safeParse(value);
+ return singleLineTextCelValueSchema.nullable().safeParse(value);
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9fa33895f1..e0cd4d93be 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -882,6 +882,9 @@ importers:
jest:
specifier: 29.7.0
version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.1)
+ prettier:
+ specifier: 3.0.3
+ version: 3.0.3
rimraf:
specifier: 5.0.5
version: 5.0.5