js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
283 lines (282 loc) • 12.3 kB
JavaScript
/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- Used for clarity */
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var _ClipboardHandler_preferClipboardEvents;
import { InputEvtType } from '../inputEvents.mjs';
import fileToBase64Url from './fileToBase64Url.mjs';
const isTextMimeType = (mime) =>
// +xml: Handles image/svg+xml
mime.endsWith('+xml') || mime.startsWith('text/');
/**
* Handles conversion between the browser clipboard APIs and internal
* js-draw clipboard events.
*/
class ClipboardHandler {
constructor(editor, callbacks) {
this.editor = editor;
this.callbacks = callbacks;
_ClipboardHandler_preferClipboardEvents.set(this, false);
}
/**
* Pastes data from the clipboard into the editor associated with
* this handler.
*
* @param event Optional -- a clipboard/drag event. If not provided,
* `navigator.clipboard` will be used instead.
* @returns true if the paste event was handled by the editor.
*/
paste(event) {
const onError = (error) => {
if (this.callbacks?.onPasteError) {
this.callbacks.onPasteError(error);
return Promise.resolve(false);
}
else {
throw error;
}
};
try {
// Use .catch rather than `async` to prevent future modifications from
// moving clipboard handling logic out of user event handlers.
// In the past, `await`s have caused permissions issues in some browsers.
return this.pasteInternal(event).catch(onError);
}
catch (error) {
return onError(error);
}
}
async pasteInternal(event) {
const editor = this.editor;
const clipboardData = event?.dataTransfer ?? event?.clipboardData ?? null;
const hasEvent = !!clipboardData;
const sendPasteEvent = (mime, data) => {
return (data &&
editor.toolController.dispatchInputEvent({
kind: InputEvtType.PasteEvent,
mime,
data,
}));
};
// Listed in order of precedence
const supportedMIMEs = ['image/svg+xml', 'text/html', 'image/png', 'image/jpeg', 'text/plain'];
let files = [];
const textData = new Map();
const editorSettings = editor.getCurrentSettings();
if (hasEvent) {
// NOTE: On some browsers, .getData and .files must be used before any async operations.
files = [...clipboardData.files];
for (const mime of supportedMIMEs) {
const data = clipboardData.getData(mime);
if (data) {
textData.set(mime, data);
}
}
}
else if (editorSettings.clipboardApi) {
const clipboardData = await editorSettings.clipboardApi.read();
for (const [type, data] of clipboardData.entries()) {
if (typeof data === 'string') {
textData.set(type, data);
}
else {
let blob = data;
if (blob.type !== type) {
blob = new Blob([blob], { type });
}
files.push(blob);
}
}
}
else {
const clipboardData = await navigator.clipboard.read();
for (const item of clipboardData) {
for (const mime of item.types) {
if (supportedMIMEs.includes(mime)) {
files.push(await item.getType(mime));
}
}
}
}
// Returns true if handled
const handleMIME = async (mime) => {
const isTextFormat = isTextMimeType(mime);
if (isTextFormat) {
const data = textData.get(mime);
if (sendPasteEvent(mime, data)) {
event?.preventDefault();
return true;
}
}
for (const file of files) {
const fileType = file?.type?.toLowerCase();
if (fileType !== mime) {
continue;
}
if (isTextFormat) {
const text = await file.text();
if (sendPasteEvent(mime, text)) {
event?.preventDefault();
return true;
}
}
else {
editor.showLoadingWarning(0);
const onprogress = (evt) => {
editor.showLoadingWarning(evt.loaded / evt.total);
};
try {
const data = await fileToBase64Url(file, { onprogress });
if (sendPasteEvent(mime, data)) {
event?.preventDefault();
editor.hideLoadingWarning();
return true;
}
}
catch (e) {
console.error('Error reading image:', e);
}
editor.hideLoadingWarning();
}
}
return false;
};
for (const mime of supportedMIMEs) {
if (await handleMIME(mime)) {
return true;
}
}
return false;
}
/**
* Copies text from the editor associated with this.
*
* Even if `event` is provided, the `navigator.clipboard` API may be used if image data
* is to be copied. This is done because `ClipboardEvent`s seem to not support attaching
* images.
*/
copy(event) {
const onError = (error) => {
if (this.callbacks?.onCopyError) {
this.callbacks.onCopyError(error);
return Promise.resolve();
}
else {
throw error;
}
};
try {
// As above, use `.catch` to be certain that certain copyInternal
// is run now, before returning.
return this.copyInternal(event).catch(onError);
}
catch (error) {
return onError(error);
}
}
copyInternal(event) {
const mimeToData = new Map();
if (this.editor.toolController.dispatchInputEvent({
kind: InputEvtType.CopyEvent,
setData: (mime, data) => {
mimeToData.set(mime, data);
},
})) {
event?.preventDefault();
}
const mimeTypes = [...mimeToData.keys()];
const hasNonTextMimeTypes = mimeTypes.some((mime) => !isTextMimeType(mime));
const copyToEvent = (reason) => {
if (!event) {
throw new Error(`Unable to copy -- no event provided${reason ? `. Original error: ${reason}` : ''}`);
}
for (const [key, value] of mimeToData.entries()) {
if (typeof value === 'string') {
if ('clipboardData' in event) {
event.clipboardData?.setData(key, value);
}
else {
event.dataTransfer?.setData(key, value);
}
}
}
};
const copyToClipboardApi = () => {
const mapInternalDataToBrowserData = (originalMimeToData) => {
const mappedMimeToData = Object.create(null);
for (const [key, data] of originalMimeToData.entries()) {
if (typeof data === 'string') {
const loadedData = new Blob([new TextEncoder().encode(data)], { type: key });
mappedMimeToData[key] = loadedData;
}
else {
mappedMimeToData[key] = data;
}
// Different platforms have varying support for different clipboard MIME types:
// - As of September 2024, image/svg+xml is unsupported on iOS
// - text/html is unsupported on Chrome/Android (and perhaps Chrome on other platforms).
// - See https://issues.chromium.org/issues/40851502
if (key === 'image/svg+xml') {
mappedMimeToData['text/html'] ??= mappedMimeToData[key];
}
}
return mappedMimeToData;
};
const removeUnsupportedMime = (originalMimeToData) => {
const filteredMimeToData = Object.create(null);
for (const [key, data] of Object.entries(originalMimeToData)) {
// Browser support for ClipboardItem.supports is limited as of mid 2024. However, some browsers
// that do support `.supports` throw an exception when attempting to copy an unsupported MIME type
// (e.g. Firefox).
const unsupported = 'supports' in ClipboardItem &&
typeof ClipboardItem.supports === 'function' &&
!ClipboardItem.supports(key);
if (!unsupported) {
filteredMimeToData[key] = data;
}
}
return filteredMimeToData;
};
const browserMimeToData = removeUnsupportedMime(mapInternalDataToBrowserData(mimeToData));
return navigator.clipboard.write([new ClipboardItem(browserMimeToData)]);
};
const supportsClipboardApi = typeof ClipboardItem !== 'undefined' && typeof navigator?.clipboard?.write !== 'undefined';
const prefersClipboardApi = !__classPrivateFieldGet(this, _ClipboardHandler_preferClipboardEvents, "f") && supportsClipboardApi && (hasNonTextMimeTypes || !event);
const editorSettings = this.editor.getCurrentSettings();
if (prefersClipboardApi && editorSettings.clipboardApi) {
const writeResult = editorSettings.clipboardApi.write(mimeToData);
return writeResult ?? Promise.resolve();
}
else if (prefersClipboardApi) {
let clipboardApiPromise = null;
const fallBackToCopyEvent = (reason) => {
console.warn('Unable to copy to the clipboard API. Future calls to .copy will use ClipboardEvents if possible.', reason);
__classPrivateFieldSet(this, _ClipboardHandler_preferClipboardEvents, true, "f");
copyToEvent(reason);
};
try {
clipboardApiPromise = copyToClipboardApi();
}
catch (error) {
fallBackToCopyEvent(error);
}
if (clipboardApiPromise) {
return clipboardApiPromise.catch(fallBackToCopyEvent);
}
}
else {
copyToEvent();
}
return Promise.resolve();
}
}
_ClipboardHandler_preferClipboardEvents = new WeakMap();
export default ClipboardHandler;