Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/svg-clipboard-paste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@plait/core': patch
'@plait/common': patch
---

Support pasting SVG clipboard content as an image file.
2 changes: 1 addition & 1 deletion packages/common/src/constants/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
162 changes: 162 additions & 0 deletions packages/core/src/utils/clipboard/clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></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('<svg xmlns="http://www.w3.org/2000/svg"></svg>')]
});

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 = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></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 = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10z"/></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();
});
});
42 changes: 37 additions & 5 deletions packages/core/src/utils/clipboard/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
return new Promise<string>((resolve) => {
item.getAsString((value) => {
resolve(value || '');
});
});
};

const readSvgText = async (dataTransfer: DataTransfer): Promise<string> => {
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;
};
Expand All @@ -22,16 +48,22 @@ export const getCachedClipboardData = () => {
};

export const getClipboardData = async (dataTransfer: DataTransfer | null): Promise<ClipboardData | null> => {
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();
Expand Down
Loading