diff --git a/.changeset/svg-clipboard-paste.md b/.changeset/svg-clipboard-paste.md new file mode 100644 index 000000000..c3c5e6a68 --- /dev/null +++ b/.changeset/svg-clipboard-paste.md @@ -0,0 +1,6 @@ +--- +'@plait/core': patch +'@plait/common': patch +--- + +Support pasting SVG clipboard content as an image file. diff --git a/packages/common/src/constants/media.ts b/packages/common/src/constants/media.ts index d4c9ff4cb..694cfb9dc 100644 --- a/packages/common/src/constants/media.ts +++ b/packages/common/src/constants/media.ts @@ -4,6 +4,6 @@ export enum MediaKeys { export const PICTURE_ACCEPTED_UPLOAD_SIZE = 20; -export const acceptImageTypes = ['png', 'jpeg', 'gif', 'bmp']; +export const acceptImageTypes = ['png', 'jpeg', 'gif', 'bmp', 'svg+xml']; export const WithCommonPluginKey = 'plait-common-plugin-key'; diff --git a/packages/core/src/utils/clipboard/clipboard.spec.ts b/packages/core/src/utils/clipboard/clipboard.spec.ts new file mode 100644 index 000000000..de55a84ce --- /dev/null +++ b/packages/core/src/utils/clipboard/clipboard.spec.ts @@ -0,0 +1,162 @@ +import { buildPlaitHtml } from './common'; +import { getClipboardData } from './clipboard'; +import { WritableClipboardType } from './types'; + +const SVG_MIME_TYPE = 'image/svg+xml'; + +const readFileText = (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('loadend', () => resolve(reader.result as string)); + reader.addEventListener('error', () => reject(reader.error)); + reader.readAsText(file); + }); +}; + +const createSvgDataTransferItem = (svg: string, onRead?: () => void) => { + return { + kind: 'string', + type: SVG_MIME_TYPE, + getAsString: (callback: FunctionStringCallback | null) => { + queueMicrotask(() => { + onRead?.(); + callback?.(svg); + }); + } + } as DataTransferItem; +}; + +const createDataTransfer = ({ + html = '', + text = '', + svg = '', + items = [] +}: { + html?: string; + text?: string; + svg?: string; + items?: DataTransferItem[]; +}) => { + return { + files: { length: 0 }, + items, + getData: (type: string) => { + if (type === 'text/html') { + return html; + } + if (type === 'text/plain') { + return text; + } + if (type === SVG_MIME_TYPE) { + return svg; + } + return ''; + } + } as unknown as DataTransfer; +}; + +describe('getClipboardData', () => { + let originalClipboardDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + }); + + afterEach(() => { + if (originalClipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('should read SVG clipboard content from string data transfer item as a file', async () => { + const svg = ''; + const dataTransfer = createDataTransfer({ + items: [createSvgDataTransferItem(svg)] + }); + + const clipboardData = await getClipboardData(dataTransfer); + const file = clipboardData?.files?.[0]; + + expect(file).toBeTruthy(); + expect(file?.name).toBe('plait-svg-image.svg'); + expect(file?.type).toBe(SVG_MIME_TYPE); + expect(file && (await readFileText(file))).toBe(svg); + }); + + it('should keep Plait HTML clipboard data before SVG clipboard content', async () => { + const element = { id: 'geometry', type: 'geometry', points: [] }; + const dataTransfer = createDataTransfer({ + html: buildPlaitHtml(WritableClipboardType.elements, [element]), + items: [createSvgDataTransferItem('')] + }); + + const clipboardData = await getClipboardData(dataTransfer); + + expect(clipboardData?.elements).toEqual([element]); + expect(clipboardData?.files).toBeUndefined(); + }); + + it('should read SVG clipboard content from data transfer MIME data', async () => { + const svg = ''; + const dataTransfer = createDataTransfer({ svg }); + + const clipboardData = await getClipboardData(dataTransfer); + const file = clipboardData?.files?.[0]; + + expect(file).toBeTruthy(); + expect(file?.type).toBe(SVG_MIME_TYPE); + expect(file && (await readFileText(file))).toBe(svg); + }); + + it('should use synchronous SVG MIME data when the SVG item string is empty', async () => { + const svg = ''; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {} + }); + const dataTransfer = createDataTransfer({ + svg, + items: [createSvgDataTransferItem('')] + }); + + const clipboardData = await getClipboardData(dataTransfer); + const file = clipboardData?.files?.[0]; + + expect(file).toBeTruthy(); + expect(file?.type).toBe(SVG_MIME_TYPE); + expect(file && (await readFileText(file))).toBe(svg); + }); + + it('should preserve plain text before async SVG reading without using navigator clipboard', async () => { + const text = 'plain text clipboard data'; + let dataTransferReadable = true; + const read = jasmine.createSpy('read').and.callFake(() => Promise.reject(new Error('unexpected navigator clipboard read'))); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + read + } + }); + const dataTransfer = { + files: { length: 0 }, + items: [ + createSvgDataTransferItem('', () => { + dataTransferReadable = false; + }) + ], + getData: (type: string) => { + if (!dataTransferReadable) { + return ''; + } + return type === 'text/plain' ? text : ''; + } + } as unknown as DataTransfer; + + const clipboardData = await getClipboardData(dataTransfer); + + expect(clipboardData?.text).toBe(text); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/utils/clipboard/clipboard.ts b/packages/core/src/utils/clipboard/clipboard.ts index c599dc6d1..314ae5303 100644 --- a/packages/core/src/utils/clipboard/clipboard.ts +++ b/packages/core/src/utils/clipboard/clipboard.ts @@ -13,6 +13,32 @@ import { import { getNavigatorClipboard, setNavigatorClipboard } from './navigator-clipboard'; import { ClipboardData, WritableClipboardContext } from './types'; +const SVG_MIME_TYPE = 'image/svg+xml'; + +const readDataTransferItemAsString = (item: DataTransferItem): Promise => { + return new Promise((resolve) => { + item.getAsString((value) => { + resolve(value || ''); + }); + }); +}; + +const readSvgText = async (dataTransfer: DataTransfer): Promise => { + const svgText = dataTransfer.getData(SVG_MIME_TYPE); + const svgItem = Array.from(dataTransfer.items || []).find((item) => item.kind === 'string' && item.type === SVG_MIME_TYPE); + if (svgText.trim()) { + return svgText; + } + + return svgItem ? await readDataTransferItemAsString(svgItem) : ''; +}; + +const createSvgClipboardData = (svgText: string): ClipboardData => { + return { + files: [new File([svgText], 'plait-svg-image.svg', { type: SVG_MIME_TYPE })] + }; +}; + export const cacheClipboardData = (clipboardData: ClipboardData) => { (window as any)['plait_fallback_clipboard_data'] = clipboardData; }; @@ -22,16 +48,22 @@ export const getCachedClipboardData = () => { }; export const getClipboardData = async (dataTransfer: DataTransfer | null): Promise => { - let clipboardData = {}; if (dataTransfer) { if (dataTransfer.files.length) { return { files: Array.from(dataTransfer.files) }; } - clipboardData = getDataTransferClipboard(dataTransfer); - if (Object.keys(clipboardData).length === 0) { - clipboardData = getDataTransferClipboardText(dataTransfer); + const plaitClipboardData = getDataTransferClipboard(dataTransfer); + if (Object.keys(plaitClipboardData).length > 0) { + return plaitClipboardData; + } + const svgTextPromise = readSvgText(dataTransfer); + // DataTransfer is event-scoped, so preserve the plain-text fallback before awaiting the SVG item. + const textClipboardData = getDataTransferClipboardText(dataTransfer); + const svgText = await svgTextPromise; + if (svgText.trim()) { + return createSvgClipboardData(svgText); } - return clipboardData; + return textClipboardData; } if (getProbablySupportsClipboardRead()) { return await getNavigatorClipboard();