SpreadJS Designer

SpreadJS Designer

-Как использовать SpreadJS Designer и устранять ошибки-

Введение

Впервые я столкнулся с библиотекой SpreadJS, когда мне поручили проект для. Библиотека использовалась для отображения и редактирования файлов Excel на экране, и здесь я систематизировал проблемы, с которыми столкнулся при ее применении. Среди них была ошибка, из-за которой при обновлении или импорте исчезали боковая панель и пути привязки, а также функции, добавленные для выполнения требований заказчика: запрет добавления листов и запрет удаления ячеек. В конечном итоге для решения обеих проблем потребовалось разобраться в системе команд SpreadJS Designer. Поэтому мне пришлось самостоятельно анализировать недокументированные области, чтобы реализовать необходимые требования.

Боковая панель и пути привязки исчезают при обновлении или повторном выполнении запроса

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

Причина: команды типа checkbox переключают свое состояние при каждом вызове

Функция, которая отрисовывает боковую панель, вызывала workbook.open() внутри callback-функции success. Когда я вывел в консоль объект Designer.getCommand(TEMPLATE_DESIGN_MODE) с помощью command , он содержал следующее поле.

{
  "commandName": "templateDesignMode",
  "type": "checkbox"
}

"type": "checkbox"Это было ключом к разгадке. Команды типа checkbox в SpreadJS Designer предназначены для переключения своего состояния включения или выключения при каждом вызове, как кнопки «Полужирный» и «Курсив» на ленте. Поскольку эта команда выполнялась при каждой загрузке данных, фактически повторялась следующая последовательность.

Ошибка напрямую зависела от того, «каким по счету был вызов этой команды», а не от самой кнопки обновления. Сначала я подозревал проблему со временем отрисовки и попробовал сначала изменить setTimeout и suspendPaint/resumePaint , но они не имели никакого отношения к причине. Сам факт того, что симптомы точно разделялись между нечетными и четными попытками, был подсказкой о том, что «здесь задействовано значение состояния», однако жаль, что я не распознал этот сигнал сразу.

Решение

Перед выполнением команды я сначала проверял ее текущее состояние и пропускал выполнение, если она уже была включена.

return (designer) => {
  const isAlreadyOn = command.getState?.(designer);
  if (isAlreadyOn) return;
  return execute(designer);
};

Требования заказчика: запрет добавления листов и удаления связанных ячеек

Когда я приближался к завершению работы над функцией, заказчик отправил еще три дополнительных требования.

1. Запретить добавление листов на экране настройки шаблона (+ , удалив кнопку или используя другой способ)

2. Запретить удаление ячеек с путями привязки. Значения этих ячеек должны были использоваться без изменений на другом экране, поэтому их нельзя удалять. Однако все остальные ячейки без путей привязки должны по-прежнему оставаться доступными для свободного редактирования. Необходимо заблокировать только «удаление».

3. При каждом переключении листа отображать пути привязки этого листа в списке полей на боковой панели.

Обновление боковой панели при переключении листов

Требование 3 удалось сравнительно легко решить с помощью события ActiveSheetChanged. При каждом изменении листа я заново извлекал или сопоставлял пути привязки в зависимости от текущего режима — режима импорта файла или режима запроса к существующей базе данных, — а затем вызывал функцию, перерисовывающую дерево боковой панели.

workbook.bind(Events.ActiveSheetChanged, function (sender, args) {
  const currentSheet = args.newSheet;
  if (!currentSheet) return;
  const sheetName = currentSheet.name();

  if (isImport) {
    const bindingPathsFromSheet = extractBindingPathsFromSheet(currentSheet);
    void setBindingPathToData(currentDesigner, currentWorkbook, bindingPathsFromSheet);
  } else {
    const currentSheetDatas = fieldsBySheetMap?.get(sheetName) || [];
    void setBindingPathToData(currentDesigner, currentWorkbook, currentSheetDatas);
  }
});

Для выполнения требований 1 и 2 мне пришлось примерно трижды полностью пересмотреть свой подход, прежде чем найти правильное решение.

Подход 1. Блокировка ячеек

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

const unlockedStyle = new GC.Spread.Sheets.Style();
unlockedStyle.locked = false;
sheet.setDefaultStyle(unlockedStyle);

for (let r = 0; r < sheet.getRowCount(); r++) {
  for (let c = 0; c < sheet.getColumnCount(); c++) {
    if (sheet.getBindingPath(r, c)) {
      sheet.getCell(r, c).locked(true);
    }
  }
}
sheet.options.isProtected = true;

