From 9a2df1b09cbdc23216ca66a07f5fc6b678febf0c Mon Sep 17 00:00:00 2001 From: kvasonaft Date: Fri, 3 Jul 2026 22:21:50 +0300 Subject: [PATCH] Add detailed Russian comments and beginner's guide (GUIDE.md) Co-Authored-By: Claude Fable 5 --- GUIDE.md | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 371 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 657 insertions(+), 73 deletions(-) create mode 100644 GUIDE.md diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..9acbf34 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,359 @@ +# FB2 Reader: подробное руководство по устройству плагина + +Это руководство написано для человека, который **не знает JavaScript и TypeScript**, но хочет понять, как работает этот плагин — вплоть до каждой строчки. Читать лучше по порядку: сначала общая картина, потом основы языка, потом разбор кода. + +## Содержание + +1. [Что делает плагин](#1-что-делает-плагин) +2. [Общая картина: как код становится плагином](#2-общая-картина-как-код-становится-плагином) +3. [Файлы репозитория: кто за что отвечает](#3-файлы-репозитория-кто-за-что-отвечает) +4. [Минимум JavaScript и TypeScript для чтения кода](#4-минимум-javascript-и-typescript-для-чтения-кода) +5. [Как Obsidian запускает плагин](#5-как-obsidian-запускает-плагин) +6. [Разбор main.ts: путь книги от файла до экрана](#6-разбор-maints-путь-книги-от-файла-до-экрана) +7. [styles.css: как оформляется книга](#7-stylescss-как-оформляется-книга) +8. [Как собрать, установить и менять плагин](#8-как-собрать-установить-и-менять-плагин) +9. [Что нужно для публикации в каталоге Obsidian](#9-что-нужно-для-публикации-в-каталоге-obsidian) + +--- + +## 1. Что делает плагин + +FB2 Reader учит Obsidian открывать книги в формате FictionBook (файлы `.fb2` и `.fb2` внутри `.zip`). Возможности: + +- **Читалка.** Файл `.fb2` открывается как красиво оформленная книга: обложка, название, авторы, аннотация, главы, стихи, цитаты, сноски, картинки, таблицы. +- **Оглавление.** В правой боковой панели появляется список глав; клик по главе прокручивает книгу к ней. +- **Запоминание позиции.** Плагин помнит, на каком абзаце вы остановились в каждой книге (до 300 книг), и при повторном открытии возвращает вас туда. +- **Настройки вида.** Тема (светлая/тёмная/сепия), шрифт, размер шрифта, межстрочный интервал, цвет текста. + +## 2. Общая картина: как код становится плагином + +Цепочка выглядит так: + +``` +src/main.ts ──(сборщик esbuild)──► main.js ──(читает Obsidian)──► работающий плагин + ▲ ▲ +исходник, который файл, который реально +пишет человек исполняется +``` + +Зачем эта цепочка нужна: + +- **JavaScript (JS)** — язык, который умеют исполнять браузеры. Obsidian построен на технологии Electron (по сути, браузер Chrome в отдельном окне), поэтому плагины для него — это JavaScript. +- **TypeScript (TS)** — это JavaScript плюс **типы**: возможность указать «эта переменная — число», «эта функция возвращает строку». Obsidian не понимает TypeScript, поэтому перед запуском типы «стираются», и остаётся чистый JavaScript. Зато на этапе написания кода компилятор TypeScript проверяет, что вы нигде не перепутали число со строкой и не обратились к несуществующему полю. Для новичка это огромная подмога: множество ошибок ловится до запуска. +- **esbuild** — сборщик. Он делает две вещи: стирает типы из `src/main.ts` и «вклеивает» в результат код используемых библиотек (у нас это fflate для zip-архивов). На выходе — один самодостаточный файл `main.js`. +- **Node.js и npm** — инструменты для запуска всего этого на вашем компьютере. Node.js исполняет JavaScript вне браузера (нужен сборщику), npm — менеджер пакетов: скачивает библиотеки, перечисленные в `package.json`, в папку `node_modules`. + +Важное следствие: **`main.js` и `node_modules` не хранятся в git** — это «продукты производства», которые всегда можно получить заново командами `npm install` и `npm run build`. Именно поэтому их нет в этой ветке. + +## 3. Файлы репозитория: кто за что отвечает + +| Файл | Роль | +|---|---| +| `src/main.ts` | **Весь код плагина.** Единственный исходник. | +| `styles.css` | Оформление: темы, отступы, шрифты, вид оглавления. | +| `manifest.json` | «Паспорт» плагина для Obsidian: id, имя, версия, минимальная версия Obsidian. Без него Obsidian плагин не увидит. | +| `package.json` | Описание проекта для npm: список зависимостей и команды сборки (`npm run build`, `npm run dev`). | +| `package-lock.json` | Автогенерируемый файл: точные версии всех скачанных библиотек, чтобы сборка везде была одинаковой. Руками не редактируется. | +| `tsconfig.json` | Настройки проверки типов TypeScript (насколько строго проверять, какой диалект JS считать целевым). | +| `.gitignore` | Список того, что git не должен отслеживать: `node_modules/`, `main.js`. | +| `GUIDE.md` | Этот документ. | + +## 4. Минимум JavaScript и TypeScript для чтения кода + +Этот раздел — «словарик», к которому можно возвращаться при чтении `main.ts`. + +### Переменные и константы + +```ts +const name = "Война и мир"; // константа: перезаписать нельзя +let count = 0; // переменная: можно менять +count = count + 1; +``` + +В коде плагина почти всё объявлено через `const` — это хороший стиль: сразу видно, что значение не поменяется. + +### Типы (это уже TypeScript) + +```ts +const size: number = 17; // явно указан тип «число» +const title: string = "FB2"; // «строка» +const ready: boolean = true; // «да/нет» +``` + +Часто тип не пишут — TypeScript сам его выводит: в `const size = 17` и так понятно, что это число. Тип указывают там, где вывести его неоткуда (параметры функций) или где хочется дополнительной страховки. + +Ещё встречаются: + +```ts +string[] // массив строк: ["a", "b", "c"] +Record // объект-словарь: { "иван": 3, "мария": 5 } +string | null // «строка ИЛИ null (пусто)» +"light" | "dark" | "sepia" // только одна из этих трёх строк и ничего больше +``` + +### Объекты и интерфейсы + +Объект — набор именованных значений: + +```ts +const settings = { fontSize: 17, theme: "dark" }; +settings.fontSize // → 17 (доступ через точку) +``` + +`interface` описывает, какой формы должен быть объект. Это чисто проверочная конструкция — в готовый `main.js` она не попадает: + +```ts +interface Fb2Settings { + fontSize: number; + theme: string; +} +``` + +Теперь, если где-то написать `settings.fontSze` (опечатка), TypeScript сразу подчеркнёт ошибку. + +### Функции + +Функция — именованный кусок кода, который принимает входные значения (параметры) и возвращает результат: + +```ts +function double(n: number): number { // принимает число, возвращает число + return n * 2; +} +double(21) // → 42 +``` + +Есть и краткая запись — **стрелочная функция**. Она часто передаётся другой функции как «что делать с каждым элементом»: + +```ts +(n) => n * 2 // то же, что double, но без имени +[1, 2, 3].map((n) => n * 2) // → [2, 4, 6]: применить к каждому элементу +[1, 2, 3].filter((n) => n > 1) // → [2, 3]: оставить подходящие +``` + +В `main.ts` стрелочные функции повсюду: «когда произойдёт клик — выполни вот это», «для каждого автора — склей имя и фамилию». + +### Классы + +Класс — чертёж объекта: его **поля** (данные) и **методы** (действия). По одному чертежу можно создать много объектов. + +```ts +class Book { + title: string; // поле + + constructor(title: string) { // вызывается при создании объекта + this.title = title; // this — «этот самый объект» + } + + describe(): string { // метод + return "Книга: " + this.title; + } +} + +const b = new Book("Муму"); // создание объекта по чертежу +b.describe() // → "Книга: Муму" +``` + +Ключевое слово `extends` — **наследование**: «возьми готовый класс и дострой его». + +```ts +class AudioBook extends Book { ... } +``` + +Весь плагин построен на наследовании: Obsidian даёт заготовки (`Plugin`, `FileView`, `ItemView`, `PluginSettingTab`), а мы наследуемся от них и заполняем нужные методы. `super` внутри метода означает «вызови эту же логику у родителя». Пометка `private` у поля значит «доступно только внутри класса» — снаружи трогать нельзя. + +### async / await: ожидание медленных операций + +Чтение файла с диска — «медленная» операция. Если бы код просто ждал её, окно Obsidian замирало бы. Поэтому такие операции возвращают **обещание** (`Promise`) — «результат будет позже». Слово `await` означает «дождись результата, но не замораживай программу», а функция, внутри которой есть `await`, помечается словом `async`: + +```ts +async function readBook(file: TFile) { + const bytes = await this.app.vault.readBinary(file); // ждём чтения с диска + // сюда придём, когда bytes уже получены +} +``` + +### Полезные мелкие операторы + +```ts +a ?? b // «a, а если a — null/undefined, то b» +obj?.field // «возьми field, а если obj пустой — верни undefined без ошибки» +a || b // «a, а если a пустое/ложное — b» +condition ? x : y // «если condition — то x, иначе y» +`размер: ${n}px` // шаблонная строка: подставляет значение n внутрь текста +``` + +### DOM: как код «рисует» на экране + +Страница в браузере — это дерево элементов (**DOM**): абзацы, заголовки, картинки вложены друг в друга. Код может создавать элементы, менять их и удалять — именно так плагин «рисует» книгу. Obsidian добавляет к стандартным средствам удобные сокращения: + +```ts +parent.createEl("p", { text: "Привет", cls: "fb2-p" }) +// создать внутри parent абзац

