Сохранение документа и проверка импорта
До этого момента схема существует только в памяти страницы. Теперь добавим локальное сохранение и обмен JSON-файлом. Пользователь сможет закрыть вкладку, открыть редактор снова или перенести документ в другой браузер. Главная задача урока — сохранить принятую модель и не заменить её непроверенными либо устаревшими данными.
Код расположен в files.mjs и обработчиках main.mjs архива серии. Пример ещё не запускался. Поэтому успешная загрузка, ошибки и восстановление ниже описаны как ожидаемые сценарии. Наш инструмент остаётся локальным: синхронизация между устройствами и серверное резервирование сюда не включены.
Формат файла и граница доверия
JSON хранит только документ: schema, размеры страницы и фигуры. Камера, выделение, история и сообщение состояния не экспортируются. Это позволяет открыть схему заново в другом окружении, не восстанавливая случайное положение интерфейса. Номер schema = 1 фиксирует известный нам формат; неизвестный номер отклоняется.
Чтение строки выполняет parseDocument(). Сначала проверяем размер UTF-8-представления, затем вызываем JSON.parse() и validateDocument(). Ограничение составляет один мебибайт. Число символов строки не равно числу байтов: кириллица и другие символы могут занимать больше места, поэтому используем TextEncoder.
export function parseDocument(raw) {
if (new TextEncoder().encode(raw).byteLength > LIMITS.bytes) {
throw new Error('Документ больше 1 МиБ');
}
return validateDocument(JSON.parse(raw));
}
Это полная функция из модуля. Проверка объёма не доказывает корректность структуры. Маленький файл может содержать неверный тип, повторный идентификатор или огромную координату. И наоборот, синтаксически правильный JSON не обязан быть документом «Схемы». Все три ступени имеют отдельный смысл: объём, синтаксис и договор модели.
Для внешнего File сначала проверяем file.size, чтобы не читать заведомо большой файл целиком. После file.text() всё равно применяем общий разбор. Локально сохранённая строка также проходит через него. Источник из своего браузера не является гарантией актуального формата: запись могла остаться от другой версии приложения или быть изменена вручную.
Локальное сохранение
Используем ключ pw-diagram-schema1. При чтении отсутствие значения означает, что можно показать учебный документ. Ошибка чтения или разбора тоже приводит к открытию примера, но сопровождается сообщением. Пользователь должен понимать, почему его прежняя схема не восстановилась.
Сохранение вызывает localStorage.setItem() с JSON проверенного документа. Эта операция может завершиться ошибкой из-за политики браузера, заполненного хранилища или других ограничений. Мы не удаляем текущую схему из памяти. Сообщение предлагает скачать JSON — это другой путь получения копии, который не зависит от успешной записи данного ключа.
Не стоит называть localStorage резервной копией. Данные относятся к конкретному origin и профилю браузера; пользователь может очистить их вместе с данными сайта. Переход с одного локального порта на другой также меняет origin. Для важной работы файл следует скачивать отдельно, а при дальнейшем развитии предусмотреть понятный способ резервирования.
Когда сохранять
После законченной команды ставим таймер на 400 миллисекунд. Новая команда сбрасывает прежний таймер. Такой debounce объединяет быстрые последовательные изменения и уменьшает число синхронных записей. Однако он создаёт небольшой промежуток, в котором последнее действие ещё не сохранено. Обещать отсутствие потери при немедленном закрытии вкладки нельзя.
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
saveLocal(history.value);
announce('Документ сохранён в этом браузере.');
} catch {
announce('Локальное сохранение недоступно. Скачайте JSON.');
}
}, 400);
Фрагмент находится в saveLater(). В момент срабатывания берём актуальное значение истории. Перед началом перетаскивания очищаем таймер, чтобы не записать промежуточный кадр. После успешного окончания или отмены планируем сохранение снова. Так граница сохранения согласована с границей пользовательской операции.
Можно было бы сохранять каждый pointermove, но после аварийного закрытия пользователь получил бы случайную точку незавершённого жеста. С другой стороны, сохранять только по отдельной кнопке удобно не всем. Выбранная версия сочетает автоматическую запись стабильных состояний и явное скачивание файла, не скрывая ограничений debounce.
Асинхронный импорт
Чтение файла возвращает Promise. Если пользователь быстро выбирает A, а затем B, загрузка A может закончиться последней. Для этого у обработчика есть счётчик importRevision. Каждый новый выбор увеличивает его; завершённая операция может применить документ только при совпадении собственного номера с текущим.
const token = ++importRevision;
const doc = await importDocument(file);
if (token !== importRevision || drag) return;
commit(doc, 'JSON импортирован.');
Это центральный участок обработчика. Счётчик не прерывает чтение старого файла; он запрещает устаревшему результату заменить новый. Если во время ожидания начинается перетаскивание, результат также не применяется. В первой версии импорт заменяет весь документ и не объединяет его с правками, сделанными во время ожидания. Это ограничение следует учитывать при дальнейшем развитии интерфейса.
После успешной загрузки камера показывает новую страницу целиком, а выделение проверяется заново. Замена проходит через историю, поэтому её можно отменить. При ошибке текущий документ остаётся прежним. Поле выбора очищается в finally, чтобы повторный выбор того же файла мог вызвать новое событие.
Ещё одна граница локального ключа — несколько вкладок одного origin. Они могут записывать разные схемы под одним именем, и поздняя запись заменит раннюю. Наша версия не слушает событие storage и не объединяет документы. Поэтому её нельзя представлять как совместный редактор или надёжное хранилище параллельной работы. Для нескольких вкладок нужен отдельный договор версий и уведомлений.
Также мы не записываем весь массив истории. После повторного открытия восстанавливается последний сохранённый документ, а кнопки отмены начинают с пустого прошлого. Это уменьшает объём хранения и не переносит случайные временные состояния. Если история сеанса станет важной частью продукта, потребуется собственный формат, пределы и миграция, а не просто ещё один JSON.stringify().
Скачивание и срок жизни Blob
Для JSON создаём Blob, затем временный URL через URL.createObjectURL(). Ссылка с атрибутом download предлагает имя schema.json. После программного нажатия ссылку удаляем, а URL освобождаем через тридцать секунд. Это освобождает ресурс после выдачи браузеру, но не подтверждает, что пользователь действительно сохранил файл на диск.
Для самостоятельной проверки рассмотрите повреждённый JSON, неизвестную schema, два одинаковых id и файл больше лимита. Каждый должен оставить текущую схему нетронутой. Затем загрузите допустимый документ другого размера и отмените замену. Отдельно проверьте поведение при недоступном локальном хранилище: возможность скачать JSON должна сохраниться.
Эти проверки пока не выполнены. Мы получили переносимый редактируемый формат и локальное восстановление с явными границами. Следующий урок посвящён изображениям: SVG сохранит векторную геометрию, а Canvas поможет сформировать PNG заданного размера, независимо от масштаба текущего просмотра.