-Как использовать 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