10

I've noticed that the document.execCommand('copy') command times out after about 5s when running in the background. Is there a way to get around this limitation, or perhaps a fallback if it takes longer than that?

Here is the page that I've been using for the Clipboard docs. For example, I have a function that 'prepares' the data (generating html from tabular data) and then a second function that copies it to the clipboard with some additional markup. On large tables this can often take perhaps ten seconds from the time a user press Cmd-C until the html is generated and able to be copied.

Additionally, I've noticed Google Sheets allows Copy operations that extend beyond five seconds so I'm curious how they would be doing it:

# still works after 25 seconds!
[Violation] 'copy' handler took 25257ms     2217559571-waffle_js_prod_core.js:337 

The code is minified/obfuscated so very difficult to read but here is the file from above: https://docs.google.com/static/spreadsheets2/client/js/2217559571-waffle_js_prod_core.js.

For reference, the amount of data being copied is about 50MB. Please use a ~10 second delay on the copy operation to simulate this long-running process.


For the bounty, I'm hoping someone could show a working example of doing a single Cmd-C to either:

  • Is it possible to have a long-running copy operation in the background (i.e., asynchronously), for example with a web worker?
  • If it must be done synchronously, an example of doing the copy operation, showing some progress -- for example, maybe the copy operation emits an event after every 10k rows or so.

It must generate html and must involve only a single Cmd-C (even if we use a preventDefault and trigger the copy-event in the background.


You can use the following as a template for how the 'html-generation' function should work:

function sleepFor( sleepDuration ){
    var now = new Date().getTime();
    while(new Date().getTime() < now + sleepDuration){ /* do nothing */ } 
}

// note: the data should be copied to a dom element and not a string
//       so it can be used on `document.execCommand("copy")`
//       but using a string below as its easier to demonstrate
//       note, however, that it will give a "range exceeded" error
//       on very large strings  (when using the string, but ignore that, 
//       as it won't occur when using the proper dom element

var sall='<html><table>'
var srow='<tr><td  ><div style="text-align: right"><span style="color: #060606; ">1</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">Feb 27, 2018</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">315965</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CA</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">SDBUY</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">9.99</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CAD</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">7.88</span></div></td></tr>'
for (i=0; i<1e6; i++) {
    sall += srow;
    if (i%1e5==0) sleepFor(1000); // simulate a 10 second operation...
    if (i==(1e6-1)) console.log('Done')
}
sall += '</table></html>'
// now copy to clipboard

If helpful to reproduce a true copy event: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard.

David542
  • 104,438
  • 178
  • 489
  • 842
  • I didn't took the time to search thoroughly yet, but a few points: google chrome includes some extensions that do interact with google's pages beyond what we can normally do with web APIs. I don't know if here is such a case, I know they do it with meet for instance. One could verify this by using an other browser to test. Also, once marked as approved the async Clipboard API should not require an user gesture anymore, if the site is served from a securecontext. – Kaiido Feb 07 '21 at 08:43
  • @Kaiido I've updated with sample code for the question, does that help at all? – David542 Feb 08 '21 at 04:41
  • 1
    It looks as though Google uses e.clipboardData.setData("Text", content); for copying – Seth B Feb 11 '21 at 01:06
  • @SethB thanks, how can you tell? Would you want to post an answer showing how they do it with a basic example? – David542 Feb 11 '21 at 01:56
  • @Kaiido are the two answers here helpful for your comment pointers? – David542 Feb 11 '21 at 02:27

4 Answers4

7

Here's what I found:

In this script: https://docs.google.com/static/spreadsheets2/client/js/1150385833-codemirror.js

I found this function:

function onCopyCut(e) {
  if (!belongsToInput(e) || signalDOMEvent(cm, e))
    return;
  if (cm.somethingSelected()) {
    setLastCopied({
      lineWise: false,
      text: cm.getSelections()
    });
    if (e.type == "cut")
      cm.replaceSelection("", null, "cut")
  } else if (!cm.options.lineWiseCopyCut)
    return;
  else {
    var ranges = copyableRanges(cm);
    setLastCopied({
      lineWise: true,
      text: ranges.text
    });
    if (e.type == "cut")
      cm.operation(function() {
        cm.setSelections(ranges.ranges, 0, sel_dontScroll);
        cm.replaceSelection("", null, "cut")
      })
  }
  if (e.clipboardData) {
    e.clipboardData.clearData();
    var content = lastCopied.text.join("\n");
    e.clipboardData.setData("Text", content);
    if (e.clipboardData.getData("Text") == content) {
      e.preventDefault();
      return
    }
  }
  var kludge = hiddenTextarea(),
    te = kludge.firstChild;
  cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild);
  te.value = lastCopied.text.join("\n");
  var hadFocus = document.activeElement;
  selectInput(te);
  setTimeout(function() {
    cm.display.lineSpace.removeChild(kludge);
    hadFocus.focus();
    if (hadFocus == div)
      input.showPrimarySelection()
  }, 50)
}

NEW FIND

I found that Google sheets loads this script:

(function() {
    window._docs_chrome_extension_exists = !0;
    window._docs_chrome_extension_features_version = 1;
    window._docs_chrome_extension_permissions = "alarms clipboardRead clipboardWrite identity power storage unlimitedStorage".split(" ");
}
).call(this);

This is tied to their own extensions

NEW FIND 2

When I paste in a cell, it uses these two functions:

Script: https://docs.google.com/static/spreadsheets2/client/js/1526657789-waffle_js_prod_core.js

