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 = + '
NameCountStatus
John20light
Tom30medium
Bob40heavy
'; + + 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 = ` + + +
Hello
+ `; + 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 = ` + + +
Hello
+ `; + 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