Add detailed Russian comments and beginner's guide (GUIDE.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kvasonaft 2026-07-03 22:21:50 +03:00
parent 244b1e18b3
commit 9a2df1b09c
2 changed files with 657 additions and 73 deletions

359
GUIDE.md Normal file
View file

@ -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<string, number> // объект-словарь: { "иван": 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 абзац <p class="fb2-p">Привет</p>
parent.createDiv({ cls: "fb2-book" }) // создать <div class="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
<section>
<title><p>Глава первая</p></title>
<p>Все счастливые семьи похожи друг на друга...</p>
</section>
```
Браузер таких тегов не знает, поэтому каждый надо превратить в HTML-аналог. Большинство превращений тривиальны, и они описаны тремя таблицами:
- `BLOCK_CONTAINERS`: `section`, `poem`, `cite`... → обёртка (`div` или `blockquote`) с CSS-классом; содержимое обрабатывается дальше.
- `BLOCK_PARAGRAPHS`: `p`, `subtitle`, `v`, `text-author` → абзац `<p>` с CSS-классом.
- `INLINE_TAGS`: `emphasis``<em>` (курсив), `strong``<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 они лежат в тегах `<binary>` как текст base64; из них делается словарь «id → data-URL», и `<img>` показывает картинку прямо из этой строки, без внешних файлов.
3. **`renderBook`** рисует титульную страницу (`renderTitleInfo`: обложка, название, авторы, аннотация), затем каждое `<body>` книги. Второе `<body name="notes">` — это сноски; они оформляются отдельно и не попадают в оглавление. В конце вешается один обработчик кликов на всю книгу: клик по внутренней ссылке находит элемент-цель по `data-fb2-id` и плавно прокручивает к нему.
4. **`renderBlock`** — рекурсивный «переводчик» блочных тегов: сначала смотрит в таблицы (случаи 1 и 2), затем обрабатывает особые теги (`title`, `image`, `table`, `empty-line`), а незнакомые теги прозрачно «проходит насквозь», отрисовывая их содержимое. Рекурсия (метод вызывает сам себя для детей элемента) позволяет обойти дерево любой глубины. Параметр `depth` растёт при входе в каждую `<section>` — от него зависит уровень заголовка (`h2`, `h3`...). Попутно заголовки складываются в `tocItems` — этот список потом показывает панель оглавления.
5. **`renderInline`** — то же для строчного содержимого: текст добавляется как есть, простые теги берутся из таблицы `INLINE_TAGS`, ссылки `<a>` обрабатываются отдельно (сноски оборачиваются в `<sup>` — маленькая цифра сверху).
**Позиция чтения.** Хранить позицию в пикселях ненадёжно: смените шрифт — и всё съедет. Поэтому хранится **номер первого видимого блока текста**. При прокрутке (не чаще раза в 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-переменные** на элементе `<body>` (например, `--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`) записывает переменную на `<body>`, стиль её подхватывает. Поменяли размер в настройках — книга мгновенно перерисовалась, без участия кода отрисовки.
**Классы тем.** Выбор темы добавляет на `<body>` класс `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)).

View file

@ -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<string, ReadingPosition>;
positions: Record<string, ReadingPosition>; // путь к файлу → позиция чтения
settings: Fb2Settings;
}
// Настройки по умолчанию — используются при первом запуске
// и при нажатии кнопки "Reset to defaults".
const DEFAULT_SETTINGS: Fb2Settings = {
fontFamily: "",
fontSize: 17,
@ -53,6 +89,9 @@ const DEFAULT_SETTINGS: Fb2Settings = {
textColor: "",
};
// Готовые варианты цвета текста для выпадающего списка в настройках:
// «код цвета → подпись». Record<string, string> значит «объект, где
// и ключи, и значения — строки».
const TEXT_COLORS: Record<string, string> = {
"": "Default (theme)",
"#000000": "Black",
@ -67,51 +106,68 @@ const TEXT_COLORS: Record<string, string> = {
};
// ---------------------------------------------------------------------------
// FB2 tag → HTML mapping tables
// Таблицы соответствий «тег FB2 → элемент HTML»
//
// FB2 — это XML со своими тегами (<section>, <poem>, <emphasis>...).
// Браузер и Obsidian понимают только HTML, поэтому каждый тег FB2 надо
// «перевести». Большинство переводов тривиальны, и вместо длинной цепочки
// условий мы описываем их тремя таблицами. Чтобы узнать, как отображается
// тот или иной тег, достаточно найти его строчку здесь.
// ---------------------------------------------------------------------------
// Block-level FB2 tags that become a plain container; children are rendered
// inside it as blocks. Only <section> increases the nesting depth.
// Блочные теги-«контейнеры»: превращаются в обёртку с CSS-классом,
// а их содержимое обрабатывается дальше как блоки.
// Только <section> увеличивает глубину вложенности (важно для заголовков).
const BLOCK_CONTAINERS: Record<string, { tag: "div" | "blockquote"; cls: string }> = {
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.
// Блочные теги, которые становятся абзацем <p> с указанным CSS-классом;
// их содержимое — уже строчный текст (курсив, ссылки и т.п.).
const BLOCK_PARAGRAPHS: Record<string, string> = {
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<string, keyof HTMLElementTagNameMap> = {
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<string, Uint8Array>;
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<string[]> {
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<string, string>();
private collectToc = false;
// «private» — поле доступно только внутри этого класса.
private plugin: Fb2ReaderPlugin; // ссылка на главный объект плагина
private bookTitle = ""; // название книги (для заголовка вкладки)
private binaries = new Map<string, string>(); // картинки книги: 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<void> {
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 не бросает исключение, а вставляет
// в документ специальный тег <parsererror> — проверяем его наличие.
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<void> {
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<HTMLElement>(
@ -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 лежат в конце файла в тегах <binary> в виде текста
// base64. Складываем их в словарь «id → data-URL»; такой URL браузер
// может показать в <img> без всяких внешних файлов.
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}`);
}
}
// Верхний уровень отрисовки: титульная страница, затем все <body>
// (основной текст и, отдельным блоком, сноски).
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"))) {
// <body name="notes"> — это сноски; их заголовки в оглавление не берём.
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" });
}
// Для каждого <author> склеиваем имя-отчество-фамилию через пробел,
// пропуская отсутствующие части; пустых авторов отбрасываем.
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 может состоять из нескольких <p> —
// показываем их с новой строки (через <br>).
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": {
// Таблица: переносим строки <tr> и ячейки <td>/<th> как есть.
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: <emphasis> → <em> и т.п.
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") оборачивается в <sup>, чтобы её номер
// отображался маленькой цифрой сверху.
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 и создаёт элемент <img>.
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<void> {
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 {
);
}
// Вызывается при выключении плагина: сохраняем данные и убираем
// с <body> все следы наших настроек (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-переменные
// на <body>; файл 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<void> {
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);