import { Arr, Cell, Strings, Type } from '@ephox/katamari';
import Editor from '../api/Editor';
import Env from '../api/Env';
import { BlobCache, BlobInfo } from '../api/file/BlobCache';
import { ParserArgs } from '../api/html/DomParser';
import * as Options from '../api/Options';
import Delay from '../api/util/Delay';
import { EditorEvent } from '../api/util/EventDispatcher';
import VK from '../api/util/VK';
import * as Conversions from '../file/Conversions';
import * as Whitespace from '../text/Whitespace';
import * as InternalHtml from './InternalHtml';
import * as Newlines from './Newlines';
import { PasteBin, isDefaultPasteBinContent } from './PasteBin';
import * as PasteUtils from './PasteUtils';
import * as ProcessFilters from './ProcessFilters';
import * as SmartPaste from './SmartPaste';
interface FileResult {
readonly file: File;
readonly uri: string;
}
export interface ClipboardContents {
[key: string]: string;
}
const uniqueId = PasteUtils.createIdGenerator('mceclip');
const doPaste = (editor: Editor, content: string, internal: boolean, pasteAsText: boolean): void => {
const args = ProcessFilters.process(editor, content, internal);
if (!args.cancelled) {
SmartPaste.insertContent(editor, args.content, pasteAsText);
}
};
/*
* Pastes the specified HTML. This means that the HTML is filtered and then
* inserted at the current selection in the editor. It will also fire paste events
* for custom user filtering.
*/
const pasteHtml = (editor: Editor, html: string, internalFlag: boolean): void => {
const internal = internalFlag ? internalFlag : InternalHtml.isMarked(html);
doPaste(editor, InternalHtml.unmark(html), internal, false);
};
/*
* Pastes the specified text. This means that the plain text is processed
* and converted into BR and P elements. It will fire paste events for custom filtering.
*/
const pasteText = (editor: Editor, text: string): void => {
const encodedText = editor.dom.encode(text).replace(/\r\n/g, '\n');
const normalizedText = Whitespace.normalize(encodedText, Options.getPasteTabSpaces(editor));
const html = Newlines.toBlockElements(normalizedText, Options.getForcedRootBlock(editor), Options.getForcedRootBlockAttrs(editor));
doPaste(editor, html, false, true);
};
/*
* Gets various content types out of a datatransfer object.
*/
const getDataTransferItems = (dataTransfer: DataTransfer | null): ClipboardContents => {
const items: ClipboardContents = {};
if (dataTransfer && dataTransfer.types) {
for (let i = 0; i < dataTransfer.types.length; i++) {
const contentType = dataTransfer.types[i];
try { // IE11 throws exception when contentType is Files (type is present but data cannot be retrieved via getData())
items[contentType] = dataTransfer.getData(contentType);
} catch (ex) {
items[contentType] = ''; // useless in general, but for consistency across browsers
}
}
}
return items;
};
const hasContentType = (clipboardContent: ClipboardContents, mimeType: string): boolean =>
mimeType in clipboardContent && clipboardContent[mimeType].length > 0;
const hasHtmlOrText = (content: ClipboardContents): boolean =>
hasContentType(content, 'text/html') || hasContentType(content, 'text/plain');
const extractFilename = (editor: Editor, str: string): string | undefined => {
const m = str.match(/([\s\S]+?)(?:\.[a-z0-9.]+)$/i);
return Type.isNonNullable(m) ? editor.dom.encode(m[1]) : undefined;
};
const createBlobInfo = (editor: Editor, blobCache: BlobCache, file: File, base64: string): BlobInfo => {
const id = uniqueId();
const useFileName = Options.shouldReuseFileName(editor) && Type.isNonNullable(file.name);
const name = useFileName ? extractFilename(editor, file.name) : id;
const filename = useFileName ? file.name : undefined;
const blobInfo = blobCache.create(id, file, base64, name, filename);
blobCache.add(blobInfo);
return blobInfo;
};
const pasteImage = (editor: Editor, imageItem: FileResult): void => {
Conversions.parseDataUri(imageItem.uri).each(({ data, type, base64Encoded }) => {
const base64 = base64Encoded ? data : btoa(data);
const file = imageItem.file;
// TODO: Move the bulk of the cache logic to EditorUpload
const blobCache = editor.editorUpload.blobCache;
const existingBlobInfo = blobCache.getByData(base64, type);
const blobInfo = existingBlobInfo ?? createBlobInfo(editor, blobCache, file, base64);
pasteHtml(editor, `
`, false);
});
};
const isClipboardEvent = (event: Event): event is ClipboardEvent =>
event.type === 'paste';
const readFilesAsDataUris = (items: File[]): Promise =>
Promise.all(Arr.map(items, (file) => {
return Conversions.blobToDataUri(file).then((uri) => ({ file, uri }));
}));
const isImage = (editor: Editor) => {
const allowedExtensions = Options.getAllowedImageFileTypes(editor);
return (file: File): boolean => Strings.startsWith(file.type, 'image/') && Arr.exists(allowedExtensions, (extension) => {
return PasteUtils.getImageMimeType(extension) === file.type;
});
};
const getImagesFromDataTransfer = (editor: Editor, dataTransfer: DataTransfer): File[] => {
const items = dataTransfer.items ? Arr.bind(Arr.from(dataTransfer.items), (item) => {
return item.kind === 'file' ? [ item.getAsFile() as File ] : [];
}) : [];
const files = dataTransfer.files ? Arr.from(dataTransfer.files) : [];
return Arr.filter(items.length > 0 ? items : files, isImage(editor));
};
/*
* Checks if the clipboard contains image data if it does it will take that data
* and convert it into a data url image and paste that image at the caret location.
*/
const pasteImageData = (editor: Editor, e: ClipboardEvent | DragEvent, rng: Range | undefined): boolean => {
const dataTransfer = isClipboardEvent(e) ? e.clipboardData : e.dataTransfer;
if (Options.shouldPasteDataImages(editor) && dataTransfer) {
const images = getImagesFromDataTransfer(editor, dataTransfer);
if (images.length > 0) {
e.preventDefault();
readFilesAsDataUris(images).then((fileResults) => {
if (rng) {
editor.selection.setRng(rng);
}
Arr.each(fileResults, (result) => {
pasteImage(editor, result);
});
});
return true;
}
}
return false;
};
// Chrome on Android doesn't support proper clipboard access so we have no choice but to allow the browser default behavior.
const isBrokenAndroidClipboardEvent = (e: ClipboardEvent): boolean =>
Env.os.isAndroid() && e.clipboardData?.items?.length === 0;
// Ctrl+V or Shift+Insert
const isKeyboardPasteEvent = (e: KeyboardEvent): boolean =>
(VK.metaKeyPressed(e) && e.keyCode === 86) || (e.shiftKey && e.keyCode === 45);
const insertClipboardContent = (editor: Editor, clipboardContent: ClipboardContents, html: string, plainTextMode: boolean): void => {
let content = PasteUtils.trimHtml(html);
const isInternal = hasContentType(clipboardContent, InternalHtml.internalHtmlMime()) || InternalHtml.isMarked(html);
const isPlainTextHtml = !isInternal && Newlines.isPlainText(content);
const isAbsoluteUrl = SmartPaste.isAbsoluteUrl(content);
// If the paste bin is empty try using plain text mode since that is better than nothing right?
// Also if we got nothing from clipboard API/pastebin or the content is a plain text (with only
// some BRs, Ps or DIVs as newlines) then we fallback to plain/text
if (isDefaultPasteBinContent(content) || !content.length || (isPlainTextHtml && !isAbsoluteUrl)) {
plainTextMode = true;
}
// Grab plain text from Clipboard API or convert existing HTML to plain text
if (plainTextMode || isAbsoluteUrl) {
// Use plain text contents from Clipboard API unless the HTML contains paragraphs then
// we should convert the HTML to plain text since works better when pasting HTML/Word contents as plain text
if (hasContentType(clipboardContent, 'text/plain') && isPlainTextHtml) {
content = clipboardContent['text/plain'];
} else {
content = PasteUtils.innerText(content);
}
}
// If the content is the paste bin default HTML then it was impossible to get the clipboard data out.
if (isDefaultPasteBinContent(content)) {
return;
}
if (plainTextMode) {
pasteText(editor, content);
} else {
pasteHtml(editor, content, isInternal);
}
};
const registerEventHandlers = (editor: Editor, pasteBin: PasteBin, pasteFormat: Cell): void => {
let keyboardPastePlainTextState: boolean;
const getLastRng = (): Range =>
pasteBin.getLastRng() || editor.selection.getRng();
editor.on('keydown', (e) => {
if (isKeyboardPasteEvent(e) && !e.isDefaultPrevented()) {
keyboardPastePlainTextState = e.shiftKey && e.keyCode === 86;
}
});
editor.on('paste', (e: EditorEvent) => {
if (e.isDefaultPrevented() || isBrokenAndroidClipboardEvent(e)) {
return;
}
const plainTextMode = pasteFormat.get() === 'text' || keyboardPastePlainTextState;
keyboardPastePlainTextState = false;
const clipboardContent = getDataTransferItems(e.clipboardData);
if (!hasHtmlOrText(clipboardContent) && pasteImageData(editor, e, getLastRng())) {
return;
}
// If the clipboard API has HTML then use that directly
if (hasContentType(clipboardContent, 'text/html')) {
e.preventDefault();
insertClipboardContent(editor, clipboardContent, clipboardContent['text/html'], plainTextMode);
} else {
// We can't extract the HTML content from the clipboard so we need to allow the paste
// to run via the pastebin and then extract from there
pasteBin.create();
Delay.setEditorTimeout(editor, () => {
// Get the pastebin content and then remove it so the selection is restored
const html = pasteBin.getHtml();
pasteBin.remove();
insertClipboardContent(editor, clipboardContent, html, plainTextMode);
}, 0);
}
});
};
const registerDataImageFilter = (editor: Editor) => {
const isWebKitFakeUrl = (src: string): boolean => Strings.startsWith(src, 'webkit-fake-url');
const isDataUri = (src: string): boolean => Strings.startsWith(src, 'data:');
const isPasteInsert = (args: ParserArgs): boolean => args.data?.paste === true;
// Remove all data images from paste for example from Gecko
// except internal images like video elements
editor.parser.addNodeFilter('img', (nodes, name, args) => {
if (!Options.shouldPasteDataImages(editor) && isPasteInsert(args)) {
for (const node of nodes) {
const src = node.attr('src');
if (Type.isString(src) && !node.attr('data-mce-object') && src !== Env.transparentSrc) {
// Safari on Mac produces webkit-fake-url see: https://bugs.webkit.org/show_bug.cgi?id=49141
if (isWebKitFakeUrl(src)) {
node.remove();
} else if (!Options.shouldAllowHtmlDataUrls(editor) && isDataUri(src)) {
node.remove();
}
}
}
}
});
};
/*
* This class contains logic for getting HTML contents out of the clipboard.
*
* This by default will attempt to use the W3C clipboard API to get HTML content.
* If that can't be used then fallback to letting the browser paste natively with
* some logic to clean up what the browser generated, as it can mutate the content.
*
* Current implementation steps:
* 1. On keydown determine if we should paste as plain text.
* 2. Wait for the browser to fire a "paste" event and get the contents out of clipboard.
* 3. If no content is available, then attach the paste bin and change the selection to be inside the bin.
* 4. Extract the contents from the bin in the next event loop.
* 5. If no HTML is found or we're using plain text paste mode then convert the HTML or lookup the clipboard to get the plain text.
* 6. Process the content from the clipboard or pastebin and insert it into the editor.
*/
const registerEventsAndFilters = (editor: Editor, pasteBin: PasteBin, pasteFormat: Cell): void => {
registerEventHandlers(editor, pasteBin, pasteFormat);
registerDataImageFilter(editor);
};
export {
registerEventsAndFilters,
pasteHtml,
pasteText,
pasteImageData,
getDataTransferItems,
hasHtmlOrText,
hasContentType
};