From 84e9be614e0a3a83d07038e01f7c6e536508d9a9 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 16:15:20 +0300 Subject: [PATCH] feat(reader): implement full appearance customization panel with themes, fonts, margins, line-height, and localStorage persistence --- TODO.md | 32 ++++- app/static/css/style.css | 290 +++++++++++++++++++++++++++++++++++++-- app/static/index.html | 82 ++++++++++- app/static/js/app.js | 222 +++++++++++++++++++++++++++--- 4 files changed, 589 insertions(+), 37 deletions(-) diff --git a/TODO.md b/TODO.md index ed166cc..48028c5 100644 --- a/TODO.md +++ b/TODO.md @@ -98,7 +98,29 @@ --- -### 5. 🔌 Пул соединений HTTP (`httpx.AsyncClient` с Keep-Alive) +--- + +### 5. 🔤 Кастомизация оформления читалки (Типографика, темы, отступы, LocalStorage) +- [x] **Панель настроек оформления текста (`Aa`)**: + - Кнопка вызова настроек в тулбаре читалки: поповер на ПК и выдвижная шторка (Bottom Sheet) на смартфонах. +- [x] **Настройка размера шрифта и степпер**: + - Степпер `A-` / `A+` с индикатором точного размера (от 14px до 34px). +- [x] **Семейство шрифтов (Font Family)**: + - Переключение в 1 клик между `Sans` (системный), `Serif` (книжный с засечками), `Mono` (моноширинный). +- [x] **Межстрочный интервал (Line Height)**: + - Компактный (1.35), Оптимальный (1.65), Широкий (1.95). +- [x] **Поля и ширина колонки (Margins & Max Width)**: + - Узкие поля (940px), Стандарт (760px), Книжная колонка (600px). +- [x] **Цветовые темы читалки (Themes)**: + - 🌙 Тёмная (Dark Slate), 📜 Сепия (Тёплый пергамент), ☀️ Светлая (Чистый лист), 🖤 OLED (Абсолютный чёрный). +- [x] **Выравнивание текста (Text Alignment)**: + - По левому краю / По ширине (Justified). +- [x] **Автосохранение и персистентность**: + - Мгновенное реактивное применение стилей через CSS-переменные без перезагрузки и сохранение настроек в `localStorage`. + +--- + +### 6. 🔌 Пул соединений HTTP (`httpx.AsyncClient` с Keep-Alive) - [ ] В [`app/dictionary.py`](file:///home/ai-projects/services/study.qpid.pro/app/dictionary.py) создать синглтон-клиент `get_http_client()` с пулом соединений: ```python limits = httpx.Limits(max_keepalive_connections=10, max_connections=20) @@ -108,7 +130,7 @@ --- -### 6. 🗄️ Тюнинг SQLite PRAGMA и составных индексов +### 7. 🗄️ Тюнинг SQLite PRAGMA и составных индексов - [ ] В [`app/database.py`](file:///home/ai-projects/services/study.qpid.pro/app/database.py): - Вынести `PRAGMA journal_mode=WAL;` в `init_db()` (убрать избыточный вызов при каждом запросе). - Включить параметры производительности памяти Linux: @@ -121,13 +143,13 @@ --- -### 7. ✂️ Де-блоатинг внешних парсеров (YAGNI) +### 8. ✂️ Де-блоатинг внешних парсеров (YAGNI) - [ ] Удалить лишний сетевой опрос Datamuse (`fetch_datamuse`) — он дублирует определения и перегружает ответ нерелевантными рифмами. - [ ] Упростить хрупкий regex-парсинг разметки Wikitext, опираясь на стабильные REST JSON эндпоинты. --- -### 8. 📚 Локальный оффлайн-словарь (99%+ покрытия со словоформами, SQLite 10–20 МБ) +### 9. 📚 Локальный оффлайн-словарь (99%+ покрытия со словоформами, SQLite 10–20 МБ) - [ ] Создать в SQLite таблицу: ```sql CREATE TABLE IF NOT EXISTS offline_dictionary ( @@ -149,7 +171,7 @@ --- -### 9. 🧩 Модульность фронтенда на нативных ES-модулях (без Webpack/Vite) +### 10. 🧩 Модульность фронтенда на нативных ES-модулях (без Webpack/Vite) - [ ] Перевести [`app/static/js/app.js`](file:///home/ai-projects/services/study.qpid.pro/app/static/js/app.js) на нативный браузерный синтаксис ` + diff --git a/app/static/js/app.js b/app/static/js/app.js index 9d57358..966301e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -102,8 +102,18 @@ document.addEventListener('DOMContentLoaded', () => { const readerChapterSelect = document.getElementById('reader-chapter-select'); const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); const readerNextChBtn = document.getElementById('reader-next-ch-btn'); - const readerFontDecBtn = document.getElementById('reader-font-dec-btn'); - const readerFontIncBtn = document.getElementById('reader-font-inc-btn'); + const readerSettingsBtn = document.getElementById('reader-settings-btn'); + const readerSettingsPanel = document.getElementById('reader-settings-panel'); + const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn'); + const settingFontDecBtn = document.getElementById('setting-font-dec-btn'); + const settingFontIncBtn = document.getElementById('setting-font-inc-btn'); + const settingFontSizeVal = document.getElementById('setting-font-size-val'); + const readerFontFamilyGroup = document.getElementById('reader-font-family-group'); + const readerLineHeightGroup = document.getElementById('reader-line-height-group'); + const readerMarginsGroup = document.getElementById('reader-margins-group'); + const readerThemeGroup = document.getElementById('reader-theme-group'); + const readerAlignGroup = document.getElementById('reader-align-group'); + const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn'); const readerTrainBtn = document.getElementById('reader-train-btn'); const readerContentBody = document.getElementById('reader-content-body'); @@ -123,10 +133,18 @@ document.addEventListener('DOMContentLoaded', () => { const popupSavedBadge = document.getElementById('popup-saved-badge'); const popupAddContextBtn = document.getElementById('popup-add-context-btn'); - // Reader State + // Reader State & Appearance Settings + const DEFAULT_READER_SETTINGS = { + fontSize: 18, + fontFamily: 'serif', + lineHeight: 1.65, + marginWidth: 'normal', + theme: 'dark', + textAlign: 'left' + }; + let readerSettings = { ...DEFAULT_READER_SETTINGS }; let currentBook = null; let currentChapterIndex = 0; - let readerFontSize = 19; let isHighlightEnabled = false; let popupCurrentData = null; @@ -1989,6 +2007,7 @@ document.addEventListener('DOMContentLoaded', () => { bookshelfContainer.classList.add('hidden'); readerInterface.classList.remove('hidden'); + applyReaderSettings(); renderChapter(currentChapterIndex); } catch (e) { @@ -2826,23 +2845,192 @@ document.addEventListener('DOMContentLoaded', () => { }); } - if (readerFontIncBtn) { - readerFontIncBtn.addEventListener('click', () => { - if (readerFontSize < 28) { - readerFontSize += 2; - readerContentBody.style.fontSize = `${readerFontSize}px`; + function loadSavedReaderSettings() { + try { + const saved = localStorage.getItem('notes_reader_settings'); + if (saved) { + readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved)); + } + } catch (e) {} + } + + function saveReaderSettings() { + try { + localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings)); + } catch (e) {} + } + + function applyReaderSettings() { + if (!readerScrollArea) return; + + // 1. Font size + readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`); + if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; + + // 2. Line height + readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight); + + // 3. Theme + readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled'); + readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`); + + // 4. Font family + readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono'); + readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`); + + // 5. Margin width + readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide'); + readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`); + + // 6. Text align + readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify'); + readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`); + + updateReaderSettingsUI(); + } + + function updateReaderSettingsUI() { + if (!readerSettingsPanel) return; + + if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; + + if (readerFontFamilyGroup) { + readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily); + }); + } + + if (readerLineHeightGroup) { + readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05); + }); + } + + if (readerMarginsGroup) { + readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth); + }); + } + + if (readerThemeGroup) { + readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => { + btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme); + }); + } + + if (readerAlignGroup) { + readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign); + }); + } + } + + function initReaderSettingsPanel() { + loadSavedReaderSettings(); + applyReaderSettings(); + + if (readerSettingsBtn) { + readerSettingsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (readerSettingsPanel) { + const isHidden = readerSettingsPanel.classList.contains('hidden'); + readerSettingsPanel.classList.toggle('hidden', !isHidden); + if (isHidden) updateReaderSettingsUI(); + } + }); + } + + if (readerSettingsCloseBtn) { + readerSettingsCloseBtn.addEventListener('click', () => { + if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden'); + }); + } + + if (settingFontDecBtn) { + settingFontDecBtn.addEventListener('click', () => { + if (readerSettings.fontSize > 13) { + readerSettings.fontSize -= 1; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (settingFontIncBtn) { + settingFontIncBtn.addEventListener('click', () => { + if (readerSettings.fontSize < 34) { + readerSettings.fontSize += 1; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerFontFamilyGroup) { + readerFontFamilyGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.font) { + readerSettings.fontFamily = btn.dataset.font; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerLineHeightGroup) { + readerLineHeightGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.lh) { + readerSettings.lineHeight = parseFloat(btn.dataset.lh); + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerMarginsGroup) { + readerMarginsGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.margin) { + readerSettings.marginWidth = btn.dataset.margin; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerThemeGroup) { + readerThemeGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.theme-chip'); + if (btn && btn.dataset.theme) { + readerSettings.theme = btn.dataset.theme; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerAlignGroup) { + readerAlignGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.align) { + readerSettings.textAlign = btn.dataset.align; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + document.addEventListener('click', (e) => { + if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) { + if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) { + readerSettingsPanel.classList.add('hidden'); + } } }); } - if (readerFontDecBtn) { - readerFontDecBtn.addEventListener('click', () => { - if (readerFontSize > 14) { - readerFontSize -= 2; - readerContentBody.style.fontSize = `${readerFontSize}px`; - } - }); - } + initReaderSettingsPanel(); if (readerToggleHighlightBtn) { readerToggleHighlightBtn.addEventListener('click', () => {