mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-22 07:13:08 -05:00
fe0dfdf6c4
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>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
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,
|
||
};
|