p.B_a = function(a) {
  var b = a.Ge().clipboardData;
  if (b && (b = b.getData("text/plain"),
      !be(Kf(b)))) {
    b = Lm(b);
    var c = this.C.getRange(),
      d = this.C.getRange();
    d.jq() && $fc(this.Fd(), d) == this.getValue().length && (c = this.Fd(),
      d = c.childNodes.length,
      c = TJ(c, 0 < d && XJ(c.lastChild) ? d - 1 : d));
    c.yP(b);
    VJ(b, !1);
    a.preventDefault()
  }
};
p.Z1b = function() {
  var a = this.C.getRange();
  a && 1 < fec(a).textContent.length && SAc(this)
}

NEW FIND 3

This function is used when I select all and copy:

Script: https://docs.google.com/static/spreadsheets2/client/js/1526657789-waffle_js_prod_core.js

p.bxa = function(a, b) {
  this.D = b && b.Ge().clipboardData || null;
  this.J = !1;
  try {
    this.rda();
    if (this.D && "paste" == b.type) {
      var c = this.D,
        d = this.L,
        e = {},
        f = [];
      if (void 0 !== c.items)
        for (var h = c.items, k = 0; k < h.length; k++) {
          var l = h[k],
            n = l.type;
          f.push(n);
          if (!e[n] && d(n)) {
            a: switch (l.kind) {
              case "string":
                var q = xk(c.getData(l.type));
                break a;
              case "file":
                var t = l.getAsFile();
                q = t ? Bnd(t) : null;
                break a;
              default:
                q = null
            }
            var u = q;
            u && (e[n] = u)
          }
        }
      else {
        var z = c.types || [];
        for (h = 0; h < z.length; h++) {
          var E = z[h];
          f.push(E);
          !e[E] && d(E) && (e[E] = xk(c.getData(E)))
        }
        k = c.files || [];
        for (c = 0; c < k.length; c++) {
          u = k[c];
          var L = u.type;
          f.push(L);
          !e[L] && d(L) && (e[L] = Bnd(u))
        }
      }
      this.C = e;
      a: {
        for (d = 0; d < f.length; d++)
          if ("text/html" == f[d]) {
            var Q = !0;
            break a
          }
        Q = !1
      }
      this.H = Q || !And(f)
    }
    this.F.bxa(a, b);
    this.J && b.preventDefault()
  } finally {
    this.D = null
  }
}

ANSWER TO YOUR COMMENT

Here is the difference between e.clipboardData.setData() and execCommand("copy"):

e.clipboardData.setData() is used to manipulate the data going to the clipboard.

execCommand("copy") programatically calls the CMD/CTRL + C.

If you call execCommand("copy"), it will just copy your current selection just as if you pressed CMD/CTRL + C. You can also use this function with e.clipboardData.setData():

//Button being a HTML button element
button.addEventListener("click",function(){
  execCommand("copy");
});

//This function is called by a click or CMD/CTRL + C
window.addEventListener("copy",function(e){
  e.preventDefault();  
  e.clipboardData.setData("text/plain", "Hey!");
}

NEW FIND 3 (POSSIBLE ANSWER)

Don't use setTimeout for simulating long text because it will freeze up the UI. Instead, just use a large chunk of text.

This script works without timing out.

window.addEventListener('copy', function(e) {
  e.preventDefault();

  console.log("Started!");
  //This will throw an error on StackOverflow, but works on my website.
  //Use this to disable it for testing on StackOverflow
  //if (!(navigator.clipboard)) {
  if (navigator.clipboard) {
    document.getElementById("status").innerHTML = 'Copying, do not leave page.';
    document.getElementById("main").style.backgroundColor = '#BB595C';
    tryCopyAsync(e).then(() =>
      document.getElementById("main").style.backgroundColor = '#59BBB7',
      document.getElementById("status").innerHTML = 'Idle... Try copying',
      console.log('Copied!')
    );
  } else {
    console.log('Not async...');
    tryCopy(e);
    console.log('Copied!');
  }
});

function tryCopy(e) {
  e.clipboardData.setData("text/html", getText());
}
function getText() {
  var html = '';
  var row = '<div></div>';
  for (i = 0; i < 1000000; i++) {
    html += row;
  }
  return html;
}
async function tryCopyAsync(e) {
  navigator.clipboard.writeText(await getTextAsync());
}
async function getTextAsync() {
  var html = '';
  var row = '<div></div>';
  await waitNextFrame();
  for (i = 0; i < 1000000; i++) {
    html += row;
  }
  await waitNextFrame();
  html = [new ClipboardItem({"text/html": new Blob([html], {type: 'text/html'})})];
  return html;
}

//Credit: https://stackoverflow.com/a/66165276/7872728
function waitNextFrame() {
  return new Promise(postTask);
}

function postTask(task) {
  const channel = postTask.channel || new MessageChannel();
  channel.port1.addEventListener("message", () => task(), {
    once: true
  });
  channel.port2.postMessage("");
  channel.port1.start();
}
#main{
  width:100%;
  height:100vh;
  background:gray;
  color:white;
  font-weight:bold;
}
#status{
  text-align:center;
  padding-top:24px;
  font-size:16pt;
}
body{
  padding:0;
  margin:0;
  overflow:hidden;
}
<div id='main'>
  <div id='status'>Idle... Try copying</div>
</div>

To test, make sure you click inside the snippet before copying.

Full DEMO

