如何在 JavaScript 中复制大量 HTML 内容到剪贴板而不超时

10
我注意到当在后台运行时,document.execCommand('copy') 命令会在大约 5 秒后超时。是否有办法绕过这个限制,或者如果需要更长时间,是否有备用方案?
这是我一直在使用的剪贴板文档页面。例如,我有一个函数来“准备”数据(从表格数据生成 HTML),然后有第二个函数来添加一些额外的标记将其复制到剪贴板上。在大型表格中,从用户按下 Cmd-C 的时间到生成 HTML 并能够复制可能需要十秒钟左右。
另外,我注意到 Google Sheets 允许超过五秒的复制操作,因此我很好奇他们是如何做到的。
# still works after 25 seconds!
[Violation] 'copy' handler took 25257ms     2217559571-waffle_js_prod_core.js:337 

代码被压缩/混淆,因此很难阅读,但上面的文件在这里:https://docs.google.com/static/spreadsheets2/client/js/2217559571-waffle_js_prod_core.js。为了参考,复制的数据量大约为50MB。请在复制操作中使用10秒左右的延迟,以模拟这个长时间运行的过程。
对于悬赏,请问是否可以展示一个可行的示例来执行单个Cmd-C,要么:
  • 是否可能在后台异步地进行长时间运行的复制操作,例如使用Web Worker?
  • 如果必须同步完成,则展示复制操作的示例,显示一些进度 - 例如,每10k行之后,复制操作会发出事件。
它必须生成HTML并且只涉及单个Cmd-C(即使我们使用`preventDefault`并在后台触发复制事件)。你可以使用以下内容作为“html-generation”函数应该工作的模板:
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

如果需要复制真实事件,请参考以下链接:https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard


我还没有花时间彻底搜索,但有几点需要注意:谷歌浏览器包括一些扩展程序,可以与谷歌的页面进行交互,超出了我们通常使用Web API所能做到的范围。我不知道这里是否存在这样的情况,例如我知道他们在Meet上这样做。可以通过使用其他浏览器进行测试来验证这一点。此外,一旦标记为已批准,异步剪贴板API就不再需要用户手势,如果网站是从安全上下文中提供的。 - Kaiido
@Kaiido,我已经更新了问题的示例代码,这有帮助吗? - David542
1
看起来 Google 使用 e.clipboardData.setData("Text", content); 进行复制。 - Seth B
@SethB 谢谢,你是怎么知道的?你想发一个带有基本示例的答案来展示他们是如何做到的吗? - David542
@Kaiido,这两个答案对于您的评论指针有帮助吗? - David542
4个回答

7
这是我找到的内容:
在这个脚本中:https://docs.google.com/static/spreadsheets2/client/js/1150385833-codemirror.js
我找到了这个函数:
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)
}

新发现

我发现谷歌表格加载了这个脚本:

(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);

这与他们自己的扩展程序有关。

新发现 2

当我在单元格中粘贴时,它使用了这两个函数:

脚本: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)
}

新发现 3

当我选择全部并复制时,使用此功能:

脚本: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
  }
}

你的评论的回答

以下是 e.clipboardData.setData()execCommand("copy") 之间的区别:

e.clipboardData.setData() 用于操作传递到剪贴板中的数据。

execCommand("copy") 以编程方式调用 CMD/CTRL + C

如果你调用了 execCommand("copy"),它将只复制当前选择的内容,就像按下 CMD/CTRL + C一样。你也可以使用此函数与 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!");
}

新发现3(可能的答案)

不要使用 setTimeout 模拟长文本,因为它会导致界面冻结。相反,只需使用大块的文本。

这段脚本可以正常运行而无需超时。

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>

为了测试,请确保在复制前先单击代码段内部。
完整演示

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网页:这是我的DEMO

有用的链接


太棒了,非常感谢您的查看。 "Text"是否也适用于HTML场景?此外,如果有帮助,这是未经压缩的代码(虽然我相信您已经拥有):https://drive.google.com/file/d/1Nr5gvgD8coHNObLGJurRXnn44c2Ry2JX/view?usp=sharing...以及 e.clipboardData.setData(...)document.execCommand(...)之间有什么区别? - David542
1
@David542 这个函数对你有效吗? - Seth B
1
现在公平了,恭喜@SethB!如果你没有注意到,我是下一个回答的人。但是我认为权限技巧是专门针对扩展的,我从未在网页上使用过它。 - Bob
1
@David542,问题已经解决。回退副本(旧浏览器)仍然以“text/plain”形式复制。 - Seth B
1
@David542 现在它复制为 text/htmltext/plain(演示)。 - Seth B
显示剩余25条评论

3

execCommand方法所说的:

根据浏览器的不同,这可能无法工作。在Firefox上,它不起作用,您将在控制台中看到以下消息:

document.execCommand('cut' / 'copy')被拒绝,因为它没有在短暂运行的用户生成事件处理程序内调用。

要启用此用例,您需要请求“clipboardWrite”权限。所以:“clipboardWrite”使您能够在短暂的用户操作事件处理程序之外写入剪贴板。