Привет

+ +parent.createDiv({ cls: "fb2-book" }) // создать
+container.empty() // удалить всё содержимое +el.addClass("fb2-reader") // добавить CSS-класс +``` + +CSS-класс (`cls`) — это «ярлык», по которому файл `styles.css` находит элемент и оформляет его. + +## 5. Как Obsidian запускает плагин + +1. Obsidian сканирует папку `.obsidian/plugins/` в хранилище. В папке плагина он ожидает три файла: `manifest.json`, `main.js`, `styles.css`. +2. Прочитав `manifest.json`, Obsidian узнаёт id и версию плагина. +3. При включении плагина Obsidian загружает `main.js`, находит в нём класс, помеченный `export default` (у нас это `Fb2ReaderPlugin`), создаёт его и вызывает метод **`onload()`**. +4. В `onload()` плагин «представляется»: регистрирует свои виды, расширения файлов, команды, вкладку настроек. Дальше Obsidian сам вызывает нужные методы в нужные моменты — это называется «инверсия управления»: не мы командуем программой, а программа зовёт нас. +5. При выключении плагина вызывается **`onunload()`** — там нужно прибрать за собой. + +Ключевые понятия Obsidian, встречающиеся в коде: + +- **Vault (хранилище)** — папка с вашими заметками. `this.app.vault.readBinary(file)` — прочитать файл из хранилища. +- **Workspace (рабочая область)** — расположение окон: вкладки, панели. +- **Leaf (лист)** — одна «ячейка» рабочей области (вкладка или панель), в которую вставляется вид. +- **View (вид)** — содержимое листа. Наша читалка и наше оглавление — это виды. + +## 6. Разбор main.ts: путь книги от файла до экрана + +Файл состоит из восьми разделов (они отмечены комментариями-линейками). Здесь — логика каждого; построчные пояснения смотрите в комментариях самого файла. + +### 6.1. Импорты + +Вверху файла подключаются инструменты из пакета `obsidian` (классы-заготовки и функция `debounce`) и функция `unzipSync` из библиотеки fflate. При сборке esbuild вклеивает код fflate внутрь `main.js`, а пакет `obsidian` не вклеивает (флаг `--external:obsidian` в команде сборки) — его предоставляет сам Obsidian во время работы. + +### 6.2. Типы и настройки по умолчанию + +Интерфейсы `TocItem`, `ReadingPosition`, `Fb2Settings`, `Fb2Data` описывают форму данных плагина. `DEFAULT_SETTINGS` — настройки при первом запуске. `TEXT_COLORS` — варианты для выпадающего списка «цвет текста». + +### 6.3. Таблицы тегов — сердце «перевода» FB2 в HTML + +FB2 — это XML-формат со своими тегами. Например, глава книги выглядит так: + +```xml +
+ <p>Глава первая</p> +

Все счастливые семьи похожи друг на друга...

+
+``` + +Браузер таких тегов не знает, поэтому каждый надо превратить в HTML-аналог. Большинство превращений тривиальны, и они описаны тремя таблицами: + +- `BLOCK_CONTAINERS`: `section`, `poem`, `cite`... → обёртка (`div` или `blockquote`) с CSS-классом; содержимое обрабатывается дальше. +- `BLOCK_PARAGRAPHS`: `p`, `subtitle`, `v`, `text-author` → абзац `

` с CSS-классом. +- `INLINE_TAGS`: `emphasis` → `` (курсив), `strong` → `` (жирный) и т.п. + +Хотите узнать, как отображается тег — ищите его в этих таблицах. Хотите поддержать новый тег — чаще всего достаточно добавить одну строку в таблицу и стиль в `styles.css`. + +### 6.4. Вспомогательные функции + +- `detectEncoding` / `decodeFb2` — превращают байты файла в текст. Старые FB2-книги часто в кодировке windows-1251, поэтому кодировку приходится угадывать: сначала по служебным байтам BOM, потом по объявлению `encoding="..."` в начале XML. +- `extractFb2FromZip` — достаёт `.fb2` из zip-архива с помощью fflate. +- `getSystemFonts` — запрашивает у системы список установленных шрифтов (для настроек). Результат кэшируется; если возможность недоступна — возвращается пустой список, и настройки покажут текстовое поле вместо выпадающего списка. +- `getHref` — достаёт адрес ссылки, пробуя все варианты записи атрибута (`xlink:href`, `l:href`, `href`), которые встречаются в реальных книгах. +- `copyId` — переносит `id` тега FB2 на HTML-элемент, чтобы работали внутренние ссылки (сноски). + +### 6.5. Класс Fb2View — читалка + +Наследуется от `FileView` (вид, привязанный к файлу). Когда пользователь открывает `.fb2`, Obsidian создаёт `Fb2View` и вызывает `onLoadFile`. Внутри — вся цепочка: + +``` +байты файла → (unzip при необходимости) → текст → XML-дерево → HTML-элементы +``` + +1. **`onLoadFile`** читает файл, распаковывает zip, декодирует текст и разбирает его встроенным `DOMParser` в дерево объектов. Если XML битый — показывает сообщение об ошибке. +2. **`collectBinaries`** собирает картинки. В FB2 они лежат в тегах `` как текст base64; из них делается словарь «id → data-URL», и `` показывает картинку прямо из этой строки, без внешних файлов. +3. **`renderBook`** рисует титульную страницу (`renderTitleInfo`: обложка, название, авторы, аннотация), затем каждое `` книги. Второе `` — это сноски; они оформляются отдельно и не попадают в оглавление. В конце вешается один обработчик кликов на всю книгу: клик по внутренней ссылке находит элемент-цель по `data-fb2-id` и плавно прокручивает к нему. +4. **`renderBlock`** — рекурсивный «переводчик» блочных тегов: сначала смотрит в таблицы (случаи 1 и 2), затем обрабатывает особые теги (`title`, `image`, `table`, `empty-line`), а незнакомые теги прозрачно «проходит насквозь», отрисовывая их содержимое. Рекурсия (метод вызывает сам себя для детей элемента) позволяет обойти дерево любой глубины. Параметр `depth` растёт при входе в каждую `

