Обработчик paste блокирует изменение DOM

Уважаемый коллективный разум!Очень нужна помощь в решении такой задачи. Есть обработчик вставки табличных данных из буфера обмена в веб-таблицу, производящий некоторые расчеты. Это может занимать ощутимое время для больших таблицы, поэтому в начале надо показать спинер, а в конце - спрятать его. Пытаюсь это делать с помощью такого кода:

const globalDivShowSpinner = document.getElementsByClassName("show_spinner")[0];

// очищаем HTML при вставке текста
document.addEventListener('paste', async function(e) {
  var bufferText = ((e.originalEvent || e).clipboardData || window.clipboardData).getData('Text');
  e.preventDefault();

  let lines = bufferText.split(/\r?\n/).filter(Boolean);
  if(!lines.length) {
    console.log('Нечего вставлять');
    return;
  }

  // cells = createTableFromLines(lines);
  if(globalDivShowSpinner != null && typeof globalDivShowSpinner != "undefined") {
    globalDivShowSpinner.style.display='block';
    console.log('globalDivShowSpinner.style.display => '+globalDivShowSpinner.style.display);     
  }

  let delta_idx = 0; // смещение чтения данных в случае, если первая колонка содержит номер позиции

  for(let i = 0; i < lines.length; i++) {
    let current_line = lines[i];
    let cells = current_line.split(/\t/);
    cells = cells.filter(function(str) { return /\S/.test(str); }); // убираем пустые строки

    let delta_idx = 0; // смещение чтения данных в случае, если первая колонка содержит номер позиции

    // проверяем, является ли первая колонка порядковым номером
    if(cells[0].match(/^[0-9\.]+$/) !== null) {
      delta_idx = 1;
    }
    // если в строке только 1 колонка, это - название раздела
    if(cells.length == 1+delta_idx) {
      addNewChapter(cells[delta_idx]);
      continue;
    }
    // Пустое наименование позиции => пропускаем
    if(!cells[delta_idx].length) {
      continue;
    }
    // заменяем ',' на '.' в числовых полях:
    if(cells.length > delta_idx+2 && typeof cells[delta_idx+2] !== "undefined" && !isNaN(parseFloat(cells[delta_idx+2]))) {
      cells[delta_idx+2] = cells[delta_idx+2].replace(',','.');
    }
    if(cells.length > delta_idx+3 && typeof cells[delta_idx+3] !== "undefined" && !isNaN(parseFloat(cells[delta_idx+3]))) {
      cells[delta_idx+3] = cells[delta_idx+3].replace(',','.');
    }
    addNewCustom(1,0,cells[delta_idx],cells[delta_idx+1],cells[delta_idx+2],cells[delta_idx+3]);
  }
  updateTotal();

  //document.body.removeChild(spinner);
  if(globalDivShowSpinner != null && typeof globalDivShowSpinner != "undefined") {
    globalDivShowSpinner.style.display='none';
    console.log('*** HERE WE GO ***');
  }
});

Спинер реализован следующим образом.

CSS:

.show_spinner {
            position:fixed;top:0;right:0;bottom:0;left:0;
            background: rgba(0,0,0,0.5) url(/images/spinners/Gear.gif) no-repeat 50% 50%;
            z-index:100;
            background-size: 10ex;
        }

HTML:

<div class="show_spinner" style="display:none"></div>

В итоге, на консоль выводится ‘globalDivShowSpinner.style.display => block’ (отладочная печать непосредственно перед отображением спинера), но сам спинер не показывается. Если вручную поменять в HTML style=“display:block”, спинер конечно показывается.

В чем здесь проблема? Я нуб в JS и уже сломал весь мозг, прошу помочь разобраться.

Привет! Ты получишь ответ быстрее, если сделаешь автономный пример с этим багом, чтобы можно было скопировать, запустить его и тогда поискать ошибку. По твоему коду видно, что он зависит от других элементов, которые ты не дал. Событие paste срабатывает, когда ты куда-то вставляешь текст. Сейчас же его некуда вставлять. А раз так, то paste не срабатывает, поэтому спиннер остаётся display: none.

Я предполагаю - исходя из описания желаемого поведения над кодом - что ошибка в использовании события paste. Это событие, насколько я знаю, сработает тогда, когда есть какие-то поля, куда ты вставляешь текст - input, textarea, div с атрибутом contenteditable=“true” например. Если ты просто вставляешь таблицу в обычный div, то никакого paste это не порождает. Следовательно, твой код попросту не запустится.

Я прогнал вопрос через ИИшку (gemini pro) следующим образом:

  1. Сделал папку
  2. Сделал в ней файл q.md
  3. Вставил в него твой вопрос. Исходного кода, и примера кода достаточно для того чтобы ИИшка разобралась в сути проблемы.
  4. Запустил коммандайновую gemini ( GitHub - google-gemini/gemini-cli: An open-source AI agent that brings the power of Gemini directly into your terminal. ). Кажется у них есть приличный бесплатный объем доступа к gemini-2.5-pro
  5. Сказал в gemini cli `дай решение и описание проблемы из файла q.md`
  6. И вот что ИИ говорит:

Вы столкнулись с классической проблемой в JavaScript, связанной с тем, как браузер обрабатывает код и отрисовывает изменения на странице.

В чем суть проблемы?

Ваш обработчик события paste выполняется в одном непрерывном потоке (это называется “main thread” или основной поток). Когда вы меняете style.display на ‘block’,
браузер не перерисовывает страницу немедленно. Он ждет, пока весь текущий JavaScript-код не завершится.

