Закладки и место продолжения
Сохранённые главы можно читать без сети. Теперь добавим возможность вернуться к определённому разделу. Это небольшая функция, но у неё другой смысл данных: HTML можно загрузить заново, а выбранная закладка принадлежит читателю. Будем хранить её отдельно от кеша ответов и не удалять при обычном обновлении оформления.
Для XML используем article_id xml и якорь query-example. Для Markdown — article_id markdown и assemble. Эти устойчивые ID добавляются в подготовленные HTML-заголовки своего offline-lab. Они не являются автоматически подтверждёнными якорями настоящего ProfessorWeb. Адрес страницы остаётся прежним; fragment указывает место внутри неё.
Что мы называем прогрессом
Открытие главы не означает её освоение. Положение прокрутки тоже не доказывает, что человек прочитал объяснение или выполнил пример. Наш интерфейс назовёт функцию «Продолжить с этого раздела». Она хранит выбранное место, а не процент знаний. Если позже понадобится отметка выполнения задания, это будет отдельное явное действие и другой договор.
Закладка содержит article_id, heading_id, release_id и saved_at. ID материала переживает перестановку каталога; устойчивый якорь переживает изменение соседнего текста. Дата помогает человеку понять, когда сделан выбор. Она не используется как показатель длительности изучения или точного момента завершения урока.
IndexedDB хранит структурированные данные отдельно от Cache API. Создадим базу pw-reader версии1 и object store bookmarks с ключом article_id. Получится одна последняя закладка на материал. Для нескольких именованных закладок потребовался бы другой ключ и отдельный интерфейс; текущая схема не притворяется таким менеджером. Открытие IndexedDB.
Полный модуль хранения
Следующий assets/bookmarks.js открывает базу и сохраняет согласованную запись. Список разрешённых article_id маленький и явный. Проверка якоря ограничивает формат, а наличие раздела нужно сверить в текущем документе до вызова. Ошибка хранения возвращается вызывающей стороне; надпись об успехе появляется лишь после завершения транзакции.
export function openReader(onBlocked = console.warn) {
return new Promise((resolve, reject) => {
const request = indexedDB.open("pw-reader", 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("bookmarks", {keyPath: "article_id"});
};
request.onerror = () => reject(request.error);
request.onblocked = () => onBlocked("Закройте прежнюю вкладку учебника");
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => db.close();
resolve(db);
};
});
}
export function saveBookmark(db, bookmark) {
if (!["xml", "markdown"].includes(bookmark.article_id) ||
!/^[a-z][a-z0-9-]*$/.test(bookmark.heading_id)) {
return Promise.reject(new Error("Неизвестный материал или якорь"));
}
return new Promise((resolve, reject) => {
const tx = db.transaction("bookmarks", "readwrite");
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error ?? new Error("Запись отменена"));
tx.onerror = () => reject(tx.error);
tx.objectStore("bookmarks").put({
article_id: bookmark.article_id,
heading_id: bookmark.heading_id,
release_id: bookmark.release_id ?? "unknown",
saved_at: new Date().toISOString()
});
});
}
export function readBookmark(db, articleId) {
return new Promise((resolve, reject) => {
const tx = db.transaction("bookmarks", "readonly");
const request = tx.objectStore("bookmarks").get(articleId);
let value;
request.onsuccess = () => { value = request.result; };
tx.oncomplete = () => resolve(value);
tx.onabort = () => reject(tx.error ?? new Error("Чтение отменено"));
tx.onerror = () => reject(tx.error);
});
}
Успех отдельного request не используется как окончательное подтверждение записи. Договор функции связывает resolve с complete транзакции. Это позволяет интерфейсу не объявлять завершённым действие, которое ещё может быть отменено вместе с остальными запросами той же транзакции. IDBTransaction.complete.
Код открывает соединение, а не загружает HTML. Если глава отсутствует в кеше, успешное сохранение закладки не создаст её копию. Эти два состояния показываются отдельно: место продолжения известно, материал на устройстве отсутствует. Пользователь сможет получить главу при возвращении сети, сохранив прежний выбор раздела.
Кнопка возле раздела
В подготовленном XML разместите heading с id="query-example" и обычную кнопку «Продолжить отсюда». Обработчик сначала проверяет наличие такого элемента, затем вызывает saveBookmark для xml. Ветка успеха сообщает «Место продолжения сохранено», ветка отказа — «Не удалось сохранить место». Сам клик не является успехом хранилища.
Чтение закладки возвращает объект либо undefined. В первом случае ссылка строится из URL реестра и heading_id. Во втором нет прежнего места, поэтому показывается обычная ссылка на начало. Не храните готовый произвольный URL, если достаточно ID: тогда смена домена учебного стенда не превращает локальную запись в переход на старый origin.
Сохранение по ключу article_id заменяет предыдущую запись этого материала. Так функция «последнее место» получает простое предсказуемое поведение. Она не обязана заменять закладку автоматически при каждой прокрутке. Явная кнопка позволяет читателю выбрать смысловую точку и уменьшает количество незаметных записей.
Рассмотрим конкретный путь возвращения. Читатель сохранил query-example у XML и закрыл окно. Новая вкладка открывает базу, читает запись xml и строит ссылку из реестра. Если HTML-копия доступна, обычная навигация получает её через worker и браузер переходит к якорю. Если копии нет и сеть отсутствует, запасная страница объясняет недоступность материала. Закладка при этом не удаляется: её смысл не зависит от текущей возможности загрузить тело статьи.
При открытии с якорем найдите соответствующий раздел и проверьте, что ID существует. Если после обновления он исчез, ссылка на начало остаётся рабочей, а сообщение объясняет потерю точного места. Не нужно менять сохранённый heading_id на первый случайный заголовок. Человек должен сам выбрать новую точку, если редакция не предоставила проверенное соответствие.
Для интерфейса важно и обратное действие: «Убрать место продолжения» удаляет конкретную запись bookmark, но не HTML из кеша. «Удалить офлайн-копию» действует наоборот. Разные подписи помогают не потерять личный выбор при попытке освободить место для другого курса.
Изменившийся раздел
После новой редакции статьи прежний heading_id может отсутствовать. Не создавайте пустой элемент только ради молчаливого успеха перехода. Откройте сам материал и сообщите, что прежний раздел не найден в текущем выпуске. Если редакция имеет проверенную карту замены якорей, её можно применить отдельно, сохранив объяснение результата.
Поле release_id помогает различить такую ситуацию. Оно описывает выпуск, в котором читатель сделал выбор, и не является версией схемы IndexedDB. Смена оформления не всегда меняет содержание; смена структуры разделов может потребовать проверки закладок. Нельзя механически считать любой больший номер worker причиной исчезновения якоря.
Значение unknown также имеет смысл. Если прежняя глава не содержала метки выпуска, не заполняйте поле придуманной версией. Неизвестность сохраняется как неизвестность. Приложение всё ещё способно найти якорь по ID, но не должно утверждать, что восстановило прежний редакционный контекст.
Локальность выбора
Данные находятся в конкретном origin и профиле браузера. Другой компьютер не получает закладки автоматически. Очистка данных сайта может удалить и кеш, и IndexedDB. Поэтому слово «сохранено» относится к завершённой локальной операции, а не к облачной резервной копии. Экспорт рассмотрим вместе с ограничениями хранилища.
Обновление HTML не должно вызывать deleteDatabase. Программа может заменить загрузимые ресурсы, сохраняя записи пользователя. Появление новых настроек требует миграции схемы, а не полного сброса ради удобства разработчика. Следующий урок добавит settings и покажет, как сохранить существующие bookmarks при переходе базы на версию2.