因此,您的数据准备可以花费任意长时间,但是对execCommand('copy')的调用必须在用户生成其处理程序正在运行的事件之后尽快执行。 显然,它可以是任何事件处理程序,而不仅仅是复制事件。

  1. copyFormatted执行复制操作。

  2. genHtml函数异步地生成HTML数据。

  3. enableCopy分配给delegateCopy一个在允许复制的上下文中创建的函数,该函数在一秒后到期(将null分配给delegateCopy

提到了使用clipboardData的可能性,虽然此接口更加程序化,但它也需要最近的用户交互,这就是我关注的问题。当然,使用setData的优点是不需要解析HTML并创建用于复制数据的DOM,其示例中复制的数据量很大。此外,ClipboardData标记为实验性。

以下代码片段显示了解决方案:(1)异步运行,(2)请求用户交互(如果认为必要),(3)如果可能,则使用setData,(3)如果setData不可用,则使用innerHTML-> select.copy方法。

// This function expects an HTML string and copies it as rich text.
// https://dev59.com/Q1sX5IYBdhLWcg3wJMnb#34192073

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://dev59.com/Q1sX5IYBdhLWcg3wJMnb#34192073

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>

上述解决方案需要用户在数据完成后点击OK才能执行复制。我知道这不是您想要的,但浏览器需要用户干预。

在这个序列中,我有一个修改版本,我使用了mousemove事件来刷新copyDelegate,如果鼠标在数据准备结束前少于一秒移动,则不会显示OK按钮。您还可以使用keypress或任何其他频繁生成的用户事件。

// This function expects an HTML string and copies it as rich text.
// https://dev59.com/Q1sX5IYBdhLWcg3wJMnb#34192073

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

ClipboardEvents 是可以访问剪贴板的,特别是当你复制时,它会产生一个 ClipboardEvent,你可以使用方法 setData 并搭配格式 text/html 来操作。这个方法的限制在于必须同步运行,事件处理程序返回后就被禁用了,所以你不能显示进度条或其他东西。

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>

确实可以利用初始用户生成的事件来启动一个新的复制事件。当你在事件处理程序中时,检查数据是否准备好并将其写入剪贴板。但是,如果代码不是最近生成的事件的处理程序中运行,浏览器会聪明地阻止你进行复制。

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>

在上面的代码片段中,有一个有趣的效果,即如果在倒计时运行时复制某些内容,您将启动一个新的链,加速倒计时。
在我的浏览器中,在大约5秒钟后倒计时会停止。如果在复制处理程序链被断开后按下Ctrl + C,则复制处理程序将再次由用户生成的事件调用,然后再次运行5秒。

非常好的答案,谢谢。你知道 Google Sheets 是如何做到这一点的吗?它从不提示用户进行二次确认吗? - David542
我不知道谷歌表格是怎么做的。我的猜测是他们在我回答开头添加的摘录中提到了“clipboardWrite”权限。你看到了第二个实现,我在那里将复制工作委托给mousemove事件吗?您可以委托任何由用户交互触发的事件处理程序,而在复杂的页面中有许多这样的处理程序。如果您要粘贴到同一页中,甚至可以将其委派给paste事件。如果要粘贴到外部应用程序,则可以使用类似于模糊事件的东西。 - Bob
似乎从另一个用户在评论中得知,Google Sheets 使用 e.clipboardData.setData(..., content);:https://dev59.com/-FEG5IYBdhLWcg3wb9gL#66147911。如果您能提供 Google Sheets 如何实现此功能的代码片段,那么我可以接受您的答案吗? - David542
1
好的,setData方法已经覆盖了。实验(在答案的结尾)表明它还需要短暂的事件处理程序,并且有一个需要同步执行的限制。但是它有一个(重要的)优点,即不需要解析HTML数据。 - Bob
我感到很不好意思,因为你的回答非常棒,但我真的很感激你花时间回答这个问题。如果可以的话,我会把奖励分给你和另一个用户。顺便问一下,你是否愿意接受工作(兼职或全职)? - David542

1
来自同一个链接
function updateClipboard(newClip) {
  navigator.clipboard.writeText(newClip).then(function() {
    /* clipboard successfully set */
  }, function() {
    // your timeout function handler
    /* clipboard write failed */
  });
}

@justcoding -- 它与 execCommand() 有何不同?这是 Google Sheets 使用的技术吗?还是他们如何在没有时间的情况下获得25秒? - David542
我从未尝试过从网页复制大量数据,所以我不太清楚,但也许这个线程可以帮助你:https://dev59.com/fHRC5IYBdhLWcg3wJNcN#7037330 - justcodin
你有没有查看 @David542 的更新答案? - Bob

1
坦白说,我没有看到相同的行为。(编辑:请注意,我们正在使用略有不同的复制命令)当我按原样使用您的HTML生成函数时,会出现内存限制错误。具体来说,在循环中附加行的那一行上出现“未捕获的RangeError:无效的字符串长度”。
如果我减少您的循环(到i<1e4),它不会耗尽内存,只需10秒左右即可完成,也不会抛出错误。
以下是我用作参考的代码。
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();
});

我怀疑这并不能解决你实际的问题,但在评论中输入太多了。我希望导致我们看到的行为差异的任何原因都能有所帮助。

@tm -- 是的,我已经更新了问题。要进行复制,您不能直接从字符串中复制--我只是展示了这种方法作为提供解决方案模板的最简单方式,但请查看我添加到问题代码中的注释,也许最简单的复制事件重现方式是从这里开始:https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard - David542

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接