window.addEventListener("load", function() {
  window.addEventListener("click", function() {
    hideCopying();
  });
  fallbackCopy = 0;
  if (navigator.permissions && navigator.permissions.query && notUnsupportedBrowser()) {
    navigator.permissions.query({
      name: 'clipboard-write'
    }).then(function(result) {
      if (result.state === 'granted') {
        clipboardAccess = 1;
      } else if (result.state === 'prompt') {
        clipboardAccess = 2;
      } else {
        clipboardAccess = 0;
      }
    });
  } else {
    clipboardAccess = 0;
  }
  window.addEventListener('copy', function(e) {
    if (fallbackCopy === 0) {
      showCopying();
      console.log("Started!");
      if (clipboardAccess > 0) {
        e.preventDefault();
        showCopying();
        tryCopyAsync(e).then(() =>
          hideCopying(),
          console.log('Copied! (Async)')
        );
      } else if (e.clipboardData) {
        e.preventDefault();
        console.log('Not async...');
        try {
          showCopying();
          tryCopy(e);
          console.log('Copied! (Not async)');
          hideCopying();
        } catch (error) {
          console.log(error.message);
        }
      } else {
        console.log('Not async fallback...');
        try {
          tryCopyFallback();
          console.log('Copied! (Fallback)');
        } catch (error) {
          console.log(error.message);
        }
        hideCopying();
      }
    } else {
      fallbackCopy = 0;
    }
  });
});

function notUnsupportedBrowser() {
  if (typeof InstallTrigger !== 'undefined') {
    return false;
  } else {
    return true;
  }
}

function tryCopyFallback() {
  var copyEl = document.createElement
  var body = document.body;
  var input = document.createElement("textarea");
  var text = getText();
  input.setAttribute('readonly', '');
  input.style.position = 'absolute';
  input.style.top = '-10000px';
  input.style.left = '-10000px';
  input.innerHTML = text;
  body.appendChild(input);
  input.focus();
  input.select();
  fallbackCopy = 1;
  document.execCommand("copy");
}

function hideCopying() {
  el("main").style.backgroundColor = '#59BBB7';
  el("status").innerHTML = 'Idle... Try copying';
}

function showCopying() {
  el("status").innerHTML = 'Copying, do not leave page.';
  el("main").style.backgroundColor = '#BB595C';
}

function el(a) {
  return document.getElementById(a);
}

function tryCopy(e) {
  e.clipboardData.setData("text/html", getText());
  e.clipboardData.setData("text/plain", getText());
}

function getText() {
  var html = '';
  var row = '<div></div>';
  for (i = 0; i < 1000000; i++) {
    html += row;
  }
  return html;
}
async function tryCopyAsync(e) {
  navigator.clipboard.write(await getTextAsync());
}
async function getTextAsync() {
  var html = '';
  var row = '<div></div>';
  await waitNextFrame();
  for (i = 0; i < 1000000; i++) {
    html += row;
  }
  await waitNextFrame();
  html = [new ClipboardItem({"text/html": new Blob([html], {type: 'text/html'}),"text/plain": new Blob([html], {type: 'text/plain'})})];
  return html;
}
//Credit: https://stackoverflow.com/a/66165276/7872728
function waitNextFrame() {
  return new Promise(postTask);
}

function postTask(task) {
  const channel = postTask.channel || new MessageChannel();
  channel.port1.addEventListener("message", () => task(), {
    once: true
  });
  channel.port2.postMessage("");
  channel.port1.start();
}
#main {
  width: 500px;
  height: 200px;
  background: gray;
  background: rgba(0, 0, 0, 0.4);
  color: white;
  font-weight: bold;
  margin-left: calc(50% - 250px);
  margin-top: calc(50vh - 100px);
  border-radius: 12px;
  border: 3px solid #fff;
  border: 3px solid rgba(0, 0, 0, 0.4);
  box-shadow: 5px 5px 50px -15px #000;
  box-shadow: 20px 20px 50px 15px rgba(0, 0, 0, 0.3);
}

#status {
  text-align: center;
  line-height: 180px;
  vertical-align: middle;
  font-size: 16pt;
}

body {
  background: lightgrey;
  background: linear-gradient(325deg, rgba(81, 158, 155, 1) 0%, rgba(157, 76, 79, 1) 100%);
  font-family: arial;
  height: 100vh;
  padding: 0;
  margin: 0;
  overflow: hidden;
}

@media only screen and (max-width: 700px) {
  #main {
    width: 100%;
    height: 100vh;
    border: 0;
    border-radius: 0;
    margin: 0;
  }

  #status {
    line-height: calc(100vh - 20px);
  }
}
<!DOCTYPE html>
<html>
  <head>
    <title>Clipboard Test</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta charset='UTF-8'>
  </head>
  <body>
    <div id='main'>
      <div id='status'>Click the webpage to start.</div>
    </div>
  </body>
</html>

DEMO webpage: Here is my DEMO

HELPFUL LINKS

