mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-22 07:13:08 -05:00
950e122c5c
Instead of using innerText, this now uses the getTextContentsOfRange helper fn to work out the white space, so is consistent with the editor's getSelectedText method and should be higher fidelity. Note, you can still override the behaviour by supplying a toPlainText fn in the editor config (such as the one you can find at https://github.com/fastmail/overture/blob/master/source/html/toPlainText.js)
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
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';
|
||
import { getTextContentsOfRange } from './range/Contents';
|
||
|
||
// ---
|
||
|
||
const indexOf = Array.prototype.indexOf;
|
||
|
||
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.
|
||
const clipboardData = event.clipboardData;
|
||
if (isLegacyEdge || !clipboardData) {
|
||
return false;
|
||
}
|
||
// First get the plain text version from the range (unless we have a custom
|
||
// HTML -> Text conversion fn)
|
||
let text = toPlainText ? '' : getTextContentsOfRange(range);
|
||
|
||
// 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!;
|
||
}
|
||
|
||
// Get HTML version of data
|
||
let html: 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 {
|
||
const node = createElement('DIV') as HTMLDivElement;
|
||
node.appendChild(contents);
|
||
html = node.innerHTML;
|
||
if (toCleanHTML) {
|
||
html = toCleanHTML(html);
|
||
}
|
||
}
|
||
|
||
// Get Text version of data
|
||
if (plainTextOnly) {
|
||
// Do nothing; we were copying plain text to start
|
||
} else if (toPlainText && html !== undefined) {
|
||
text = toPlainText(html);
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
|
||
// Set clipboard data
|
||
if (!plainTextOnly && html && text !== html) {
|
||
clipboardData.setData('text/html', html);
|
||
}
|
||
clipboardData.setData('text/plain', text);
|
||
event.preventDefault();
|
||
|
||
return true;
|
||
};
|
||
|
||
// ---
|
||
|
||
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,
|
||
this._config.toPlainText,
|
||
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,
|
||
this._config.toPlainText,
|
||
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,
|
||
};
|