Ваш код работает так:

  1. globalDivShowSpinner.style.display=‘block’; — браузер получает команду “нужно показать спиннер” и ставит ее в очередь на отрисовку.
  2. Сразу после этого запускается долгий цикл for, который полностью занимает основной поток и не дает браузеру сделать что-либо еще (включая отрисовку).
  3. Цикл for завершается.
  4. globalDivShowSpinner.style.display=‘none’; — браузер получает команду “нужно спрятать спиннер”.
  5. Ваш обработчик события завершает работу.

Только теперь браузер может обработать очередь задач на отрисовку. Он видит, что спиннер нужно сначала показать, а потом сразу же скрыть. Браузер оптимизирует это, и
в итоге спиннер не отображается вовсе. Перерисовка экрана просто не успевает произойти.

Как это исправить?

Нужно дать браузеру возможность отрисовать спиннер перед тем, как вы начнете выполнять “тяжелые” вычисления. Самый простой способ — обернуть ресурсоемкую часть кода
в setTimeout с нулевой задержкой.

Это работает как команда браузеру: “Покажи спиннер, а следующий кусок кода выполни, как только сможешь (в следующем цикле обработки событий)”. Этой небольшой паузы
достаточно, чтобы браузер успел обновить интерфейс и показать спиннер.

Исправленный код

Вот ваш код, измененный с использованием setTimeout. Основная логика обработки данных теперь выполняется внутри функции обратного вызова setTimeout.

const globalDivShowSpinner = document.getElementsByClassName("show_spinner")[0];
// очищаем HTML при вставке текста
document.addEventListener('paste', function(e) {
  var bufferText = ((e.originalEvent || e).clipboardData || window.clipboardData).getData('Text');
  e.preventDefault();
  let lines = bufferText.split(/\r?\n/).filter(Boolean);
  if(!lines.length) {
    console.log('Нечего вставлять');
    return;
  }
  // 1. Показываем спиннер
  if(globalDivShowSpinner != null && typeof globalDivShowSpinner != "undefined") {
    globalDivShowSpinner.style.display='block';
    console.log('globalDivShowSpinner.style.display => '+globalDivShowSpinner.style.display);
  }
  // 2. Откладываем тяжелую работу, чтобы браузер успел отрисовать спиннер
  setTimeout(function() {
    for(let i = 0; i < lines.length; i++) {
      let current_line = lines[i];
      let cells = current_line.split(/\t/);
      cells = cells.filter(function(str) { return /\S/.test(str); }); // убираем пустые строки
      let delta_idx = 0; // смещение чтения данных в случае, если первая колонка содержит номер позиции
      // проверяем, является ли первая колонка порядковым номером
      if(cells[0] && cells[0].match(/^[0-9\.]+$/) !== null) {
        delta_idx = 1;
      }
      // если в строке только 1 колонка, это - название раздела
      if(cells.length === 1 + delta_idx) {
        addNewChapter(cells[delta_idx]);
        continue;
      }
      // Пустое наименование позиции => пропускаем
      if(!cells[delta_idx] || !cells[delta_idx].length) {
        continue;
      }
      // заменяем ',' на '.' в числовых полях:
      if(cells.length > delta_idx+2 && typeof cells[delta_idx+2] !== "undefined" && !isNaN(parseFloat(cells[delta_idx+2]))) {
        cells[delta_idx+2] = cells[delta_idx+2].replace(',','.');
      }
      if(cells.length > delta_idx+3 && typeof cells[delta_idx+3] !== "undefined" && !isNaN(parseFloat(cells[delta_idx+3]))) {
        cells[delta_idx+3] = cells[delta_idx+3].replace(',','.');
      }
      addNewCustom(1,0,cells[delta_idx],cells[delta_idx+1],cells[delta_idx+2],cells[delta_idx+3]);
    }
    updateTotal();
    // 3. Прячем спиннер после завершения работы
    if(globalDivShowSpinner != null && typeof globalDivShowSpinner != "undefined") {
      globalDivShowSpinner.style.display='none';
      console.log('*** HERE WE GO ***');
    }
  }, 0); // Нулевая задержка дает браузеру время на перерисовку
});

Я также внес пару небольших исправлений в ваш код:

  • Убрал повторное объявление переменной let delta_idx внутри цикла.
  • Добавил проверки cells[0] и !cells[delta_idx], чтобы избежать ошибок, если ячейка окажется пустой (undefined).

Надеюсь, это решение поможет вам

От себя добавлю что браузер прорисовывает изменения DOM-а только после завершения выполнения синхронного блока кода. Так что если запустить изменение DOM-а, и сразу начать долгое вычисление, то DOM не обновится до завершения синхронного блока.

Я решал схожие задачи через таймауты (мне надо было рисовать пути на карте и количество отрисовок варьировалось от 2-3 что происходит моментально до 50 что занимало секунды если запускать синхронно). Разбивал массив их на синхронные блоки, давая браузеру возвожность прорисовать изменения между вызовами блоков. Код стар, еще на es5, но рабоч javascript-toolbox/smart_async_iterator.js at master · podgorniy/javascript-toolbox · GitHub

Если хочется меня поддержать, можно подписаться на мой бот за денюжку Telegram: Launch @experai_bot и спрашивать через него как gemni, claude так и openai модели. Вот пример того как они ответила gemini 2.5 Telegram: View @experai_bot_speaks