import {Promise} from "es6-promise"; import {DT, suppressDTWarnings} from "./DT"; // Debug log strings should be short, since they are copmiled into the production build. // TODO: Compile debug logging code out of production builds? var debugLog: (s: string) => void = function(s: string) {}; var showWarnings = true; var warn = (console.warn || console.log).bind(console, "[clipboard-polyfill]"); var TEXT_PLAIN = "text/plain"; export default class ClipboardPolyfill { public static readonly DT = DT; public static setDebugLog(f: (s: string) => void): void { debugLog = f; } public static suppressWarnings() { showWarnings = false; suppressDTWarnings(); } public static write(data: DT): Promise { if (showWarnings && !data.getData(TEXT_PLAIN)) { warn("clipboard.write() was called without a "+ "`text/plain` data type. On some platforms, this may result in an "+ "empty clipboard. Call clipboard.suppressWarnings() "+ "to suppress this warning."); } return new Promise((resolve, reject) => { // Internet Explorer if (seemToBeInIE()) { if (writeIE(data)) { resolve() } else { reject(new Error("Copying failed, possibly because the user rejected it.")); } return; } var tracker = execCopy(data); if (tracker.success) { debugLog("regular execCopy worked"); resolve(); return; } // Success detection on Edge is not possible, due to bugs in all 4 // detection mechanisms we could try to use. Assume success. if (navigator.userAgent.indexOf("Edge") > -1) { debugLog("UA \"Edge\" => assuming success"); resolve(); return; } // Fallback 1 for desktop Safari. tracker = copyUsingTempSelection(document.body, data); if (tracker.success) { debugLog("copyUsingTempSelection worked"); resolve(); return; } // Fallback 2 for desktop Safari. tracker = copyUsingTempElem(data); if (tracker.success) { debugLog("copyUsingTempElem worked"); resolve(); return; } // Fallback for iOS Safari. var text = data.getData(TEXT_PLAIN); if (text !== undefined && copyTextUsingDOM(text)) { debugLog("copyTextUsingDOM worked"); resolve(); return; } reject(new Error("Copy command failed.")); }); } public static writeText(s: string): Promise { var dt = new DT(); dt.setData(TEXT_PLAIN, s); return this.write(dt); } public static read(): Promise
{ return new Promise((resolve, reject) => { if (seemToBeInIE()) { readIE().then( (s: string) => resolve(DTFromText(s)), reject ); return; } // TODO: Attempt to read using async clipboard API. reject("Read is not supported in your browser.") }); } public static readText(): Promise { if (seemToBeInIE()) { return readIE(); } return new Promise((resolve, reject) => { // TODO: Attempt to read using async clipboard API. reject("Read is not supported in your browser.") }); } } /******** Implementations ********/ class FallbackTracker { public success: boolean = false; } function copyListener(tracker: FallbackTracker, data: DT, e: ClipboardEvent): void { debugLog("listener called"); tracker.success = true; data.forEach((value: string, key: string) => { e.clipboardData.setData(key, value); if (key === TEXT_PLAIN && e.clipboardData.getData(key) != value) { debugLog("setting text/plain failed"); tracker.success = false; } }); e.preventDefault(); } function execCopy(data: DT): FallbackTracker { var tracker = new FallbackTracker(); var listener = copyListener.bind(this, tracker, data); document.addEventListener("copy", listener); try { // We ignore the return value, since FallbackTracker tells us whether the // listener was called. It seems that checking the return value here gives // us no extra information in any browser. document.execCommand("copy"); } finally { document.removeEventListener("copy", listener); } return tracker; } // Create a temporary DOM element to select, so that `execCommand()` is not // rejected. function copyUsingTempSelection(e: HTMLElement, data: DT): FallbackTracker { selectionSet(e); var tracker = execCopy(data); selectionClear(); return tracker; } // Create a temporary DOM element to select, so that `execCommand()` is not // rejected. function copyUsingTempElem(data: DT): FallbackTracker { var tempElem = document.createElement("div"); // Place some text in the elem so that Safari has something to select. tempElem.textContent = "temporary element"; document.body.appendChild(tempElem); var tracker = copyUsingTempSelection(tempElem, data); document.body.removeChild(tempElem); return tracker; } // Uses shadow DOM. function copyTextUsingDOM(str: string): boolean { debugLog("copyTextUsingDOM"); var tempElem = document.createElement("div"); // Use shadow DOM if available. var spanParent: Node = tempElem; if (tempElem.attachShadow) { debugLog("Using shadow DOM."); spanParent = tempElem.attachShadow({mode: "open"}); } var span = document.createElement("span"); span.innerText = str; // span.style.whiteSpace = "pre-wrap"; // TODO: Use `innerText` above instead? spanParent.appendChild(span); document.body.appendChild(tempElem); selectionSet(span); var result = document.execCommand("copy"); selectionClear(); document.body.removeChild(tempElem); return result; } /******** Selection ********/ function selectionSet(elem: Element): void { var sel = document.getSelection(); var range = document.createRange(); range.selectNodeContents(elem); sel.removeAllRanges(); sel.addRange(range); } function selectionClear(): void { var sel = document.getSelection(); sel.removeAllRanges(); } /******** Convenience ********/ function DTFromText(s: string): DT { var dt = new DT(); dt.setData(TEXT_PLAIN, s); return dt; } /******** Internet Explorer ********/ interface IEWindow extends Window { clipboardData: { setData: (key: string, value: string) => boolean; // Always results in a string: https://msdn.microsoft.com/en-us/library/ms536436(v=vs.85).aspx getData: (key: string) => string; } } function seemToBeInIE(): boolean { return typeof ClipboardEvent === "undefined" && typeof (window as IEWindow).clipboardData !== "undefined" && typeof (window as IEWindow).clipboardData.setData !== "undefined"; } function writeIE(data: DT): boolean { // IE supports text or URL, but not HTML: https://msdn.microsoft.com/en-us/library/ms536744(v=vs.85).aspx // TODO: Write URLs to `text/uri-list`? https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types var text = data.getData(TEXT_PLAIN); if (text !== undefined) { return (window as IEWindow).clipboardData.setData("Text", text); } throw ("No `text/plain` value was specified."); } // Returns "" if the read failed, e.g. because the user rejected the permission. function readIE(): Promise { return new Promise((resolve, reject) => { var text = (window as IEWindow).clipboardData.getData("Text"); if (text === "") { reject(new Error("Empty clipboard or could not read plain text from clipboard")); } else { resolve(text); } }) } /******** Expose `clipboard` on the global object in browser. ********/ // TODO: Figure out how to expose ClipboardPolyfill as self.clipboard using // WebPack? declare var module: any; module.exports = ClipboardPolyfill;