Seth B
  • 1,014
  • 7
  • 21
  • awesome, thanks so much for taking a look. Does `"Text"` cover the html scenario too? Also, if helpful, here is the unminified code (though I'm sure you already have this): https://drive.google.com/file/d/1Nr5gvgD8coHNObLGJurRXnn44c2Ry2JX/view?usp=sharing ... and what's the difference between `e.clipboardData.setData(...)` and `document.execCommand(...)` ? – David542 Feb 11 '21 at 02:23
  • I'm not sure about the "Text" for HTML – Seth B Feb 11 '21 at 02:34
  • 1
    @David542 does this function work for you? – Seth B Feb 11 '21 at 18:12
  • @David542 Does this answer your question? – Seth B Feb 13 '21 at 18:24
  • Awesome, this is great. By the way, are you open for dev work (either part-time or full-time)? – David542 Feb 13 '21 at 21:37
  • I tried the POSSIBLE ANSWER and it does not work. What is your website where you say it works? I tried to copy in an HTML file and run offline no success on Edge, Chrome or IE. – Bob Feb 14 '21 at 10:18
  • @user12750353 here you go: https://app.jerc.me/test/clipboard/ – Seth B Feb 14 '21 at 15:36
  • @David542, I emailed you – Seth B Feb 14 '21 at 20:33
  • 1
    Now it is fair, congratulations @SethB! I am the guy of the next answer if you didn't notice. But I thought the permissions trick was exclusive to extensions, I have never used it for webpages. – Bob Feb 15 '21 at 10:56
  • @user12750353 Your answer is rly good, I wish I could share some points... Oh wait, I can... – Seth B Feb 15 '21 at 14:45
  • @SethB the result of full demo actually copies HTML **as text** and not as text/html -- could you please check and confirm that, for example, you can copy HTML into another app, such as Excel and it retains formatting? https://gyazo.com/b09cf553aa499a4e84401e25cfc82a94 – David542 Feb 16 '21 at 19:09
  • @David542 Like you want it's MEME type to be: "text/html"? – Seth B Feb 16 '21 at 19:11
  • @SethB oh yes of course! Otherwise it won't be interchangeable between applications. Here's an example: https://gyazo.com/3b3503043d0198476142b074c7663bc9 – David542 Feb 16 '21 at 19:20
  • 1
    @David542 It is fixed. The fallback copy (old browsers) still copies as `text/plain` though – Seth B Feb 16 '21 at 19:58
  • @David542 my full demo (https://app.jerc.me/test/clipboard/) has it copying a table as `text/html` – Seth B Feb 16 '21 at 20:20
  • @SethB I see, cool. How can I copy someone on your demo though? Is there any sample text or anything that I can use? For now it seems like I can just press cmd-c but there's nothing on the page to copy. – David542 Feb 16 '21 at 20:34
  • I updated the demo site to include a textarea @David542 – Seth B Feb 16 '21 at 21:39
  • @SethB nah, it needs to copy it with mime type html, which it's not doing now. Check this out: https://gyazo.com/87de03f1bc4ac07409268643ddbbddd8. What type of machine are you on, mac? If so, I can send you the program I use to view the clipboard data (you need a special program to see which mimetype it is). – David542 Feb 16 '21 at 22:39
  • @David542 Oh, I have it where it allows copying and pasting like normal while the textarea is focused. If you click away and then `CMD + C`, it will copy with meme type HTML – Seth B Feb 16 '21 at 22:45
  • I am using Mac OSX 10.13 – Seth B Feb 16 '21 at 22:45
  • Here is an example: https://photos.app.goo.gl/TofsD7KiZPXqZVGa8 – Seth B Feb 16 '21 at 23:20
  • @SethB ah, got it. Ok cool, confirmed it works now. – David542 Feb 16 '21 at 23:23
  • @SethB by the way, sometimes get a `Uncaught (in promise) DOMException: Support for multiple ClipboardItems is not implemented.` error when trying to copy to both text/html and text/plain -- any idea? – David542 Feb 17 '21 at 08:14
  • 1
    @David542 It now copies as `text/html` and `text/plain` (the demo) – Seth B Feb 17 '21 at 18:47
  • @SethB one last thing: if you make the size of the html on your app to ~25MB, how long does it take to copy? I tested it a bit and was running into performance issues where it was taking just a dreadful amount of time to copy-to-clipboard when there was about that size of data -- do you see something similar? – David542 Feb 19 '21 at 05:52
  • @David542 I updated the demo to be copying ~142MB, it does it in less than a second... I am not sure what is the problem. Are you creating the HTML dynamically (on copy)? – Seth B Feb 19 '21 at 17:48
  • @SethB are you sure? When I copy I get 1.4MB: https://gyazo.com/ad6c82160ba79f8d2dc7e22e26e662e7. – David542 Feb 19 '21 at 20:00
  • @David542 Oh yeah :) I forgot 2 zeros. I changed it to around 28MB. Yeah, it is taking awhile. I will see why – Seth B Feb 19 '21 at 20:43
  • @SethB cool, might be related... https://stackoverflow.com/questions/66272162/copy-to-clipboard-takes-a-tremendous-amount-of-time – David542 Feb 19 '21 at 20:54
  • @David542 I was actually just looking at that. It looks like it takes WAY longer when I add `text/html` instead of just text. Also, the sync function takes a lot faster – Seth B Feb 19 '21 at 21:23
3

From the the execCommand method it says

Depending on the browser, this may not work. On Firefox, it will not work, and you'll see a message like this in your console:

document.execCommand(‘cut’/‘copy’) was denied because it was not called from inside a short running user-generated event handler.

To enable this use case, you need to ask for the "clipboardWrite" permission. So: "clipboardWrite" enables you to write to the clipboard outside a short-lived event handler for a user action.

So your data preparation may take as long as you want, but the call to execCommand('copy') must be executed soon after the user generated the event whose handler is running it. Apparently it may be any event handler, not only a copy event.

  1. copyFormatted to perform the copy.

  2. genHtml function produces the HTML data assynchronously.

  3. enableCopy assigns to delegateCopy a function created in a context where copy is allowed, and that expires in after one second (assigning null to delegateCopy)

It was mentioned the possibility of using clipboardData while this interface is more programatic, it requires recent user interaction as well, that was the problem I focused. Of course using setData has the advantage of not requiring to parse the HTML and create a DOM for the copied data that in the motivation example is a large amount of data. Furthermore ClipboardData is marked as experimental.

The following snipped shows a solution that (1) runs asynchronously, (2) request user interaction if deemed necessary, (3) use setData if possible, (3) if setData not available then uses the innerHTML -> select. copy approach.

// This function expects an HTML string and copies it as rich text.
// https://stackoverflow.com/a/34192073/12750353

function copyFormatted (html) {
  // Create container for the HTML
  console.log('copyFormatted')
  var container = document.createElement('div')
  let hasClipboardData = true;
  const selectContainer = () => {
    const range = document.createRange()
    range.selectNode(container)
    window.getSelection().addRange(range)
  }
  const copyHandler = (event) => {
    console.log('copyHandler')
    event.stopPropagation()
    if(hasClipboardData){
      if(event.clipboardData && event.clipboardData.setData){
        console.log('setData skip html rendering')
        event.clipboardData.setData('text/html', html);
        event.preventDefault();
      } else {
        hasClipboardData = false;
        container.innerHTML = html;
        selectContainer();
        document.execCommand('copy');
        return; // don't remove the element yet
      }
    }
    document.body.removeChild(container);
    document.removeEventListener('copy', copyHandler)
  }
  // Mount the container to the DOM to make `contentWindow` available
  document.body.appendChild(container)
  document.addEventListener('copy', copyHandler);
  selectContainer();
  document.execCommand('copy')
}

function sleepFor( sleepDuration ){
  // sleep asynchronously
  return new Promise((resolve) => setTimeout(resolve, sleepDuration))
}
async function genHtml(NSECONDS=10, NROWS=10){
  var sall='<html><table>'
  var srow='<tr><td  ><div style="text-align: right"><span style="color: #060606; ">1</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">Feb 27, 2018</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">315965</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CA</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">SDBUY</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">9.99</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CAD</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">7.88</span></div></td></tr>'
  BATCH = Math.max(1, Math.floor(NROWS / 10000))
  for (i=0; i<NROWS; i++) {
      sall += srow; // process a chunk of data
      if(i % BATCH === 0){
        updateProgress((i+1) / NROWS);
      }
      await sleepFor(1000 * NSECONDS / NROWS);
      if (i==(1e6-1)) console.log('Done')
  }
  sall += '</table></html>'
  return sall;
}

let lastProgress = '';
function updateProgress(progress){
  const progressText = (100 * progress).toFixed(2) + '%';
  // prevents updating UI very frequently
  if(progressText !== lastProgress){
    const progressElement = document.querySelector('#progress');
    progressElement.textContent = lastProgress = progressText
  }
}

let delegateCopy = null;

function enableCopy(){
  // we are inside an event handler, thus, a function in this
  // context can copy.
  // What I will do is to export to the variable delegateCopy
  // a function that will run in this context.
  delegateCopy = (html) => copyFormatted(html)
  
  // But this function expires soon
  COPY_TIMEOUT=1.0; // one second to be called
  setTimeout(() => delegateCopy = null, 1000 * COPY_TIMEOUT)
}


function showOkButton(){
  document.querySelector('#confirm-copy').style.display = 'inline-block';
}
function hideOkButton() {
  document.querySelector('#confirm-copy').style.display = 'none';
}

// now copy to clipboard
async function doCopy(NSECONDS=10, NROWS=10){
  enableCopy()
  const html = await genHtml(NSECONDS, NROWS)
  
  // if the copy delegate expired show the ok button
  if(delegateCopy === null){
    showOkButton();
    // wait for some event that will enable copy
    while(delegateCopy === null){
        await sleepFor(100); 
    }
  }
  delegateCopy(html);
  hideOkButton()
}




document.querySelector('#copy-0p5s').addEventListener('click', (event) => {doCopy(0.5, 10)})

document.querySelector('#copy-2s').addEventListener('click', (event) => {doCopy(2, 10)})

document.querySelector('#copy-10s').addEventListener('click', (event) => {doCopy(10, 10)})
document.querySelector('#copy-30s').addEventListener('click', (event) => {doCopy(30, 1000)})
document.querySelector('#copy-60s').addEventListener('click', (event) => {doCopy(60, 1e5)})
document.querySelector('#confirm-copy').addEventListener('click', () => enableCopy())
<button id="copy-0p5s">Copy in 0.5s</button>
<button id="copy-2s">Copy in 2s</button>
<button id="copy-10s">Copy in 10s</button>
<button id="copy-30s">Copy in 30s</button>
<button id="copy-60s">Copy in 1 min (large data)</button>

<div id="progress"></div>
<button id="confirm-copy" style="display: none;">OK</button>
<hr>
Paste here if you want.
<div contenteditable="true" style="background: #f0f0f0; margin: 5px; border-radius: 5px; border: 1px solid black;">&nbsp;</div>

// This function expects an HTML string and copies it as rich text.
// https://stackoverflow.com/a/34192073/12750353

function copyFormatted (html) {
  // Create container for the HTML
  // [1]
  var container = document.createElement('div')
  container.innerHTML = html

  // Hide element
  // [2]
  container.style.position = 'fixed'
  container.style.pointerEvents = 'none'
  container.style.opacity = 0

  // Detect all style sheets of the page
  var activeSheets = Array.prototype.slice.call(document.styleSheets)
    .filter(function (sheet) {
      return !sheet.disabled
    })

  // Mount the container to the DOM to make `contentWindow` available
  // [3]
  document.body.appendChild(container)

  // Copy to clipboard
  // [4]
  window.getSelection().removeAllRanges()

  var range = document.createRange()
  range.selectNode(container)
  window.getSelection().addRange(range)

  // [5.1]
  document.execCommand('copy')

  // [5.2]
  for (var i = 0; i < activeSheets.length; i++) activeSheets[i].disabled = true

  // [5.3]
  document.execCommand('copy')

  // [5.4]
  for (var i = 0; i < activeSheets.length; i++) activeSheets[i].disabled = false

  // Remove the container
  // [6]
  document.body.removeChild(container)
}


function sleepFor( sleepDuration ){
  // sleep asynchronously
  return new Promise((resolve) => setTimeout(resolve, sleepDuration))
}
async function genHtml(NSECONDS=10, NROWS=10){
  var sall='<html><table>'
  var srow='<tr><td  ><div style="text-align: right"><span style="color: #060606; ">1</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">Feb 27, 2018</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">315965</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CA</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">SDBUY</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">9.99</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CAD</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">7.88</span></div></td></tr>'
  BATCH = Math.max(1, Math.floor(NROWS / 10000))
  for (i=0; i<NROWS; i++) {
      sall += srow; // process a chunk of data
      if(i % BATCH === 0){
        updateProgress((i+1) / NROWS);
      }
      await sleepFor(1000 * NSECONDS / NROWS);
      if (i==(1e6-1)) console.log('Done')
  }
  sall += '</table></html>'
  return sall;
}

let lastProgress = '';
function updateProgress(progress){
  const progressText = (100 * progress).toFixed(2) + '%';
  // prevents updating UI very frequently
  if(progressText !== lastProgress){
    const progressElement = document.querySelector('#progress');
    progressElement.textContent = lastProgress = progressText
  }
}

let delegateCopy = null;

function enableCopy(){
  // we are inside an event handler, thus, a function in this
  // context can copy.
  // What I will do is to export to the variable delegateCopy
  // a function that will run in this context.
  delegateCopy = (html) => copyFormatted(html)
  
  // But this function expires soon
  COPY_TIMEOUT=1.0; // one second to be called
  setTimeout(() => delegateCopy = null, 1000 * COPY_TIMEOUT)
}


function showOkButton(){
  document.querySelector('#confirm-copy').style.display = 'inline-block';
}
function hideOkButton() {
  document.querySelector('#confirm-copy').style.display = 'none';
}

// now copy to clipboard
async function doCopy(NSECONDS=10, NROWS=10){
  enableCopy()
  const html = await genHtml(NSECONDS, NROWS)
  
  // if the copy delegate expired show the ok button
  if(delegateCopy === null){
    showOkButton();
    // wait for some event that will enable copy
    while(delegateCopy === null){
        await sleepFor(100); 
    }
  }
  delegateCopy(html);
  hideOkButton()
}




document.querySelector('#copy-0p5s').addEventListener('click', (event) => {doCopy(0.5, 10)})

document.querySelector('#copy-2s').addEventListener('click', (event) => {doCopy(2, 10)})

document.querySelector('#copy-10s').addEventListener('click', (event) => {doCopy(10, 10)})
document.querySelector('#copy-30s').addEventListener('click', (event) => {doCopy(30, 1000)})
document.querySelector('#copy-60s').addEventListener('click', (event) => {doCopy(60, 1e5)})
document.querySelector('#confirm-copy').addEventListener('click', () => enableCopy())
<button id="copy-0p5s">Copy in 0.5s</button>
<button id="copy-2s">Copy in 2s</button>
<button id="copy-10s">Copy in 10s</button>
<button id="copy-30s">Copy in 30s</button>
<button id="copy-60s">Copy in 1 min (large data)</button>

<div id="progress"></div>
<button id="confirm-copy" style="display: none;">OK</button>
<hr>
Paste here if you want.
<div contenteditable="true" style="background: #f0f0f0; margin: 5px; border-radius: 5px; border: 1px solid black;">&nbsp;</div>

The solution above requires the user to click OK after the data is finished to perform the copy. I know this is not what you want, but the browser requires a user intervention.

In the sequence I have a modified version where I used the mousemove event to refresh the copyDelegate, if the mouse moved less than one second from the end of the data preparation the OK button won't show. You could also use keypress or any other frequent user generated event.

// This function expects an HTML string and copies it as rich text.
// https://stackoverflow.com/a/34192073/12750353

function copyFormatted (html) {
  // Create container for the HTML
  // [1]
  var container = document.createElement('div')
  container.innerHTML = html

  // Hide element
  // [2]
  container.style.position = 'fixed'
  container.style.pointerEvents = 'none'
  container.style.opacity = 0

  // Detect all style sheets of the page
  var activeSheets = Array.prototype.slice.call(document.styleSheets)
    .filter(function (sheet) {
      return !sheet.disabled
    })

  // Mount the container to the DOM to make `contentWindow` available
  // [3]
  document.body.appendChild(container)

  // Copy to clipboard
  // [4]
  window.getSelection().removeAllRanges()

  var range = document.createRange()
  range.selectNode(container)
  window.getSelection().addRange(range)

  // [5.1]
  document.execCommand('copy')

  // [5.2]
  for (var i = 0; i < activeSheets.length; i++) activeSheets[i].disabled = true

  // [5.3]
  document.execCommand('copy')

  // [5.4]
  for (var i = 0; i < activeSheets.length; i++) activeSheets[i].disabled = false

  // Remove the container
  // [6]
  document.body.removeChild(container)
}


function sleepFor( sleepDuration ){
  // sleep asynchronously
  return new Promise((resolve) => setTimeout(resolve, sleepDuration))
}
async function genHtml(NSECONDS=10, NROWS=10){
  var sall='<html><table>'
  var srow='<tr><td  ><div style="text-align: right"><span style="color: #060606; ">1</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">Feb 27, 2018</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">315965</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CA</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">SDBUY</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">9.99</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CAD</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">7.88</span></div></td></tr>'
  BATCH = Math.max(1, Math.floor(NROWS / 10000))
  for (i=0; i<NROWS; i++) {
      sall += srow; // process a chunk of data
      if(i % BATCH === 0){
        updateProgress((i+1) / NROWS);
      }
      await sleepFor(1000 * NSECONDS / NROWS);
      if (i==(1e6-1)) console.log('Done')
  }
  sall += '</table></html>'
  return sall;
}

let lastProgress = '';
function updateProgress(progress){
  const progressText = (100 * progress).toFixed(2) + '%';
  // prevents updating UI very frequently
  if(progressText !== lastProgress){
    const progressElement = document.querySelector('#progress');
    progressElement.textContent = lastProgress = progressText
  }
}

let delegateCopy = null;

function enableCopy(){
  // we are inside an event handler, thus, a function in this
  // context can copy.
  // What I will do is to export to the variable delegateCopy
  // a function that will run in this context.
  delegateCopy = (html) => copyFormatted(html)
  
  // But this function expires soon
  COPY_TIMEOUT=1.0; // one second to be called
  setTimeout(() => delegateCopy = null, 1000 * COPY_TIMEOUT)
}


function showOkButton(){
  document.querySelector('#confirm-copy').style.display = 'inline-block';
}
function hideOkButton() {
  document.querySelector('#confirm-copy').style.display = 'none';
}

// now copy to clipboard
async function doCopy(NSECONDS=10, NROWS=10){
  enableCopy()
  const html = await genHtml(NSECONDS, NROWS)
  
  // if the copy delegate expired show the ok button
  if(delegateCopy === null){
    showOkButton();
    // wait for some event that will enable copy
    while(delegateCopy === null){
        await sleepFor(100); 
    }
  }
  delegateCopy(html);
  hideOkButton()
}




document.querySelector('#copy-0p5s').addEventListener('click', (event) => {doCopy(0.5, 10)})

document.querySelector('#copy-2s').addEventListener('click', (event) => {doCopy(2, 10)})

document.querySelector('#copy-10s').addEventListener('click', (event) => {doCopy(10, 10)})
document.querySelector('#copy-30s').addEventListener('click', (event) => {doCopy(30, 1000)})
document.querySelector('#copy-60s').addEventListener('click', (event) => {doCopy(60, 1e5)})
document.querySelector('#confirm-copy').addEventListener('click', () => enableCopy())
// mouse move happens all the time this prevents the OK button from appearing
document.addEventListener('mousemove', () => enableCopy())
<button id="copy-0p5s">Copy in 0.5s</button>
<button id="copy-2s">Copy in 2s</button>
<button id="copy-10s">Copy in 10s</button>
<button id="copy-30s">Copy in 30s</button>
<button id="copy-60s">Copy in 1 min (large data)</button>

<div id="progress"></div>
<button id="confirm-copy" style="display: none;">OK</button>
<hr>
Paste here if you want.
<div contenteditable="true" style="background: #f0f0f0; margin: 5px; border-radius: 5px; border: 1px solid black;">&nbsp;</div>

clipboardData.setData

For ClipboardEvents you have access to the clipboard, in particular when you copy it producess a ClipboardEvent that you can use the method setData with format text/html. The limitation with this method is that it the setData must run synchronously, after the event handler returns it is disabled, so you cannot show a progress bar or these things.

document.body.addEventListener('copy', (event) =>{
  const t = Number(document.querySelector('#delay').value)
  const copyNow = () => {
   console.log('Delay of ' + (t / 1000) + ' second')
    event.clipboardData.setData('text/html', 
    '<table><tr><td>Hello</td><td>' + t / 1000 +'s delay</td></tr>' + 
    '<td></td><td>clipboard</td></tr></table>')
  }
  if(t === 0){
    copyNow()
  }else{
    setTimeout(copyNow, t)
  }
  event.preventDefault()
})
Perform copy after 
<select id="delay">
<option value="0" selected="true">immediately</option>
<option value="1">1 millisecond</option>
<option value="500">0.5 second</option>
<option value="1000">1 second</option>
<option value="2000">2 second</option>
<option value="10000">10 second</option>
<option value="20000">20 second</option>
<option value="20000">30 second</option>
</select>
<p>
Paste here if you want.
</p>
<div contenteditable="true" style="background: #f0f0f0; margin: 5px; border-radius: 5px; border: 1px solid black;">&nbsp;</div>

It is true that you can take advantage of the initial user generated event to start a new copy event. And when you are in the event handler you check if the data is ready and write it to clipboard. But the browser is smart enough to prevent you from copying if the code is not running in a handler of an event recently generated.

function DelayedClipboardAccess() {
  let data = null;
  let format = 'text/html';
  let timeout = 0;
  const copyHandler = (event) => {
    // this will be invoked on copy
    // if there is data to be copied then it will 
    // it will set clipboard data, otherwise it will fire
    // another copy in the near future.
    if(timeout > 0){
      const delay = Math.min(100, timeout);
      setTimeout( () => {
        this.countdown(timeout -= delay)
        document.execCommand('copy')
      }, delay);
      event.preventDefault()
    }else if(data) {
      console.log('setData')
      event.clipboardData.setData(format, data);
      event.preventDefault()
      data = null;
    }else{
      console.log({timeout, data})
    }
  }
  document.addEventListener('copy', copyHandler)
  this.countdown = (time) => {}
  this.delayedCopy = (_data, _timeout, _format) => {
    format = _format || 'text/html';
    data = _data;
    timeout = _timeout;
    document.execCommand('copy');
  }
}
const countdownEl = document.querySelector('#countdown')
const delayEl = document.querySelector('#delay')
const copyAgain = document.querySelector('#copy-again')
const clipboard = new DelayedClipboardAccess()
function delayedCopy() {
  const t = Number(delayEl.value)
    const data = '<table><tr><td>Hello</td><td>' +  t / 1000 +'s delay</td></tr>' +
    '<td></td><td>clipboard</td></tr></table>';
    clipboard.delayedCopy(data, t, 'text/html')
}
clipboard.countdown = (t) => countdownEl.textContent = t;
delayEl.addEventListener('change', delayedCopy)
copyAgain.addEventListener('click', delayedCopy)
Perform copy after 
<select id="delay">
<option value="0" selected="true">immediately</option>
<option value="1">1 millisecond</option>
<option value="500">0.5 second</option>
<option value="1000">1 second</option>
<option value="2000">2 second</option>
<option value="10000">10 second</option>
<option value="20000">20 second</option>
<option value="30000">30 second</option>
</select>

<button id="copy-again">Copy again</button>


<div id="countdown"></div>

<p>
Paste here if you want.
</p>
<div contenteditable="true" style="background: #f0f0f0; margin: 5px; border-radius: 5px; border: 1px solid black;">&nbsp;</div>

In the above snipped there is an interesting effect that is if you copy something while the countdown is running you are going to start a new chain that will accelerate the countdown.

In my browser the countdown stops after around 5 seconds. If I press Ctrl+C after the chain of copy handlers was broken, the copy handler is invoked again by a user generated event then it goes for 5 seconds again.

Bob
  • 13,867
  • 1
  • 5
  • 27
  • great answer thank you. Do you know how Google Sheets does this then? It doesn't ever prompt for a secondary confirmation from the user? – David542 Feb 10 '21 at 17:36
  • I don't know how Google Sheets does. My guess is that they have the "clipboardWrite" permission mentioned in the excerpt I added to the beginning o my answer. Did you see the second implementation where I delegate the copy work to the mousemove event? You can delegate to any event handler triggered by user interaction and in a complex page there are many. And if you are going to paste in the same page you can even delegate that to the paste event. If you want to paste to external app you could use something like a blur event. – Bob Feb 10 '21 at 18:21
  • it seems from another user in one of the comments that Google Sheets uses `e.clipboardData.setData(..., content);`: https://stackoverflow.com/a/66147911/651174. Would you want to add a code snippet as to how Google Sheets does it then then I can accept your answer? – David542 Feb 11 '21 at 02:25
  • 1
    OK, covered setData method. Experiments (in the end of the answer) show that it also require the short living event handler, and it has the limitation that needs to be executed synchronously. But it has the (important) advantage of not requiring to parse the HTML data. – Bob Feb 11 '21 at 14:16
  • I feel bad not accepting your answer as it was so fantastic, but I really appreciate your time answering this question, and if I could I would 'split' the Bounty between you and the other user. By the way, are you open for work (either part-time or full-time)? – David542 Feb 13 '21 at 21:36
1

From the same link :

function updateClipboard(newClip) {
  navigator.clipboard.writeText(newClip).then(function() {
    /* clipboard successfully set */
  }, function() {
    // your timeout function handler
    /* clipboard write failed */
  });
}
justcodin
  • 857
  • 7
  • 17
  • @justcoding -- how does that work differently from `execCommand()` ? Is that the technique that Google Sheets uses, or how can they get a 25s-without-time? – David542 Feb 05 '21 at 06:49
  • I never tried to copy huge amount of data from a webpage so I don't really know but maybe this thread can help you : https://stackoverflow.com/a/7037330/14223224 – justcodin Feb 05 '21 at 06:54
  • Did you check the updated answer @David542 ? – Bob Feb 13 '21 at 21:14
1

To be honest, I'm not seeing the same behavior. (Edit: I will note we are using slightly different copy commands) When I take your HTML generation function as is, I get a memory limit error. Specifically, "Uncaught RangeError: Invalid string length" at the line in the loop that appends the row.

If I tone down your loop (to i<1e4) it does not run out of memory, takes just over 10 seconds to complete, and does not throw an error.

Here is the code I am using for reference.

const generateLargeHTMLChunk = () => {
    function sleepFor( sleepDuration ){
        var now = new Date().getTime();
        while(new Date().getTime() < now + sleepDuration){ /* do nothing */ } 
    }
    
    var sall='<html><table>'
    var srow='<tr><td  ><div style="text-align: right"><span style="color: #060606; ">1</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">Feb 27, 2018</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">315965</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CA</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">SDBUY</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">9.99</span></div></td><td  ><div style="text-align: left"><span style="color: #060606; ">CAD</span></div></td><td  ><div style="text-align: right"><span style="color: #060606; ">7.88</span></div></td></tr>'
    for (i=0; i<1e4; i++) {
        sall += srow;
        if (i%1e3==0) sleepFor(1000); // simulate a 10 second operation...
        if (i==(1e4-1)) console.log('Done')
    }
    sall += '</table></html>'
    // now copy to clipboard

    return sall;
}

document.addEventListener('copy', function(e) {
    const timestamp = (date) => `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}`;

    const start = new Date();
    console.log(`Starting at ${timestamp(start)}`);

    const largeHTML = generateLargeHTMLChunk();
    e.clipboardData.setData('text/plain', largeHTML);

    const end = new Date();
    console.log(`Ending at ${timestamp(end)}`);
    console.log(`Duration of ${end-start} ms.`); // ~10000 in my tests
    e.preventDefault();
});

I doubt this fixes your actual problem, but this is too much to type in a comment. I hope whatever causes the difference in the the behavior we're seeing does help, though.

tmdesigned
  • 2,098
  • 2
  • 12
  • 20
  • @tm -- yes, I've updated the question. To do the copy you can't do it directly from a string -- I've just shown that as the simplest way to provide a template for a solution, but check out the comment I've added into the question's code, and also perhaps the simplest way to reproduce a full copy-event would be from here: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard – David542 Feb 07 '21 at 22:27