` — от него зависит уровень заголовка (`h2`, `h3`...). Попутно заголовки складываются в `tocItems` — этот список потом показывает панель оглавления. +5. **`renderInline`** — то же для строчного содержимого: текст добавляется как есть, простые теги берутся из таблицы `INLINE_TAGS`, ссылки `` обрабатываются отдельно (сноски оборачиваются в `` — маленькая цифра сверху). + +**Позиция чтения.** Хранить позицию в пикселях ненадёжно: смените шрифт — и всё съедет. Поэтому хранится **номер первого видимого блока текста**. При прокрутке (не чаще раза в 800 мс — за это отвечает `debounce`) вычисляется, какой блок сейчас первый на экране, и его номер сохраняется. При открытии книги `restoreReadingPosition` прокручивает к блоку с сохранённым номером. + +### 6.6. Класс Fb2TocView — панель оглавления + +Простой вид: хранит ссылку на читалку (`source`) и рисует её `tocItems` — по строке на главу, с отступом по глубине вложенности. Клик по строке показывает вкладку книги и прокручивает к главе. Сама панель ничего не вычисляет — всю работу сделала читалка при отрисовке книги. + +### 6.7. Класс Fb2ReaderPlugin — «дирижёр» + +Тот самый класс с `export default`, который создаёт Obsidian. В `onload()`: + +- загружает сохранённые данные (`loadData()` читает `data.json` в папке плагина) и накладывает их на настройки по умолчанию; +- регистрирует оба вида и говорит, что расширения `fb2` и `zip` открываются в читалке (`registerExtensions`); +- добавляет вкладку настроек, кнопку на боковой панели (открывает настройки) и команду «Open table of contents» для палитры команд; +- подписывается на смену активной вкладки, чтобы панель оглавления всегда показывала содержание активной книги. + +Также этот класс — хранитель данных: `getPosition`/`setPosition` для позиций чтения (с ограничением в 300 книг — `prunePositions`), `applySettings` для применения настроек. Сохранение на диск отложенное (`debounce`, раз в 2 секунды), чтобы не писать файл слишком часто. + +Метод `applySettings` — важное место: он записывает значения настроек в **CSS-переменные** на элементе `` (например, `--fb2-font-size: 17px`). Код не занимается оформлением сам — он лишь «публикует» значения, а `styles.css` их читает. Об этом — следующий раздел. + +### 6.8. Класс Fb2SettingTab — вкладка настроек + +Obsidian вызывает `display()` при каждом открытии настроек. Каждая настройка создаётся классом `Setting`: имя, описание, поле ввода. Общий приём: обработчик `onChange` записывает новое значение в `plugin.fb2Settings` и вызывает `plugin.saveSettings()` — настройка применяется немедленно, без кнопки «Сохранить». + +Две хитрости: + +- **Гонка отрисовок.** Список шрифтов загружается не мгновенно. Если пользователь успел закрыть и снова открыть настройки, запускается вторая отрисовка, и первая, «отставшая», могла бы перерисовать всё поверх новой. Счётчик `renderToken` решает это: каждая отрисовка получает номер, и если к моменту готовности номер уже не последний — она тихо выходит. +- **Чужое значение в списке.** Если в настройках сохранён шрифт или цвет, которого нет в списке вариантов, он добавляется отдельным пунктом — иначе выпадающий список молча «слетел» бы на первый вариант. + +## 7. styles.css: как оформляется книга + +CSS — язык оформления: «у элементов с таким-то классом такой-то шрифт, отступ, цвет». Каждому созданному в коде элементу присвоен класс с префиксом `fb2-` (`fb2-p` — абзац, `fb2-title` — заголовок, `fb2-epigraph` — эпиграф...), и на каждый класс в `styles.css` есть правило. + +Два механизма стоит понять: + +**CSS-переменные** — мостик между настройками и оформлением: + +```css +.fb2-book { + font-size: var(--fb2-font-size, 1.05em); +} +``` + +Читается так: «размер шрифта — значение переменной `--fb2-font-size`, а если она не задана — 1.05em». Код (`applySettings`) записывает переменную на ``, стиль её подхватывает. Поменяли размер в настройках — книга мгновенно перерисовалась, без участия кода отрисовки. + +**Классы тем.** Выбор темы добавляет на `` класс `fb2-theme-dark` / `-light` / `-sepia`. В `styles.css` для каждого класса задан свой набор цветов: + +```css +body.fb2-theme-sepia .fb2-reader { + background-color: #f4ecd8 !important; + color: #5b4636; +} +``` + +Читается: «если на body есть класс fb2-theme-sepia, то внутри читалки — бумажный фон и коричневый текст». Заодно переопределяются стандартные переменные Obsidian (`--text-normal`, `--link-color`...), чтобы и вложенные элементы подхватили цвета темы. + +## 8. Как собрать, установить и менять плагин + +Один раз после клонирования репозитория: + +```bash +npm install # скачать зависимости в node_modules +``` + +Собрать плагин: + +```bash +npm run build # проверка типов + сборка main.js +``` + +Команда состоит из двух шагов (см. `package.json`): `tsc -noEmit` проверяет типы и ничего не создаёт; `esbuild` собирает `src/main.ts` в `main.js`. + +Установить в Obsidian вручную: скопировать `main.js`, `manifest.json`, `styles.css` в папку `<хранилище>/.obsidian/plugins/fb2-reader/` и включить плагин в настройках Obsidian. + +Для разработки удобнее режим наблюдения: + +```bash +npm run dev # пересобирает main.js при каждом сохранении main.ts +``` + +После пересборки нужно перезагрузить плагин в Obsidian (выключить-включить в настройках или командой «Reload app without saving»). + +Типичный цикл изменения: правите `src/main.ts` → сборка → копирование в хранилище → перезагрузка плагина → проверка на реальной книге. + +## 9. Что нужно для публикации в каталоге Obsidian + +Когда код станет полностью понятен, для публикации потребуется: + +1. **README.md на английском** — описание плагина, скриншоты, инструкция. +2. **LICENSE** — файл лицензии (в `package.json` уже указана MIT). +3. **Согласовать версии**: сейчас в `manifest.json` версия 0.7.1, а в `package.json` — 0.1.0; при публикации версии релизов должны совпадать с `manifest.json`. +4. **GitHub-релиз**: создать релиз с тегом, равным версии из `manifest.json`, и приложить к нему три файла: `main.js`, `manifest.json`, `styles.css` (именно поэтому `main.js` не хранится в репозитории — он прикладывается к релизу). +5. **Заявка в каталог**: форк репозитория [obsidianmd/obsidian-releases](https://github.com/obsidianmd/obsidian-releases), добавление своего плагина в `community-plugins.json`, pull request и прохождение ревью команды Obsidian (они проверяют код на соответствие [гайдлайнам](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines)). diff --git a/src/main.ts b/src/main.ts index 4152ef0..b38d3ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,23 @@ +/* + * Это ЕДИНСТВЕННЫЙ файл с кодом плагина. Всё, что делает FB2 Reader, + * описано здесь. Подробное руководство для начинающих — в файле GUIDE.md + * в корне репозитория: там объясняются и язык TypeScript, и устройство + * плагинов Obsidian, и логика этого файла раздел за разделом. + * + * Краткая карта файла (в порядке следования): + * 1. Импорты — подключение готовых инструментов Obsidian и библиотеки fflate. + * 2. Типы и настройки по умолчанию. + * 3. Таблицы соответствий «тег FB2 → элемент HTML». + * 4. Вспомогательные функции: определение кодировки, распаковка zip и т.п. + * 5. Класс Fb2View — сама «читалка», превращает FB2-файл в страницу. + * 6. Класс Fb2TocView — боковая панель с оглавлением. + * 7. Класс Fb2ReaderPlugin — «дирижёр»: регистрирует читалку в Obsidian, + * хранит настройки и позиции чтения. + * 8. Класс Fb2SettingTab — вкладка настроек плагина. + */ + +// «import» подключает код из других модулей. Из пакета "obsidian" мы берём +// классы и функции, которые Obsidian предоставляет всем плагинам. import { App, debounce, @@ -9,42 +29,58 @@ import { TFile, WorkspaceLeaf, } from "obsidian"; +// fflate — маленькая библиотека для распаковки zip-архивов +// (FB2-книги часто распространяются в виде .fb2.zip). import { unzipSync } from "fflate"; +// «const» объявляет константу — значение, которое нельзя изменить. +// Эти два идентификатора — внутренние имена наших видов (окон) в Obsidian. const VIEW_TYPE_FB2 = "fb2-reader-view"; const VIEW_TYPE_TOC = "fb2-reader-toc"; +// Пространство имён XML для атрибутов вида xlink:href (ссылки внутри FB2). const XLINK_NS = "http://www.w3.org/1999/xlink"; // --------------------------------------------------------------------------- -// Types and defaults +// Типы и значения по умолчанию +// +// «interface» — это описание ФОРМЫ объекта: какие у него поля и какого они +// типа. Интерфейсы существуют только на этапе проверки кода (TypeScript) +// и помогают ловить ошибки; в готовый main.js они не попадают. // --------------------------------------------------------------------------- +// Один пункт оглавления книги. interface TocItem { - text: string; - depth: number; - el: HTMLElement; + text: string; // текст заголовка главы + depth: number; // глубина вложенности (глава, подглава, ...) + el: HTMLElement; // сам HTML-элемент заголовка на странице — чтобы уметь к нему прокрутить } +// Сохранённая позиция чтения в конкретной книге. interface ReadingPosition { - index: number; - ts: number; + index: number; // номер абзаца, с которого продолжить чтение + ts: number; // момент сохранения (нужен, чтобы удалять самые старые записи) } +// Тема оформления: пустая строка означает «как в Obsidian». type Fb2Theme = "" | "light" | "dark" | "sepia"; +// Все настройки плагина, которые видит пользователь. interface Fb2Settings { - fontFamily: string; - fontSize: number; - lineHeight: number; - theme: Fb2Theme; - textColor: string; + fontFamily: string; // шрифт ("" = как в Obsidian) + fontSize: number; // размер шрифта в пикселях + lineHeight: number; // межстрочный интервал (множитель) + theme: Fb2Theme; // цветовая тема читалки + textColor: string; // цвет текста ("" = по теме) } +// Всё, что плагин сохраняет на диск (Obsidian кладёт это в data.json). interface Fb2Data { - positions: Record; + positions: Record; // путь к файлу → позиция чтения settings: Fb2Settings; } +// Настройки по умолчанию — используются при первом запуске +// и при нажатии кнопки "Reset to defaults". const DEFAULT_SETTINGS: Fb2Settings = { fontFamily: "", fontSize: 17, @@ -53,6 +89,9 @@ const DEFAULT_SETTINGS: Fb2Settings = { textColor: "", }; +// Готовые варианты цвета текста для выпадающего списка в настройках: +// «код цвета → подпись». Record значит «объект, где +// и ключи, и значения — строки». const TEXT_COLORS: Record = { "": "Default (theme)", "#000000": "Black", @@ -67,51 +106,68 @@ const TEXT_COLORS: Record = { }; // --------------------------------------------------------------------------- -// FB2 tag → HTML mapping tables +// Таблицы соответствий «тег FB2 → элемент HTML» +// +// FB2 — это XML со своими тегами (
, , ...). +// Браузер и Obsidian понимают только HTML, поэтому каждый тег FB2 надо +// «перевести». Большинство переводов тривиальны, и вместо длинной цепочки +// условий мы описываем их тремя таблицами. Чтобы узнать, как отображается +// тот или иной тег, достаточно найти его строчку здесь. // --------------------------------------------------------------------------- -// Block-level FB2 tags that become a plain container; children are rendered -// inside it as blocks. Only
increases the nesting depth. +// Блочные теги-«контейнеры»: превращаются в обёртку с CSS-классом, +// а их содержимое обрабатывается дальше как блоки. +// Только
увеличивает глубину вложенности (важно для заголовков). const BLOCK_CONTAINERS: Record = { - section: { tag: "div", cls: "fb2-section" }, - epigraph: { tag: "div", cls: "fb2-epigraph" }, - poem: { tag: "div", cls: "fb2-poem" }, - stanza: { tag: "div", cls: "fb2-stanza" }, - annotation: { tag: "div", cls: "fb2-annotation" }, - cite: { tag: "blockquote", cls: "fb2-cite" }, + section: { tag: "div", cls: "fb2-section" }, // глава книги + epigraph: { tag: "div", cls: "fb2-epigraph" }, // эпиграф + poem: { tag: "div", cls: "fb2-poem" }, // стихотворение + stanza: { tag: "div", cls: "fb2-stanza" }, // строфа + annotation: { tag: "div", cls: "fb2-annotation" }, // аннотация + cite: { tag: "blockquote", cls: "fb2-cite" }, // цитата }; -// Block-level FB2 tags that become a paragraph with inline content. +// Блочные теги, которые становятся абзацем

с указанным CSS-классом; +// их содержимое — уже строчный текст (курсив, ссылки и т.п.). const BLOCK_PARAGRAPHS: Record = { - p: "fb2-p", - subtitle: "fb2-subtitle", - v: "fb2-verse", - "text-author": "fb2-text-author", + p: "fb2-p", // обычный абзац + subtitle: "fb2-subtitle", // подзаголовок + v: "fb2-verse", // строка стихотворения + "text-author": "fb2-text-author", // подпись автора под цитатой/эпиграфом }; -// Inline FB2 tags that map directly to an HTML tag. +// Строчные теги (внутри абзаца), у которых есть прямой HTML-аналог. const INLINE_TAGS: Record = { - strong: "strong", - emphasis: "em", - strikethrough: "s", - sub: "sub", - sup: "sup", - code: "code", + strong: "strong", // жирный + emphasis: "em", // курсив + strikethrough: "s", // зачёркнутый + sub: "sub", // нижний индекс + sup: "sup", // верхний индекс + code: "code", // моноширинный (код) }; // --------------------------------------------------------------------------- -// File decoding helpers +// Вспомогательные функции: чтение и декодирование файла // --------------------------------------------------------------------------- +// Файл с диска приходит в виде «сырых байт» (ArrayBuffer). Чтобы превратить +// байты в текст, нужно знать кодировку. Эта функция пытается её угадать: +// сначала по первым байтам (метка BOM у UTF-16), затем по объявлению +// encoding="..." в первой строке XML. Если ничего не нашли — считаем UTF-8. function detectEncoding(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf.slice(0, 4)); if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le"; if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be"; + // Читаем первые 512 байт как latin1 (это безопасно для любых байт) + // и ищем в них слово encoding="...". const head = new TextDecoder("latin1").decode(buf.slice(0, 512)); const m = head.match(/encoding=["']([\w-]+)["']/i); return m ? m[1].toLowerCase() : "utf-8"; } +// Превращает байты FB2-файла в текст. try/catch — «попробуй, а если +// произойдёт ошибка (например, кодировка неизвестна браузеру) — сделай +// запасной вариант»: декодируем как UTF-8. function decodeFb2(buf: ArrayBuffer): string { try { return new TextDecoder(detectEncoding(buf)).decode(buf); @@ -120,44 +176,63 @@ function decodeFb2(buf: ArrayBuffer): string { } } +// Если открыли .zip: распаковываем архив и достаём из него первый файл +// с расширением .fb2. Возвращаем его байты, либо null, если не нашли. function extractFb2FromZip(buf: ArrayBuffer): ArrayBuffer | null { let entries: Record; try { + // filter — распаковываем только файлы, чьё имя заканчивается на .fb2, + // остальное содержимое архива даже не трогаем. entries = unzipSync(new Uint8Array(buf), { filter: (f) => f.name.toLowerCase().endsWith(".fb2"), }); } catch { - return null; + return null; // архив повреждён или это вовсе не zip } const name = Object.keys(entries)[0]; if (!name) return null; const data = entries[name]; + // Uint8Array может «смотреть» в середину большого буфера, + // поэтому вырезаем ровно наш кусок байт. return data.buffer.slice( data.byteOffset, data.byteOffset + data.byteLength ) as ArrayBuffer; } +// Кэш списка шрифтов: запрашивать его у системы каждый раз медленно, +// поэтому после первого успешного запроса результат запоминается. let cachedSystemFonts: string[] | null = null; +// «async» помечает функцию как асинхронную: она умеет ждать медленные +// операции (здесь — запрос списка шрифтов у системы), не замораживая +// интерфейс. Слово «await» внутри означает «дождись результата». async function getSystemFonts(): Promise { if (cachedSystemFonts) return cachedSystemFonts; + // window.queryLocalFonts — сравнительно новая возможность браузера, + // которой может и не быть, поэтому описываем её тип вручную + // и проверяем наличие. const queryLocalFonts = ( window as { queryLocalFonts?: () => Promise<{ family: string }[]> } ).queryLocalFonts; if (!queryLocalFonts) return []; try { const fonts: { family: string }[] = await queryLocalFonts.call(window); + // Одно семейство шрифта встречается по несколько раз (обычный, жирный, + // курсив...). Set оставляет только уникальные имена, sort — сортирует. const families = Array.from(new Set(fonts.map((f) => f.family))).sort( (a, b) => a.localeCompare(b) ); if (families.length) cachedSystemFonts = families; return families; } catch { - return []; + return []; // пользователь не дал разрешение — обойдёмся без списка } } +// Достаёт адрес ссылки из элемента FB2. В разных книгах атрибут ссылки +// записан по-разному (xlink:href, l:href, просто href), поэтому проверяем +// все варианты по очереди. «??» означает «если слева null — попробуй справа». function getHref(el: Element): string | null { return ( el.getAttributeNS(XLINK_NS, "href") ?? @@ -167,35 +242,53 @@ function getHref(el: Element): string | null { ); } -// Copy the FB2 "id" attribute so internal links can find this element later. +// Переносит атрибут id из тега FB2 на созданный HTML-элемент (под именем +// data-fb2-id), чтобы внутренние ссылки книги (сноски, перекрёстные ссылки) +// могли потом найти цель и прокрутить к ней. function copyId(from: Element, to: HTMLElement) { const id = from.getAttribute("id"); if (id) to.setAttribute("data-fb2-id", id); } // --------------------------------------------------------------------------- -// The reader view: renders one FB2 book +// Fb2View — читалка +// +// «class» — это чертёж объекта: набор данных (полей) и действий (методов). +// «extends FileView» означает: наш класс наследует готовый класс Obsidian +// для окон, привязанных к файлу, и добавляет/переопределяет нужное нам. +// Obsidian сам создаёт экземпляр Fb2View, когда пользователь открывает +// .fb2-файл, и сам вызывает методы жизненного цикла (onLoadFile и др.). // --------------------------------------------------------------------------- class Fb2View extends FileView { + // Пункты оглавления текущей книги; их читает панель Fb2TocView. tocItems: TocItem[] = []; - private plugin: Fb2ReaderPlugin; - private bookTitle = ""; - private binaries = new Map(); - private collectToc = false; + // «private» — поле доступно только внутри этого класса. + private plugin: Fb2ReaderPlugin; // ссылка на главный объект плагина + private bookTitle = ""; // название книги (для заголовка вкладки) + private binaries = new Map(); // картинки книги: id → data-URL + private collectToc = false; // собирать ли сейчас пункты оглавления + // debounce «сглаживает» частые вызовы: при прокрутке событие scroll + // срабатывает десятки раз в секунду, а сохранять позицию достаточно + // один раз, спустя 800 мс после того, как прокрутка затихла. private savePositionDebounced = debounce( () => this.saveReadingPosition(), 800, true ); + // Конструктор вызывается при создании объекта. «this» — сам объект: + // this.plugin = plugin означает «запомни plugin в своём поле plugin». constructor(leaf: WorkspaceLeaf, plugin: Fb2ReaderPlugin) { - super(leaf); + super(leaf); // сначала даём отработать конструктору родителя (FileView) this.plugin = plugin; - this.navigation = true; + this.navigation = true; // вкладка участвует в истории «назад/вперёд» } + // Вызывается один раз при создании вида. Подписываемся на прокрутку, + // чтобы запоминать позицию чтения. registerDomEvent — обёртка Obsidian, + // которая сама отпишет обработчик, когда вид закроется. onload(): void { super.onload(); this.registerDomEvent(this.contentEl, "scroll", () => @@ -203,29 +296,37 @@ class Fb2View extends FileView { ); } + // Следующие четыре метода — «анкета» вида, которую спрашивает Obsidian. getViewType(): string { - return VIEW_TYPE_FB2; + return VIEW_TYPE_FB2; // внутреннее имя вида } getDisplayText(): string { + // Заголовок вкладки: название книги, иначе имя файла, иначе "FB2". + // «||» возвращает первый «непустой» вариант слева направо. return this.bookTitle || this.file?.basename || "FB2"; } getIcon(): string { - return "book-open"; + return "book-open"; // имя иконки из встроенного набора Obsidian } canAcceptExtension(extension: string): boolean { return extension === "fb2" || extension === "zip"; } + // Главный метод: Obsidian вызывает его, когда в этом виде нужно открыть + // файл. Здесь происходит вся цепочка: байты → текст → XML → HTML. async onLoadFile(file: TFile): Promise { - const container = this.contentEl; - container.empty(); - container.addClass("fb2-reader"); + const container = this.contentEl; // корневой HTML-элемент нашего окна + container.empty(); // очищаем от предыдущего содержимого + container.addClass("fb2-reader"); // CSS-класс, на который нацелены стили this.tocItems = []; + // Шаг 1: читаем файл из хранилища Obsidian как байты. let buf = await this.app.vault.readBinary(file); + + // Шаг 2: если это zip — достаём из него .fb2. if (file.extension === "zip") { const extracted = extractFb2FromZip(buf); if (!extracted) { @@ -239,9 +340,14 @@ class Fb2View extends FileView { buf = extracted; } + // Шаг 3: байты → текст (с угадыванием кодировки). const xml = decodeFb2(buf); + // Шаг 4: текст → дерево XML. DOMParser встроен в браузер: он читает + // разметку и строит из неё дерево объектов, по которому можно ходить. const doc = new DOMParser().parseFromString(xml, "application/xml"); + // При ошибке разбора DOMParser не бросает исключение, а вставляет + // в документ специальный тег — проверяем его наличие. if (doc.querySelector("parsererror")) { container.createEl("p", { text: "Failed to parse the file: invalid XML.", @@ -250,12 +356,16 @@ class Fb2View extends FileView { return; } + // Шаг 5: собираем картинки, рисуем книгу, сообщаем плагину + // (чтобы тот обновил оглавление) и восстанавливаем позицию чтения. this.collectBinaries(doc); this.renderBook(doc, container.createDiv({ cls: "fb2-book" })); this.plugin.onFb2Opened(this); this.restoreReadingPosition(file.path); } + // Вызывается при закрытии файла: сохраняем позицию и прибираем за собой, + // чтобы не держать в памяти большую книгу. async onUnloadFile(file: TFile): Promise { this.saveReadingPosition(file); this.plugin.clearTocFor(this); @@ -265,8 +375,11 @@ class Fb2View extends FileView { this.contentEl.empty(); } - // --- reading position --- + // --- Позиция чтения --- + // Список всех «блоков текста» книги по порядку. Позицию чтения мы + // храним как номер блока в этом списке — это надёжнее, чем количество + // пикселей прокрутки (которое меняется при смене шрифта или окна). private getScrollBlocks(): HTMLElement[] { return Array.from( this.contentEl.querySelectorAll( @@ -275,10 +388,12 @@ class Fb2View extends FileView { ); } + // Сохраняем позицию: находим первый блок, который виден на экране + // (его нижний край ниже верхней кромки окна), и запоминаем его номер. private saveReadingPosition(file = this.file) { if (!file) return; const scroller = this.contentEl; - if (scroller.scrollTop <= 0) return; + if (scroller.scrollTop <= 0) return; // книга в самом начале — нечего запоминать const top = scroller.getBoundingClientRect().top; const index = this.getScrollBlocks().findIndex( (b) => b.getBoundingClientRect().bottom > top @@ -286,9 +401,12 @@ class Fb2View extends FileView { if (index >= 0) this.plugin.setPosition(file.path, index); } + // Восстанавливаем позицию: прокручиваем к блоку с сохранённым номером. private restoreReadingPosition(path: string) { const pos = this.plugin.getPosition(path); if (!pos || pos.index <= 0) return; + // requestAnimationFrame — «выполни перед следующей отрисовкой экрана»: + // к этому моменту браузер уже рассчитает размеры всех элементов. requestAnimationFrame(() => { const blocks = this.getScrollBlocks(); const target = blocks[Math.min(pos.index, blocks.length - 1)]; @@ -296,40 +414,48 @@ class Fb2View extends FileView { }); } - // --- rendering --- + // --- Отрисовка книги --- + // Картинки в FB2 лежат в конце файла в тегах в виде текста + // base64. Складываем их в словарь «id → data-URL»; такой URL браузер + // может показать в без всяких внешних файлов. private collectBinaries(doc: Document) { this.binaries.clear(); for (const bin of Array.from(doc.getElementsByTagName("binary"))) { const id = bin.getAttribute("id"); - if (!id) continue; + if (!id) continue; // без id на картинку нельзя сослаться — пропускаем const type = bin.getAttribute("content-type") || "image/jpeg"; - const data = (bin.textContent || "").replace(/\s+/g, ""); + const data = (bin.textContent || "").replace(/\s+/g, ""); // убираем переносы строк this.binaries.set(id, `data:${type};base64,${data}`); } } + // Верхний уровень отрисовки: титульная страница, затем все + // (основной текст и, отдельным блоком, сноски). private renderBook(doc: Document, root: HTMLElement) { const titleInfo = doc.querySelector("description > title-info"); this.collectToc = false; if (titleInfo) this.renderTitleInfo(titleInfo, root); for (const body of Array.from(doc.querySelectorAll("FictionBook > body"))) { + // — это сноски; их заголовки в оглавление не берём. const isNotes = body.getAttribute("name") === "notes"; this.collectToc = !isNotes; const bodyEl = root.createDiv({ cls: isNotes ? "fb2-body fb2-notes" : "fb2-body", }); - if (isNotes) bodyEl.createEl("hr"); + if (isNotes) bodyEl.createEl("hr"); // разделительная черта перед сносками this.renderBlockChildren(body, bodyEl, 1); } this.collectToc = false; - // One click handler for all internal links (notes, cross-references). + // Один обработчик кликов на всю книгу — для внутренних ссылок + // (сносок и перекрёстных ссылок): ищем элемент с нужным data-fb2-id + // и плавно прокручиваем к нему. root.addEventListener("click", (evt) => { const link = (evt.target as HTMLElement).closest("a[data-fb2-target]"); if (!link) return; - evt.preventDefault(); + evt.preventDefault(); // отменяем стандартный переход по ссылке const target = link.getAttribute("data-fb2-target"); const dest = root.querySelector( `[data-fb2-id="${CSS.escape(target ?? "")}"]` @@ -338,18 +464,23 @@ class Fb2View extends FileView { }); } + // Титульная страница: обложка, название, авторы, аннотация. private renderTitleInfo(info: Element, root: HTMLElement) { const header = root.createDiv({ cls: "fb2-title-page" }); const coverImage = info.querySelector("coverpage > image"); if (coverImage) this.renderImage(coverImage, header, "fb2-cover"); + // «?.» — безопасное обращение: если book-title отсутствует, вся + // цепочка вернёт undefined вместо ошибки. const title = info.querySelector("book-title")?.textContent?.trim(); if (title) { this.bookTitle = title; header.createEl("h1", { text: title, cls: "fb2-book-title" }); } + // Для каждого склеиваем имя-отчество-фамилию через пробел, + // пропуская отсутствующие части; пустых авторов отбрасываем. const authors = Array.from(info.querySelectorAll(":scope > author")) .map((a) => ["first-name", "middle-name", "last-name"] @@ -372,15 +503,20 @@ class Fb2View extends FileView { } } + // Обходит всех детей элемента и отрисовывает каждого как блок. private renderBlockChildren(el: Element, parent: HTMLElement, depth: number) { for (const child of Array.from(el.children)) { this.renderBlock(child, parent, depth); } } + // Сердце читалки: превращает один блочный тег FB2 в HTML. + // Метод рекурсивный — для вложенных тегов он вызывает сам себя, + // так дерево FB2 обходится целиком, на любую глубину. private renderBlock(el: Element, parent: HTMLElement, depth: number) { - const tag = el.localName; + const tag = el.localName; // имя тега без префиксов, например "section" + // Случай 1: тег-контейнер из таблицы BLOCK_CONTAINERS. const container = BLOCK_CONTAINERS[tag]; if (container) { const box = parent.createEl(container.tag, { cls: container.cls }); @@ -393,6 +529,7 @@ class Fb2View extends FileView { return; } + // Случай 2: тег-абзац из таблицы BLOCK_PARAGRAPHS. const paragraphCls = BLOCK_PARAGRAPHS[tag]; if (paragraphCls) { const p = parent.createEl("p", { cls: paragraphCls }); @@ -401,13 +538,18 @@ class Fb2View extends FileView { return; } + // Случай 3: особые теги, которым нужна своя логика. switch (tag) { case "title": { + // Заголовок главы. Уровень (h2, h3...) зависит от глубины + // вложенности секции; глубже h6 в HTML не бывает. const level = Math.min(depth + 1, 6); const heading = parent.createEl( `h${level}` as keyof HTMLElementTagNameMap, { cls: "fb2-title" } ); + // Заголовок в FB2 может состоять из нескольких

— + // показываем их с новой строки (через
). const tocText: string[] = []; for (const child of Array.from(el.children)) { if (child.localName !== "p") continue; @@ -416,18 +558,20 @@ class Fb2View extends FileView { const text = child.textContent?.trim(); if (text) tocText.push(text); } + // Попутно добавляем пункт в оглавление (кроме раздела сносок). if (this.collectToc) { this.tocItems.push({ text: tocText.join(" "), depth, el: heading }); } break; } case "empty-line": - parent.createDiv({ cls: "fb2-empty-line" }); + parent.createDiv({ cls: "fb2-empty-line" }); // пустой отступ break; case "image": this.renderImage(el, parent, "fb2-image-block"); break; case "table": { + // Таблица: переносим строки и ячейки / как есть. const table = parent.createEl("table", { cls: "fb2-table" }); for (const tr of Array.from(el.querySelectorAll("tr"))) { const rowEl = table.createEl("tr"); @@ -439,26 +583,32 @@ class Fb2View extends FileView { break; } default: - // Unknown container: recurse so nested known blocks still render. + // Незнакомый тег: не рисуем его сам, но обходим детей — + // вдруг внутри есть знакомые теги, которые можно показать. this.renderBlockChildren(el, parent, depth); } } + // Обходит всё содержимое элемента (и теги, и куски текста) + // и отрисовывает как строчные элементы. private renderInlineChildren(el: Element, parent: HTMLElement) { for (const node of Array.from(el.childNodes)) { this.renderInline(node, parent); } } + // Отрисовка строчного содержимого: текст, курсив, ссылки, сноски... private renderInline(node: Node, parent: HTMLElement) { + // Просто текст между тегами — добавляем как есть. if (node.nodeType === Node.TEXT_NODE) { parent.appendText(node.textContent ?? ""); return; } - if (node.nodeType !== Node.ELEMENT_NODE) return; + if (node.nodeType !== Node.ELEMENT_NODE) return; // комментарии и пр. — пропускаем const el = node as Element; const tag = el.localName; + // Простые теги из таблицы INLINE_TAGS: и т.п. const htmlTag = INLINE_TAGS[tag]; if (htmlTag) { this.renderInlineChildren(el, parent.createEl(htmlTag)); @@ -471,23 +621,30 @@ class Fb2View extends FileView { break; case "a": { const href = getHref(el) ?? ""; + // Сноска (type="note") оборачивается в , чтобы её номер + // отображался маленькой цифрой сверху. const isNote = el.getAttribute("type") === "note"; const host = isNote ? parent.createEl("sup") : parent; const anchor = host.createEl("a", { cls: "fb2-link" }); if (href.startsWith("#")) { + // Внутренняя ссылка (на сноску или главу): запоминаем цель + // в data-fb2-target — клики ловит обработчик в renderBook. anchor.setAttribute("data-fb2-target", href.slice(1)); anchor.setAttribute("href", "#"); } else { - anchor.setAttribute("href", href); + anchor.setAttribute("href", href); // обычная внешняя ссылка } this.renderInlineChildren(el, anchor); break; } default: + // Незнакомый строчный тег — показываем хотя бы его содержимое. this.renderInlineChildren(el, parent); } } + // Вставляет картинку: по ссылке "#id" находит data-URL + // в словаре binaries и создаёт элемент . private renderImage(el: Element, parent: HTMLElement, cls: string) { const href = getHref(el); if (!href || !href.startsWith("#")) return; @@ -501,10 +658,14 @@ class Fb2View extends FileView { } // --------------------------------------------------------------------------- -// The table-of-contents side panel +// Fb2TocView — боковая панель с оглавлением +// +// Наследуется от ItemView (вид без привязки к файлу). Панель ничего не +// вычисляет сама: она показывает список tocItems, который собрала читалка. // --------------------------------------------------------------------------- class Fb2TocView extends ItemView { + // Читалка, чьё оглавление показываем сейчас (null — никакой). private source: Fb2View | null = null; getViewType(): string { @@ -519,14 +680,17 @@ class Fb2TocView extends ItemView { return "list"; } + // Вызывается Obsidian, когда панель открывается. async onOpen(): Promise { this.render(); } + // Показывает ли панель оглавление именно этой читалки? sourceIs(view: Fb2View): boolean { return this.source === view; } + // Плагин вызывает это при смене активной книги; панель перерисовывается. setSource(view: Fb2View | null) { this.source = view; this.render(); @@ -545,13 +709,16 @@ class Fb2TocView extends ItemView { return; } + // Название книги сверху, затем — по строке на каждый заголовок. el.createDiv({ cls: "fb2-toc-book", text: this.source.getDisplayText() }); for (const item of this.source.tocItems) { const row = el.createDiv({ cls: "fb2-toc-item", text: item.text || "(untitled)", }); + // Отступ слева зависит от глубины — так видна вложенность глав. row.style.paddingLeft = `${(item.depth - 1) * 14 + 6}px`; + // Клик по пункту: показать вкладку с книгой и прокрутить к главе. row.addEventListener("click", () => { const src = this.source; if (!src) return; @@ -563,14 +730,27 @@ class Fb2TocView extends ItemView { } // --------------------------------------------------------------------------- -// The plugin: wires everything together, stores settings and positions +// Fb2ReaderPlugin — главный класс плагина +// +// «export default» делает класс видимым снаружи файла: именно его Obsidian +// находит и создаёт при включении плагина. Этот класс связывает всё вместе: +// регистрирует виды, хранит и сохраняет настройки и позиции чтения, +// управляет панелью оглавления. // --------------------------------------------------------------------------- export default class Fb2ReaderPlugin extends Plugin { + // Все данные плагина (настройки + позиции чтения). private data: Fb2Data = { positions: {}, settings: { ...DEFAULT_SETTINGS } }; + // Отложенное сохранение на диск: не чаще, чем раз в 2 секунды, + // чтобы не писать файл при каждом чихе. private saveDataDebounced = debounce(() => this.saveData(this.data), 2000, true); + // Вызывается Obsidian при включении плагина. Здесь — вся регистрация. async onload() { + // Загружаем сохранённые данные (data.json). «?? {}» — если данных + // ещё нет (первый запуск), берём пустой объект. Object.assign + // накладывает сохранённые настройки поверх настроек по умолчанию: + // так новые поля, появившиеся в обновлении плагина, получат значения. const stored = (await this.loadData()) ?? {}; this.data = { positions: stored.positions ?? {}, @@ -578,14 +758,17 @@ export default class Fb2ReaderPlugin extends Plugin { }; this.applySettings(); + // Сообщаем Obsidian, как создавать наши виды... this.registerView(VIEW_TYPE_FB2, (leaf) => new Fb2View(leaf, this)); this.registerView(VIEW_TYPE_TOC, (leaf) => new Fb2TocView(leaf)); + // ...и что файлы .fb2 и .zip должны открываться в нашей читалке. this.registerExtensions(["fb2", "zip"], VIEW_TYPE_FB2); this.addSettingTab(new Fb2SettingTab(this.app, this)); + // Кнопка на левой панели Obsidian — открывает настройки плагина. this.addRibbonIcon("book-open-text", "FB2 Reader settings", () => { - // "setting" is an undocumented part of the Obsidian API, so the - // App type has to be widened by hand. + // app.setting — недокументированная часть Obsidian API, поэтому + // её тип приходится дописывать вручную (см. GUIDE.md). const appSetting = ( this.app as App & { setting: { open(): void; openTabById(id: string): void }; @@ -595,12 +778,15 @@ export default class Fb2ReaderPlugin extends Plugin { appSetting.openTabById(this.manifest.id); }); + // Команда для палитры команд (Ctrl/Cmd+P): открыть оглавление. this.addCommand({ id: "open-toc", name: "Open table of contents", callback: () => this.activateTocLeaf(), }); + // При переключении вкладок: если активной стала читалка — + // показываем в панели её оглавление. this.registerEvent( this.app.workspace.on("active-leaf-change", (leaf) => { if (leaf?.view instanceof Fb2View) this.updateToc(leaf.view); @@ -608,6 +794,8 @@ export default class Fb2ReaderPlugin extends Plugin { ); } + // Вызывается при выключении плагина: сохраняем данные и убираем + // с все следы наших настроек (CSS-переменные и классы тем). onunload() { void this.saveData(this.data); const body = document.body; @@ -618,12 +806,17 @@ export default class Fb2ReaderPlugin extends Plugin { body.removeClass("fb2-theme-dark", "fb2-theme-light", "fb2-theme-sepia"); } - // --- settings --- + // --- Настройки --- + // «get» делает метод похожим на поле: снаружи пишут plugin.fb2Settings + // без скобок и получают текущие настройки. get fb2Settings(): Fb2Settings { return this.data.settings; } + // Применяет настройки к странице. Значения записываются в CSS-переменные + // на ; файл styles.css читает их и оформляет книгу. Так код + // и оформление общаются, не зная друг о друге лишнего. applySettings() { const s = this.data.settings; const body = document.body; @@ -631,6 +824,7 @@ export default class Fb2ReaderPlugin extends Plugin { else body.style.removeProperty("--fb2-font-family"); body.style.setProperty("--fb2-font-size", `${s.fontSize}px`); body.style.setProperty("--fb2-line-height", `${s.lineHeight}`); + // toggleClass(класс, условие): добавляет класс при true, снимает при false. body.toggleClass("fb2-theme-dark", s.theme === "dark"); body.toggleClass("fb2-theme-light", s.theme === "light"); body.toggleClass("fb2-theme-sepia", s.theme === "sepia"); @@ -638,12 +832,13 @@ export default class Fb2ReaderPlugin extends Plugin { else body.style.removeProperty("--fb2-text-color"); } + // Применить и (отложенно) сохранить — вызывается из вкладки настроек. saveSettings() { this.applySettings(); this.saveDataDebounced(); } - // --- reading positions --- + // --- Позиции чтения --- getPosition(path: string): ReadingPosition | undefined { return this.data.positions[path]; @@ -655,15 +850,19 @@ export default class Fb2ReaderPlugin extends Plugin { this.saveDataDebounced(); } + // Чтобы data.json не разрастался бесконечно, храним позиции только + // для 300 последних книг; самые старые записи удаляются. private prunePositions() { const entries = Object.entries(this.data.positions); if (entries.length <= 300) return; - entries.sort((a, b) => b[1].ts - a[1].ts); + entries.sort((a, b) => b[1].ts - a[1].ts); // сортируем по времени, новые сверху this.data.positions = Object.fromEntries(entries.slice(0, 300)); } - // --- table of contents --- + // --- Панель оглавления --- + // Читалка зовёт этот метод, когда открыла книгу: если панели оглавления + // ещё нет — создаём её в правой боковой панели, затем обновляем. onFb2Opened(view: Fb2View) { this.app.workspace.onLayoutReady(async () => { if (!this.app.workspace.getLeavesOfType(VIEW_TYPE_TOC).length) { @@ -674,12 +873,14 @@ export default class Fb2ReaderPlugin extends Plugin { }); } + // Показывает во всех панелях оглавления содержание указанной читалки. updateToc(view: Fb2View | null) { for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_TOC)) { if (leaf.view instanceof Fb2TocView) leaf.view.setSource(view); } } + // Когда книга закрывается — очищаем панели, показывавшие её оглавление. clearTocFor(view: Fb2View) { for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_TOC)) { if (leaf.view instanceof Fb2TocView && leaf.view.sourceIs(view)) { @@ -688,6 +889,8 @@ export default class Fb2ReaderPlugin extends Plugin { } } + // Обработчик команды "Open table of contents": находит (или создаёт) + // панель оглавления, показывает её и наполняет содержанием активной книги. private async activateTocLeaf() { let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_TOC)[0]; if (!leaf) { @@ -703,11 +906,18 @@ export default class Fb2ReaderPlugin extends Plugin { } // --------------------------------------------------------------------------- -// The settings tab +// Fb2SettingTab — вкладка настроек +// +// Наследуется от PluginSettingTab. Obsidian вызывает метод display() каждый +// раз, когда пользователь открывает настройки плагина. Каждый элемент +// интерфейса создаётся классом Setting: имя, описание и поле ввода. +// Общий приём: onChange поля меняет значение в plugin.fb2Settings +// и зовёт plugin.saveSettings() — настройка применяется сразу. // --------------------------------------------------------------------------- class Fb2SettingTab extends PluginSettingTab { private plugin: Fb2ReaderPlugin; + // Счётчик перерисовок — защита от «гонки» (см. комментарий в render). private renderToken = 0; constructor(app: App, plugin: Fb2ReaderPlugin) { @@ -716,10 +926,13 @@ class Fb2SettingTab extends PluginSettingTab { } display(): void { + // render — асинхронный (ждёт список шрифтов); «void» говорит: + // «запусти и не жди результата». void this.render(); } - // Adds a numeric text field that only saves values inside [min, max]. + // Помощник: числовое поле, принимающее только значения из [min, max]. + // Используется дважды — для размера шрифта и межстрочного интервала. private addNumberSetting( name: string, desc: string, @@ -739,6 +952,7 @@ class Fb2SettingTab extends PluginSettingTab { text.inputEl.step = step; text.setValue(String(getValue())).onChange((value) => { const n = Number(value); + // Не число или вне диапазона — просто не сохраняем. if (!Number.isFinite(n) || n < min || n > max) return; setValue(n); this.plugin.saveSettings(); @@ -749,12 +963,15 @@ class Fb2SettingTab extends PluginSettingTab { private async render(): Promise { const token = ++this.renderToken; const fonts = await getSystemFonts(); - // A newer render started while fonts were loading; let it win. + // Пока мы ждали список шрифтов, пользователь мог закрыть и снова + // открыть настройки — тогда запустился новый render. Если наш номер + // уже не последний, тихо выходим и даём победить более новому. if (token !== this.renderToken) return; const { containerEl } = this; containerEl.empty(); + // Тема оформления читалки. new Setting(containerEl) .setName("Theme") .setDesc("Color scheme for the reading area.") @@ -771,6 +988,9 @@ class Fb2SettingTab extends PluginSettingTab { }) ); + // Цвет текста: варианты из TEXT_COLORS. Если в настройках сохранён + // цвет не из списка (например, вписанный вручную в data.json), + // добавляем его отдельным пунктом, чтобы выбор не «слетал». new Setting(containerEl) .setName("Text color") .setDesc("Color of the main book text. Default follows the theme.") @@ -788,6 +1008,9 @@ class Fb2SettingTab extends PluginSettingTab { }); }); + // Шрифт: если удалось получить список системных шрифтов — даём + // выпадающий список; если нет (нет разрешения или старая система) — + // обычное текстовое поле для ввода названия вручную. const fontSetting = new Setting(containerEl).setName("Font"); if (fonts.length) { fontSetting.setDesc("Font used for book text.").addDropdown((dd) => { @@ -839,6 +1062,8 @@ class Fb2SettingTab extends PluginSettingTab { (n) => (this.plugin.fb2Settings.lineHeight = n) ); + // Кнопка сброса: возвращает настройки по умолчанию + // и перерисовывает вкладку, чтобы поля показали новые значения. new Setting(containerEl).addButton((btn) => btn.setButtonText("Reset to defaults").onClick(() => { Object.assign(this.plugin.fb2Settings, DEFAULT_SETTINGS);