Когда я применил этот подход на практике, даже ячейки без путей привязки больше нельзя было редактировать. Результат был одинаковым независимо от того, использовал ли я setDefaultStyle или принудительно перезаписывал весь диапазон. Проверив консоль, я также обнаружил, что сама блокировка не была включена даже в состоянии по умолчанию. Мне так и не удалось точно определить причину, но я подозреваю, что в самом файле шаблона (.sjs) уже могла быть настроена отдельная опция защиты, которую нельзя было сразу обнаружить через код. Какова бы ни была причина, функция защиты листа в принципе плохо соответствовала требованию: «несвязанные ячейки должны оставаться полностью доступными для свободного редактирования, а заблокировано должно быть только удаление».

Подход 2. Переопределение команды

Я попытался использовать систему команд, о которой узнал при решении проблемы с командами типа checkbox. Предположив, что для удаления также существует команда с определенным именем, я искал команду с именем deleteRows с помощью commandManager().getCommand() и переопределил ее, сохранив исходную команду, а затем добавив условную блокировку.

const originalDeleteRows = commandManager.getCommand('deleteRows');
commandManager.register('deleteRows', {
  canUndo: originalDeleteRows.canUndo,
  execute: (context, options, isUndo) => {
    // 대상 행에 바인딩패스가 있으면 차단하는 로직
    return originalDeleteRows.execute(context, options, isUndo);
  }
}, false, false, false, false);

Однако даже после того, как я выбрал «Удалить строку/столбец» в контекстном меню, переопределённый execute фактически не вызывался. Хотя мне не удалось проверить точную причину на официальном форуме, как мы ранее видели на примере команды с флажком, Designer подключает действия с помощью собственной системы commandMap — как для ленты, так и для контекстного меню. Это позволяет предположить, что нажатие «Удалить строку» в контекстном меню не просто вызывает именованную команду, зарегистрированную в commandManager, а обрабатывается отдельным путем внутри Designer. Иными словами, хотя внешне это выглядит как один и тот же тип операции «удаления», клавиша Delete (→ команда типа очистки) и удаление строк/столбцов из контекстного меню проходили по разным путям. Я пришёл к выводу, что дальнейшее изучение самой команды имеет ограничения, и изменил свой подход.

Подход 3. Управление контекстным меню

Вместо попытки заблокировать выполнение команды я изменил направление поиска, решив, что, возможно, можно просто отключать единственный пункт «Удалить» в контекстном меню при каждом его открытии. Я сузил вопрос до следующего: «Какое событие вызывается при открытии меню с помощью щелчка правой кнопкой мыши?» — и обнаружил contextMenu.onOpenMenu.

Однако при подключении обработчика как есть callback вызывался нормально, но itemsDataForShown(список пунктов меню, которые должны были отображаться) постоянно записывался в журнал как пустой массив. Поскольку Designer управляет лентой и контекстным меню через собственную систему commandMap, оказалось, что подключения обработчика только к обычному contextMenu книги недостаточно, чтобы перехватить пункты, которые фактически отображает Designer. После реорганизации кода, в ходе которой исходный onOpenMenu предварительно сохранялся и оборачивался дополнительной логикой, пункты начали передаваться нормально.

workbook.contextMenu.onOpenMenu = function (menuData, itemsDataForShown, hitInfo, spread) {
  let result = true;
  if (typeof originalOnOpenMenu === 'function') {
    result = originalOnOpenMenu.apply(this, arguments);
  }
  const sheet = spread.getActiveSheet();
  const info = hitInfo.worksheetHitInfo;

  if (!info || hitInfo.hitTestType == null) {
    // 시트 탭 영역: insertSheet 항목 비활성화
    itemsDataForShown?.forEach((item) => {
      if (item.name === 'insertSheet') item.disable = item.disabled = true;
    });
  } else if (info.row >= 0 && info.col >= 0 && sheet.getBindingPath(info.row, info.col)) {
    // 셀 영역: 바인딩패스가 있으면 delete 관련 항목 비활성화
    itemsDataForShown?.forEach((item) => {
      const name = item.name?.toLowerCase() ?? '';
      const text = item.text?.toLowerCase() ?? '';
      if (name.includes('delete') || text.includes('delete')) item.disable = item.disabled = true;
    });
  }
  return result;
};

С помощью этой логики я отдельно отключил пункт insertSheet вкладки листа и пункт delete ячейки, выполнив оба требования. Кнопка + на ленте полностью скрывалась с экрана одной настройкой — workbook.options.newTabVisible = false; — независимо от этого переопределения. Иными словами, путь добавления нового листа через контекстное меню блокировался с помощью onOpenMenu, а сама кнопка отключалась этой настройкой.

Заключение

Из этой работы я понял, что с Designer часто нельзя эффективно работать, рассматривая только общий API Workbook в SpreadJS. Поскольку лента, контекстное меню и команды связаны в единую систему, самым быстрым способом отладки ситуации, когда что-то переставало работать, было сначала изучить связанные объекты команд. Этот опыт помог мне немного лучше понять внутреннюю структуру Designer, выйдя за рамки простого использования его API.

pong

Site footer