0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-22 15:23:29 -05:00
Squire/source/Clipboard.ts
Neil Jenkins fe0dfdf6c4 Squire 2.0
This is a massive refactor to port Squire to TypeScript, fix a bunch of small
bugs and modernise our tooling. The development was done on an internal
repository, so apologies to anyone following externally for the commit dump;
updates from here should come as real commits again.

Co-authored-by: Joe Woods <woods@fastmailteam.com>
2023-01-23 13:18:29 +11:00

422 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanupBRs } from './Clean';
import { isWin, isGecko, isLegacyEdge, notWS } from './Constants';
import { createElement, detach } from './node/Node';
import { getStartBlockOfRange, getEndBlockOfRange } from './range/Block';
import { createRange, deleteContentsOfRange } from './range/InsertDelete';
import {
moveRangeBoundariesDownTree,
moveRangeBoundariesUpTree,
} from './range/Boundaries';
import type { Squire } from './Editor';
// ---
const indexOf = Array.prototype.indexOf;
// The (non-standard but supported enough) innerText property is based on the
// render tree in Firefox and possibly other browsers, so we must insert the
// DOM node into the document to ensure the text part is correct.
const setClipboardData = (
event: ClipboardEvent,
contents: Node,
root: HTMLElement,
toCleanHTML: null | ((html: string) => string),
toPlainText: null | ((html: string) => string),
plainTextOnly: boolean,
): void => {
const clipboardData = event.clipboardData!;
const body = document.body;
const node = createElement('DIV') as HTMLDivElement;
let html: string | undefined;
let text: string | undefined;
if (
contents.childNodes.length === 1 &&
contents.childNodes[0] instanceof Text
) {
// Replace nbsp with regular space;
// eslint-disable-next-line no-irregular-whitespace
text = contents.childNodes[0].data.replace(/ /g, ' ');
plainTextOnly = true;
} else {
node.appendChild(contents);
html = node.innerHTML;
if (toCleanHTML) {
html = toCleanHTML(html);
}
}
if (text !== undefined) {
// Do nothing; we were copying plain text to start
} else if (toPlainText && html !== undefined) {
text = toPlainText(html);
} else {
// Firefox will add an extra new line for BRs at the end of block when
// calculating innerText, even though they don't actually affect
// display, so we need to remove them first.
cleanupBRs(node, root, true);
node.setAttribute(
'style',
'position:fixed;overflow:hidden;bottom:100%;right:100%;',
);
body.appendChild(node);
text = node.innerText || node.textContent!;
// Replace nbsp with regular space
// eslint-disable-next-line no-irregular-whitespace
text = text.replace(/ /g, ' ');
body.removeChild(node);
}
// Firefox (and others?) returns unix line endings (\n) even on Windows.
// If on Windows, normalise to \r\n, since Notepad and some other crappy
// apps do not understand just \n.
if (isWin) {
text = text.replace(/\r?\n/g, '\r\n');
}
if (!plainTextOnly && html && text !== html) {
clipboardData.setData('text/html', html);
}
clipboardData.setData('text/plain', text);
event.preventDefault();
};
const extractRangeToClipboard = (
event: ClipboardEvent,
range: Range,
root: HTMLElement,
removeRangeFromDocument: boolean,
toCleanHTML: null | ((html: string) => string),
toPlainText: null | ((html: string) => string),
plainTextOnly: boolean,
): boolean => {
// Edge only seems to support setting plain text as of 2016-03-11.
if (!isLegacyEdge && event.clipboardData) {
// Clipboard content should include all parents within block, or all
// parents up to root if selection across blocks
const startBlock = getStartBlockOfRange(range, root);
const endBlock = getEndBlockOfRange(range, root);
let copyRoot = root;
// If the content is not in well-formed blocks, the start and end block
// may be the same, but actually the range goes outside it. Must check!
if (
startBlock === endBlock &&
startBlock?.contains(range.commonAncestorContainer)
) {
copyRoot = startBlock;
}
// Extract the contents
let contents: Node;
if (removeRangeFromDocument) {
contents = deleteContentsOfRange(range, root);
} else {
// Clone range to mutate, then move up as high as possible without
// passing the copy root node.
range = range.cloneRange();
moveRangeBoundariesDownTree(range);
moveRangeBoundariesUpTree(range, copyRoot, copyRoot, root);
contents = range.cloneContents();
}
// Add any other parents not in extracted content, up to copy root
let parent = range.commonAncestorContainer;
if (parent instanceof Text) {
parent = parent.parentNode!;
}
while (parent && parent !== copyRoot) {
const newContents = parent.cloneNode(false);
newContents.appendChild(contents);
contents = newContents;
parent = parent.parentNode!;
}
// Set clipboard data
setClipboardData(
event,
contents,
root,
toCleanHTML,
toPlainText,
plainTextOnly,
);
return true;
}
return false;
};
// ---
const _onCut = function (this: Squire, event: ClipboardEvent): void {
const range: Range = this.getSelection();
const root: HTMLElement = this._root;
// Nothing to do
if (range.collapsed) {
event.preventDefault();
return;
}
// Save undo checkpoint
this.saveUndoState(range);
const handled = extractRangeToClipboard(
event,
range,
root,
true,
this._config.willCutCopy,
null,
false,
);
if (!handled) {
setTimeout(() => {
try {
// If all content removed, ensure div at start of root.
this._ensureBottomLine();
} catch (error) {
this._config.didError(error);
}
}, 0);
}
this.setSelection(range);
};
const _onCopy = function (this: Squire, event: ClipboardEvent): void {
extractRangeToClipboard(
event,
this.getSelection(),
this._root,
false,
this._config.willCutCopy,
null,
false,
);
};
// Need to monitor for shift key like this, as event.shiftKey is not available
// in paste event.
const _monitorShiftKey = function (this: Squire, event: KeyboardEvent): void {
this._isShiftDown = event.shiftKey;
};
const _onPaste = function (this: Squire, event: ClipboardEvent): void {
const clipboardData = event.clipboardData;
const items = clipboardData?.items;
const choosePlain: boolean | undefined = this._isShiftDown;
let hasRTF = false;
let hasImage = false;
let plainItem: null | DataTransferItem = null;
let htmlItem: null | DataTransferItem = null;
// Current HTML5 Clipboard interface
// ---------------------------------
// https://html.spec.whatwg.org/multipage/interaction.html
if (items) {
let l = items.length;
while (l--) {
const item = items[l];
const type = item.type;
if (type === 'text/html') {
htmlItem = item;
// iOS copy URL gives you type text/uri-list which is just a list
// of 1 or more URLs separated by new lines. Can just treat as
// plain text.
} else if (type === 'text/plain' || type === 'text/uri-list') {
plainItem = item;
} else if (type === 'text/rtf') {
hasRTF = true;
} else if (/^image\/.*/.test(type)) {
hasImage = true;
}
}
// Treat image paste as a drop of an image file. When you copy
// an image in Chrome/Firefox (at least), it copies the image data
// but also an HTML version (referencing the original URL of the image)
// and a plain text version.
//
// However, when you copy in Excel, you get html, rtf, text, image;
// in this instance you want the html version! So let's try using
// the presence of text/rtf as an indicator to choose the html version
// over the image.
if (hasImage && !(hasRTF && htmlItem)) {
event.preventDefault();
this.fireEvent('pasteImage', {
clipboardData,
});
return;
}
// Edge only provides access to plain text as of 2016-03-11 and gives no
// indication there should be an HTML part. However, it does support
// access to image data, so we check for that first. Otherwise though,
// fall through to fallback clipboard handling methods
if (!isLegacyEdge) {
event.preventDefault();
if (htmlItem && (!choosePlain || !plainItem)) {
htmlItem.getAsString((html) => {
this.insertHTML(html, true);
});
} else if (plainItem) {
plainItem.getAsString((text) => {
// If we have a selection and text is solely a URL,
// just make the text a link.
let isLink = false;
const range = this.getSelection();
if (!range.collapsed && notWS.test(range.toString())) {
const match = this.linkRegExp.exec(text);
isLink = !!match && match[0].length === text.length;
}
if (isLink) {
this.makeLink(text);
} else {
this.insertPlainText(text, true);
}
});
}
return;
}
}
// Old interface
// -------------
// Safari (and indeed many other OS X apps) copies stuff as text/rtf
// rather than text/html; even from a webpage in Safari. The only way
// to get an HTML version is to fallback to letting the browser insert
// the content. Same for getting image data. *Sigh*.
//
// Firefox is even worse: it doesn't even let you know that there might be
// an RTF version on the clipboard, but it will also convert to HTML if you
// let the browser insert the content. I've filed
// https://bugzilla.mozilla.org/show_bug.cgi?id=1254028
const types = clipboardData?.types;
if (
!isLegacyEdge &&
types &&
(indexOf.call(types, 'text/html') > -1 ||
(!isGecko &&
indexOf.call(types, 'text/plain') > -1 &&
indexOf.call(types, 'text/rtf') < 0))
) {
event.preventDefault();
// Abiword on Linux copies a plain text and html version, but the HTML
// version is the empty string! So always try to get HTML, but if none,
// insert plain text instead. On iOS, Facebook (and possibly other
// apps?) copy links as type text/uri-list, but also insert a **blank**
// text/plain item onto the clipboard. Why? Who knows.
let data;
if (!choosePlain && (data = clipboardData.getData('text/html'))) {
this.insertHTML(data, true);
} else if (
(data = clipboardData.getData('text/plain')) ||
(data = clipboardData.getData('text/uri-list'))
) {
this.insertPlainText(data, true);
}
return;
}
// No interface. Includes all versions of IE :(
// --------------------------------------------
const body = document.body;
const range = this.getSelection();
const startContainer = range.startContainer;
const startOffset = range.startOffset;
const endContainer = range.endContainer;
const endOffset = range.endOffset;
// We need to position the pasteArea in the visible portion of the screen
// to stop the browser auto-scrolling.
let pasteArea: Element = createElement('DIV', {
contenteditable: 'true',
style: 'position:fixed; overflow:hidden; top:0; right:100%; width:1px; height:1px;',
});
body.appendChild(pasteArea);
range.selectNodeContents(pasteArea);
this.setSelection(range);
// A setTimeout of 0 means this is added to the back of the
// single javascript thread, so it will be executed after the
// paste event.
setTimeout(() => {
try {
// Get the pasted content and clean
let html = '';
let next: Element = pasteArea;
let first: Node | null;
// #88: Chrome can apparently split the paste area if certain
// content is inserted; gather them all up.
while ((pasteArea = next)) {
next = pasteArea.nextSibling as Element;
detach(pasteArea);
// Safari and IE like putting extra divs around things.
first = pasteArea.firstChild;
if (
first &&
first === pasteArea.lastChild &&
first instanceof HTMLDivElement
) {
pasteArea = first;
}
html += pasteArea.innerHTML;
}
this.setSelection(
createRange(
startContainer,
startOffset,
endContainer,
endOffset,
),
);
if (html) {
this.insertHTML(html, true);
}
} catch (error) {
this._config.didError(error);
}
}, 0);
};
// On Windows you can drag an drop text. We can't handle this ourselves, because
// as far as I can see, there's no way to get the drop insertion point. So just
// save an undo state and hope for the best.
const _onDrop = function (this: Squire, event: DragEvent): void {
// it's possible for dataTransfer to be null, let's avoid it.
if (!event.dataTransfer) {
return;
}
const types = event.dataTransfer.types;
let l = types.length;
let hasPlain = false;
let hasHTML = false;
while (l--) {
switch (types[l]) {
case 'text/plain':
hasPlain = true;
break;
case 'text/html':
hasHTML = true;
break;
default:
return;
}
}
if (hasHTML || (hasPlain && this.saveUndoState)) {
this.saveUndoState();
}
};
// ---
export {
extractRangeToClipboard,
_onCut,
_onCopy,
_monitorShiftKey,
_onPaste,
_onDrop,
};