2023-01-22 19:35:12 -05:00
|
|
|
|
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';
|
2023-10-01 21:27:19 -05:00
|
|
|
|
import { getTextContentsOfRange } from './range/Contents';
|
2023-01-22 19:35:12 -05:00
|
|
|
|
|
|
|
|
|
// ---
|
|
|
|
|
|
|
|
|
|
const indexOf = Array.prototype.indexOf;
|
|
|
|
|
|
2023-10-01 21:27:19 -05:00
|
|
|
|
const extractRangeToClipboard = (
|
2023-01-22 19:35:12 -05:00
|
|
|
|
event: ClipboardEvent,
|
2023-10-01 21:27:19 -05:00
|
|
|
|
range: Range,
|
2023-01-22 19:35:12 -05:00
|
|
|
|
root: HTMLElement,
|
2023-10-01 21:27:19 -05:00
|
|
|
|
removeRangeFromDocument: boolean,
|
2023-01-22 19:35:12 -05:00
|
|
|
|
toCleanHTML: null | ((html: string) => string),
|
|
|
|
|
toPlainText: null | ((html: string) => string),
|
|
|
|
|
plainTextOnly: boolean,
|
2023-10-01 21:27:19 -05:00
|
|
|
|
): 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;
|
2023-01-22 19:35:12 -05:00
|
|
|
|
|
2023-10-01 21:27:19 -05:00
|
|
|
|
// 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;
|
2023-01-22 19:35:12 -05:00
|
|
|
|
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 {
|
2023-10-01 21:27:19 -05:00
|
|
|
|
const node = createElement('DIV') as HTMLDivElement;
|
2023-01-22 19:35:12 -05:00
|
|
|
|
node.appendChild(contents);
|
|
|
|
|
html = node.innerHTML;
|
|
|
|
|
if (toCleanHTML) {
|
|
|
|
|
html = toCleanHTML(html);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-02 19:13:02 -05:00
|
|
|
|
// Get Text version of data if converting from HTML
|
|
|
|
|
if (toPlainText && html !== undefined) {
|
2023-01-22 19:35:12 -05:00
|
|
|
|
text = toPlainText(html);
|
|
|
|
|
}
|
2023-10-01 21:27:19 -05:00
|
|
|
|
|
2023-01-22 19:35:12 -05:00
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-01 21:27:19 -05:00
|
|
|
|
// Set clipboard data
|
2023-01-22 19:35:12 -05:00
|
|
|
|
if (!plainTextOnly && html && text !== html) {
|
2024-07-17 22:05:11 -05:00
|
|
|
|
html = '<!-- squire -->' + html;
|
2023-01-22 19:35:12 -05:00
|
|
|
|
clipboardData.setData('text/html', html);
|
|
|
|
|
}
|
|
|
|
|
clipboardData.setData('text/plain', text);
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
2023-10-01 21:27:19 -05:00
|
|
|
|
return true;
|
2023-01-22 19:35:12 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---
|
|
|
|
|
|
|
|
|
|
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,
|
2023-10-01 21:01:16 -05:00
|
|
|
|
this._config.toPlainText,
|
2023-01-22 19:35:12 -05:00
|
|
|
|
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,
|
2023-10-01 21:01:16 -05:00
|
|
|
|
this._config.toPlainText,
|
2023-01-22 19:35:12 -05:00
|
|
|
|
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,
|
|
|
|
|
};
|