mirror of
https://github.com/kvasonaft/fb2-reader.git
synced 2026-07-22 08:31:14 +00:00
Add detailed Russian comments and beginner's guide (GUIDE.md)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
244b1e18b3
commit
9a2df1b09c
2 changed files with 657 additions and 73 deletions
359
GUIDE.md
Normal file
359
GUIDE.md
Normal 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)).
|
||||
371
src/main.ts
371
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<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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue