From b224dffbf0cfe383f2975961ce07c135621c905d Mon Sep 17 00:00:00 2001 From: Troy Scott <846218+troyscott@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:26:24 -0700 Subject: [PATCH] Add persistent reader settings and progress --- .github/workflows/ci.yml | 5 + package.json | 8 + .../reader/static/reader-state.js | 104 +++++++ src/study_reader/reader/static/reader.css | 159 +++++++++- src/study_reader/reader/static/reader.js | 289 ++++++++++++++++++ .../reader/templates/chapter.html | 75 ++++- tests/browser/reader-state.test.mjs | 75 +++++ tests/integration/test_reader_page.py | 5 + 8 files changed, 705 insertions(+), 15 deletions(-) create mode 100644 package.json create mode 100644 src/study_reader/reader/static/reader-state.js create mode 100644 tests/browser/reader-state.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c055f42..c71dd41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ jobs: - uses: astral-sh/setup-uv@v6 with: enable-cache: true + - uses: actions/setup-node@v4 + with: + node-version: 22 - name: Install Python run: uv python install 3.12 - name: Install dependencies @@ -27,3 +30,5 @@ jobs: run: uv run mypy src - name: Test application and content contracts run: uv run pytest + - name: Test browser state contracts + run: node --test tests/browser/*.test.mjs diff --git a/package.json b/package.json new file mode 100644 index 0000000..0a2d4aa --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "study-reader-browser", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/browser/*.test.mjs" + } +} diff --git a/src/study_reader/reader/static/reader-state.js b/src/study_reader/reader/static/reader-state.js new file mode 100644 index 0000000..f302ce2 --- /dev/null +++ b/src/study_reader/reader/static/reader-state.js @@ -0,0 +1,104 @@ +export const DEFAULT_SETTINGS = Object.freeze({ + theme: "sepia", + font: "serif", + textSize: 17, + lineHeight: 1.78, + pageMargin: 48, + contentWidth: 68, +}); + +const THEMES = new Set(["white", "sepia", "grey", "dark"]); +const FONTS = new Set(["serif", "sans", "system"]); + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function numberOrDefault(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function normalizeSettings(candidate = {}) { + return { + theme: THEMES.has(candidate.theme) ? candidate.theme : DEFAULT_SETTINGS.theme, + font: FONTS.has(candidate.font) ? candidate.font : DEFAULT_SETTINGS.font, + textSize: clamp( + numberOrDefault(candidate.textSize, DEFAULT_SETTINGS.textSize), + 15, + 24, + ), + lineHeight: clamp( + numberOrDefault(candidate.lineHeight, DEFAULT_SETTINGS.lineHeight), + 1.4, + 2.1, + ), + pageMargin: clamp( + numberOrDefault(candidate.pageMargin, DEFAULT_SETTINGS.pageMargin), + 12, + 96, + ), + contentWidth: clamp( + numberOrDefault(candidate.contentWidth, DEFAULT_SETTINGS.contentWidth), + 42, + 82, + ), + }; +} + +export function resolveReadingTarget(blockIds, position) { + if (!blockIds.length || !position) { + return null; + } + + const stableIndex = blockIds.indexOf(position.blockId); + if (stableIndex >= 0) { + return { + blockId: blockIds[stableIndex], + blockIndex: stableIndex, + blockOffset: clamp(numberOrDefault(position.blockOffset, 0), 0, 1), + restoredBy: "block", + }; + } + + const percentage = clamp(numberOrDefault(position.percentage, 0), 0, 100); + const fallbackIndex = Math.min( + blockIds.length - 1, + Math.floor((percentage / 100) * blockIds.length), + ); + return { + blockId: blockIds[fallbackIndex], + blockIndex: fallbackIndex, + blockOffset: 0, + restoredBy: "percentage", + }; +} + +export function calculateChapterProgress(blockIds, position) { + if (!blockIds.length || !position) { + return 0; + } + + const blockIndex = blockIds.indexOf(position.blockId); + if (blockIndex < 0) { + return Math.round( + clamp(numberOrDefault(position.percentage, 0), 0, 100), + ); + } + + const blockOffset = clamp(numberOrDefault(position.blockOffset, 0), 0, 1); + return Math.round(((blockIndex + blockOffset) / blockIds.length) * 100); +} + +export function calculateBookProgress(chapterIds, progressByChapter) { + if (!chapterIds.length) { + return 0; + } + + const total = chapterIds.reduce( + (sum, chapterId) => + sum + clamp(numberOrDefault(progressByChapter[chapterId], 0), 0, 100), + 0, + ); + return Math.round(total / chapterIds.length); +} diff --git a/src/study_reader/reader/static/reader.css b/src/study_reader/reader/static/reader.css index a51bfad..691f532 100644 --- a/src/study_reader/reader/static/reader.css +++ b/src/study_reader/reader/static/reader.css @@ -8,12 +8,74 @@ --accent: #315b52; --accent-soft: #e1ece6; --warm: #a35c35; + --strong: #183c34; + --header-bg: rgba(255, 253, 248, .96); + --hover: rgba(255, 255, 255, .7); + --reader-font: Georgia, "Times New Roman", serif; + --text-size: 17px; + --line-height: 1.78; + --page-margin: 48px; + --content-width: 68ch; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; } +:root[data-theme="white"] { + --ink: #202320; + --muted: #626862; + --paper: #ffffff; + --canvas: #f1f4f2; + --line: #d8ded9; + --accent: #245c50; + --accent-soft: #e4f0eb; + --warm: #9a4f2d; + --strong: #17463b; + --header-bg: rgba(255, 255, 255, .96); +} + +:root[data-theme="grey"] { + --ink: #222625; + --muted: #5f6764; + --paper: #e9eceb; + --canvas: #dde2e0; + --line: #c8cfcc; + --accent: #245c50; + --accent-soft: #d4e3dd; + --warm: #914d2d; + --strong: #17463b; + --header-bg: rgba(233, 236, 235, .96); + --hover: rgba(255, 255, 255, .45); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #eeeae1; + --muted: #bbb5aa; + --paper: #171a19; + --canvas: #202523; + --line: #3d4541; + --accent: #9bcabb; + --accent-soft: #263d36; + --warm: #e1a071; + --strong: #b9ddcf; + --header-bg: rgba(23, 26, 25, .96); + --hover: rgba(255, 255, 255, .08); +} + * { box-sizing: border-box; } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + html { scroll-behavior: smooth; } body { @@ -39,7 +101,7 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off .skip-link:focus { transform: translateY(0); } .reading-progress { height: 3px; background: var(--line); } -.reading-progress span { display: block; width: var(--chapter-progress); height: 100%; background: var(--warm); } +.reading-progress span { display: block; width: 0; height: 100%; background: var(--warm); transition: width .2s ease-out; } .site-header { min-height: 4.5rem; @@ -49,7 +111,7 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off gap: 1rem; padding: .75rem clamp(1rem, 3vw, 2.5rem); border-bottom: 1px solid var(--line); - background: rgba(255, 253, 248, .96); + background: var(--header-bg); } .brand { display: flex; align-items: center; gap: .75rem; color: var(--ink); text-decoration: none; } @@ -57,6 +119,22 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off .brand strong, .brand small { display: block; } .brand strong { font-family: Georgia, "Times New Roman", serif; font-size: 1.05rem; } .brand small, .chapter-count { margin-top: .12rem; color: var(--muted); font-size: .76rem; } +.header-actions { display: flex; align-items: center; gap: .8rem; } +.progress-summary { color: var(--muted); font-size: .72rem; white-space: nowrap; } +.progress-summary > span[id] { color: var(--ink); font-weight: 750; } +.appearance-button, .dialog-close { + display: grid; + place-items: center; + min-width: 2.75rem; + min-height: 2.75rem; + border: 1px solid var(--line); + border-radius: .65rem; + background: var(--paper); + color: var(--ink); + cursor: pointer; + font: 700 .95rem Georgia, serif; +} +.appearance-button:hover, .dialog-close:hover { border-color: var(--accent); } .reader-layout { display: grid; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); min-height: calc(100vh - 4.7rem); } @@ -72,24 +150,26 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off .toc-domain ol { margin: .75rem 0 0 2.25rem; padding: 0; list-style: none; } .toc-domain li + li { margin-top: .2rem; } .toc-domain a { position: relative; display: block; padding: .55rem .7rem; border-radius: .5rem; color: var(--muted); font-size: .78rem; line-height: 1.35; text-decoration: none; } -.toc-domain a:hover { background: rgba(255,255,255,.7); color: var(--ink); } +.toc-domain a:hover { background: var(--hover); color: var(--ink); } .toc-domain a[aria-current="page"] { background: var(--paper); color: var(--accent); box-shadow: inset 3px 0 var(--warm); font-weight: 650; } .status-dot { display: inline-block; width: .4rem; height: .4rem; margin-left: .3rem; border: 1px solid #9d988d; border-radius: 50%; vertical-align: middle; } +.completion-mark { margin-left: .25rem; color: var(--accent); font-weight: 800; } +.toc-domain a.is-complete:not([aria-current="page"]) { color: var(--accent); } -.reading-pane { width: min(100%, 58rem); min-width: 0; margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) clamp(1.15rem, 6vw, 5rem) 5rem; } +.reading-pane { width: min(100%, calc(var(--content-width) + 2 * var(--page-margin))); min-width: 0; margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) var(--page-margin) 5rem; } .chapter-header { padding-bottom: 2rem; border-bottom: 1px solid var(--line); } .eyebrow { margin: 0 0 .65rem; color: var(--warm); font-size: .72rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } -.chapter-header h1 { max-width: 18ch; margin: 0; font: 600 clamp(2.25rem, 6vw, 4.25rem)/1.03 Georgia, "Times New Roman", serif; letter-spacing: -.035em; } -.chapter-deck { margin: 1rem 0 0; color: var(--muted); font: 1.05rem/1.5 Georgia, "Times New Roman", serif; } +.chapter-header h1 { max-width: 18ch; margin: 0; font: 600 clamp(2.25rem, 6vw, 4.25rem)/1.03 var(--reader-font); letter-spacing: -.035em; } +.chapter-deck { margin: 1rem 0 0; color: var(--muted); font: 1.05rem/1.5 var(--reader-font); } .objective-strip { display: flex; flex-wrap: wrap; gap: .55rem 1.25rem; margin-top: 1.5rem; color: var(--muted); font-size: .78rem; } .objective-strip span::before { content: ""; display: inline-block; width: .38rem; height: .38rem; margin: 0 .45rem .08rem 0; border-radius: 50%; background: var(--accent); } -.chapter-prose { padding-top: 2rem; font: 1.08rem/1.78 Georgia, "Times New Roman", serif; } +.chapter-prose { padding-top: 2rem; font-family: var(--reader-font); font-size: var(--text-size); line-height: var(--line-height); } .chapter-prose > h1:first-child { display: none; } -.chapter-prose h2 { margin: 3rem 0 .9rem; font: 600 1.65rem/1.2 Georgia, "Times New Roman", serif; letter-spacing: -.018em; scroll-margin-top: 1rem; } +.chapter-prose h2 { margin: 3rem 0 .9rem; font: 600 1.65rem/1.2 var(--reader-font); letter-spacing: -.018em; scroll-margin-top: 1rem; } .chapter-prose h3 { margin-top: 2rem; font-size: 1.2rem; } .chapter-prose p { margin: 0 0 1.25rem; } -.chapter-prose strong { color: #183c34; } +.chapter-prose strong { color: var(--strong); } .chapter-prose ul, .chapter-prose ol { margin: 0 0 1.5rem; padding-left: 1.4rem; } .chapter-prose li { padding-left: .35rem; } .chapter-prose li + li { margin-top: .45rem; } @@ -104,6 +184,22 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off .source-card p:not(.eyebrow) { max-width: 46ch; margin: .4rem 0 0; color: var(--muted); font-size: .88rem; line-height: 1.5; } .source-card > a { padding: .75rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; font-size: .8rem; font-weight: 700; text-decoration: none; } +.complete-button { + display: flex; + align-items: center; + gap: .55rem; + margin: 2rem 0 0 auto; + padding: .75rem 1rem; + border: 1px solid var(--line); + border-radius: .6rem; + background: var(--paper); + color: var(--accent); + cursor: pointer; + font: 700 .78rem Inter, ui-sans-serif, sans-serif; +} +.complete-button span { display: grid; place-items: center; width: 1.25rem; aspect-ratio: 1; border: 1px solid currentColor; border-radius: 50%; } +.complete-button.is-complete { background: var(--accent); color: var(--paper); border-color: var(--accent); } + .chapter-navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--line); } .chapter-navigation a { min-width: 0; padding: 1rem; border: 1px solid var(--line); border-radius: .65rem; color: var(--ink); text-decoration: none; } .chapter-navigation a:hover { border-color: var(--accent); } @@ -112,17 +208,58 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off .chapter-navigation span { overflow-wrap: anywhere; font: .9rem/1.35 Georgia, serif; } .chapter-navigation .next { text-align: right; } +.appearance-dialog { + position: fixed; + inset: 0; + width: min(92vw, 31rem); + max-height: min(86vh, 48rem); + margin: auto; + padding: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--paper); + color: var(--ink); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .24); +} +.appearance-heading { position: sticky; z-index: 2; top: 0; display: flex; align-items: center; justify-content: space-between; padding: 1.25rem 1.4rem; border-bottom: 1px solid var(--line); background: var(--paper); } +.appearance-heading h2 { margin: 0; font: 600 1.6rem var(--reader-font); } +.appearance-heading .eyebrow { margin-bottom: .25rem; } +.dialog-close { border: 0; font: 400 1.7rem/1 Inter, sans-serif; } +.appearance-controls { display: grid; gap: 1.35rem; padding: 1.4rem; } +.appearance-controls fieldset { margin: 0; padding: 0; border: 0; } +.appearance-controls legend, .control-row > span, .range-control > span { margin-bottom: .65rem; color: var(--muted); font-size: .76rem; font-weight: 700; } +.theme-options { display: grid; grid-template-columns: repeat(4, 1fr); gap: .5rem; } +.theme-choice { position: relative; display: block; min-width: 0; cursor: pointer; } +.theme-choice input { position: absolute; opacity: 0; } +.theme-choice span { display: grid; min-height: 3.2rem; place-items: center; border: 2px solid transparent; border-radius: .6rem; font-size: .7rem; font-weight: 700; } +.theme-choice input:checked + span { border-color: var(--warm); box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--warm); } +.theme-choice input:focus-visible + span { outline: 3px solid #d69c52; outline-offset: 3px; } +.theme-white span { background: #fff; color: #202320; } +.theme-sepia span { background: #fffdf8; color: #24231f; } +.theme-grey span { background: #e9eceb; color: #222625; } +.theme-dark span { background: #171a19; color: #eeeae1; } +.control-row { display: grid; } +.control-row select { min-height: 2.75rem; padding: .6rem .75rem; border: 1px solid var(--line); border-radius: .5rem; background: var(--canvas); color: var(--ink); font: inherit; } +.range-control { display: grid; gap: .2rem; } +.range-control > span { display: flex; justify-content: space-between; } +.range-control output { color: var(--ink); font-variant-numeric: tabular-nums; } +.range-control input { width: 100%; accent-color: var(--accent); } +.secondary-button { min-height: 2.75rem; border: 1px solid var(--line); border-radius: .5rem; background: var(--canvas); color: var(--ink); cursor: pointer; font-weight: 700; } + @media (max-width: 767px) { .site-header { position: sticky; z-index: 10; top: 0; min-height: 4.1rem; padding: .65rem 1rem; } .brand small { display: none; } - .chapter-count { font-size: .7rem; } + .chapter-count { display: none; } + .header-actions { gap: .55rem; } + .progress-label { display: none; } .reader-layout { display: block; } .toc-shell { border-right: 0; border-bottom: 1px solid var(--line); } .toc-details { position: static; max-height: none; padding: 0; overflow: visible; } .toc-details > summary { display: flex; min-height: 3rem; align-items: center; justify-content: space-between; padding: .75rem 1rem; cursor: pointer; color: var(--accent); font-size: .8rem; font-weight: 750; } .toc-details[open] > summary { border-bottom: 1px solid var(--line); } .toc-details nav { max-height: 65vh; padding: 1.1rem 1rem 2rem; overflow-y: auto; } - .reading-pane { padding: 2.5rem 1.15rem 4rem; } + .reading-pane { width: 100%; padding: 2.5rem clamp(.75rem, var(--page-margin), 4rem) 4rem; } .chapter-header h1 { font-size: clamp(2.25rem, 12vw, 3.35rem); } .chapter-prose { font-size: 1.04rem; line-height: 1.72; } .chapter-prose h2 { margin-top: 2.5rem; } diff --git a/src/study_reader/reader/static/reader.js b/src/study_reader/reader/static/reader.js index fc5a773..e7aaf98 100644 --- a/src/study_reader/reader/static/reader.js +++ b/src/study_reader/reader/static/reader.js @@ -1,9 +1,298 @@ +import { + DEFAULT_SETTINGS, + calculateBookProgress, + calculateChapterProgress, + normalizeSettings, + resolveReadingTarget, +} from "./reader-state.js"; + +const SETTINGS_KEY = "study-reader:v1:appearance"; +const body = document.body; +const bookId = body.dataset.bookId; +const chapterId = body.dataset.chapterId; +const bookStateKey = `study-reader:v1:book:${bookId}`; +const chapterLinks = [ + ...document.querySelectorAll(".toc-domain a[data-chapter-id]"), +]; +const chapterIds = chapterLinks.map((link) => link.dataset.chapterId); +const article = document.querySelector(".chapter-prose"); +const readableBlocks = [...article.querySelectorAll(":scope > [id]")].filter( + (block) => getComputedStyle(block).display !== "none", +); +const blockIds = readableBlocks.map((block) => block.id); const tableOfContents = document.querySelector(".toc-details"); const desktopReader = window.matchMedia("(min-width: 768px)"); +const appearanceDialog = document.querySelector("#appearance-dialog"); +const appearanceButton = document.querySelector("#appearance-button"); +const completeButton = document.querySelector("#chapter-complete-button"); +const progressBar = document.querySelector("#reading-progress-bar"); +const chapterProgressValue = document.querySelector("#chapter-progress-value"); +const bookProgressValue = document.querySelector("#book-progress-value"); + +const fontFamilies = { + serif: 'Georgia, "Times New Roman", serif', + sans: 'Inter, ui-sans-serif, "Segoe UI", sans-serif', + system: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', +}; + +const controls = { + font: document.querySelector("#font-control"), + textSize: document.querySelector("#text-size-control"), + lineHeight: document.querySelector("#line-height-control"), + pageMargin: document.querySelector("#page-margin-control"), + contentWidth: document.querySelector("#content-width-control"), +}; + +const outputs = { + textSize: document.querySelector("#text-size-output"), + lineHeight: document.querySelector("#line-height-output"), + pageMargin: document.querySelector("#page-margin-output"), + contentWidth: document.querySelector("#content-width-output"), +}; + +function loadJson(key, fallback) { + try { + return JSON.parse(localStorage.getItem(key)) ?? fallback; + } catch { + return fallback; + } +} + +function emptyBookState() { + return { positions: {}, progress: {}, completed: [] }; +} + +let settings = normalizeSettings(loadJson(SETTINGS_KEY, DEFAULT_SETTINGS)); +let bookState = { ...emptyBookState(), ...loadJson(bookStateKey, emptyBookState()) }; +let appearanceAnchor = null; +bookState.positions ??= {}; +bookState.progress ??= {}; +bookState.completed ??= []; +for (const completedChapter of bookState.completed) { + bookState.progress[completedChapter] = 100; +} + +function saveBookState() { + localStorage.setItem(bookStateKey, JSON.stringify(bookState)); +} + +function applySettings(nextSettings) { + settings = normalizeSettings(nextSettings); + document.documentElement.dataset.theme = settings.theme; + document.documentElement.style.setProperty( + "--reader-font", + fontFamilies[settings.font], + ); + document.documentElement.style.setProperty("--text-size", `${settings.textSize}px`); + document.documentElement.style.setProperty("--line-height", settings.lineHeight); + document.documentElement.style.setProperty( + "--page-margin", + `${settings.pageMargin}px`, + ); + document.documentElement.style.setProperty( + "--content-width", + `${settings.contentWidth}ch`, + ); + document.querySelector('meta[name="theme-color"]').content = getComputedStyle( + document.documentElement, + ).getPropertyValue("--paper"); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +function synchronizeControls() { + document.querySelector(`input[name="theme"][value="${settings.theme}"]`).checked = + true; + controls.font.value = settings.font; + for (const key of ["textSize", "lineHeight", "pageMargin", "contentWidth"]) { + controls[key].value = settings[key]; + } + outputs.textSize.value = `${settings.textSize}px`; + outputs.lineHeight.value = settings.lineHeight.toFixed(2); + outputs.pageMargin.value = `${settings.pageMargin}px`; + outputs.contentWidth.value = `${settings.contentWidth}ch`; +} + +function settingsFromControls() { + return normalizeSettings({ + theme: document.querySelector('input[name="theme"]:checked').value, + font: controls.font.value, + textSize: controls.textSize.value, + lineHeight: controls.lineHeight.value, + pageMargin: controls.pageMargin.value, + contentWidth: controls.contentWidth.value, + }); +} function synchronizeTableOfContents(event) { tableOfContents.open = event.matches; } +function capturePosition() { + if (!readableBlocks.length) { + return null; + } + const readingLine = document.querySelector(".site-header").getBoundingClientRect().height + 16; + let selectedBlock = readableBlocks[0]; + for (const block of readableBlocks) { + if (block.getBoundingClientRect().top <= readingLine) { + selectedBlock = block; + } else { + break; + } + } + + const rectangle = selectedBlock.getBoundingClientRect(); + const blockOffset = Math.min( + 1, + Math.max(0, (readingLine - rectangle.top) / Math.max(rectangle.height, 1)), + ); + const maximumScroll = Math.max( + 1, + document.documentElement.scrollHeight - window.innerHeight, + ); + return { + blockId: selectedBlock.id, + blockOffset, + percentage: Math.round((window.scrollY / maximumScroll) * 100), + }; +} + +function restorePosition(position) { + const target = resolveReadingTarget(blockIds, position); + if (!target) { + return; + } + const block = document.getElementById(target.blockId); + const headerHeight = document.querySelector(".site-header").getBoundingClientRect().height; + const top = + window.scrollY + + block.getBoundingClientRect().top + + block.getBoundingClientRect().height * target.blockOffset - + headerHeight - + 14; + window.scrollTo({ top: Math.max(0, top), behavior: "instant" }); +} + +function currentChapterProgress(position = capturePosition()) { + if (bookState.completed.includes(chapterId)) { + return 100; + } + return calculateChapterProgress(blockIds, position); +} + +function renderCompletionState() { + const completed = new Set(bookState.completed); + for (const link of chapterLinks) { + const isComplete = completed.has(link.dataset.chapterId); + link.classList.toggle("is-complete", isComplete); + link.querySelector(".completion-mark").hidden = !isComplete; + } + const chapterComplete = completed.has(chapterId); + completeButton.classList.toggle("is-complete", chapterComplete); + completeButton.setAttribute("aria-pressed", String(chapterComplete)); + completeButton.lastChild.textContent = chapterComplete + ? " Marked complete" + : " Mark chapter complete"; +} + +function renderProgress(position = capturePosition()) { + const chapterProgress = currentChapterProgress(position); + bookState.progress[chapterId] = Math.max( + Number(bookState.progress[chapterId] ?? 0), + chapterProgress, + ); + const bookProgress = calculateBookProgress(chapterIds, bookState.progress); + chapterProgressValue.textContent = `${chapterProgress}%`; + bookProgressValue.textContent = `${bookProgress}%`; + progressBar.style.width = `${chapterProgress}%`; + document.title = `${chapterProgress}% · ${document.querySelector(".chapter-header h1").textContent} · DP-700 Study Reader`; + saveBookState(); +} + +function saveCurrentPosition() { + const position = capturePosition(); + if (!position) { + return; + } + bookState.positions[chapterId] = position; + renderProgress(position); +} + +let scrollFrame = null; +window.addEventListener( + "scroll", + () => { + if (scrollFrame !== null) { + return; + } + scrollFrame = requestAnimationFrame(() => { + saveCurrentPosition(); + scrollFrame = null; + }); + }, + { passive: true }, +); +window.addEventListener("pagehide", saveCurrentPosition); + +appearanceButton.addEventListener("click", () => { + appearanceAnchor = capturePosition(); + appearanceDialog.hidden = false; +}); +appearanceDialog.addEventListener("input", () => { + applySettings(settingsFromControls()); + synchronizeControls(); + requestAnimationFrame(() => { + restorePosition(appearanceAnchor); + renderProgress(appearanceAnchor); + }); +}); +document.querySelector("#appearance-reset").addEventListener("click", () => { + applySettings(DEFAULT_SETTINGS); + synchronizeControls(); + requestAnimationFrame(() => { + restorePosition(appearanceAnchor); + renderProgress(appearanceAnchor); + }); +}); +function closeAppearanceDialog() { + const anchor = appearanceAnchor; + appearanceDialog.hidden = true; + requestAnimationFrame(() => { + restorePosition(anchor); + bookState.positions[chapterId] = anchor; + renderProgress(anchor); + appearanceAnchor = null; + }); +} +appearanceDialog + .querySelector(".dialog-close") + .addEventListener("click", closeAppearanceDialog); +window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !appearanceDialog.hidden) { + closeAppearanceDialog(); + } +}); + +completeButton.addEventListener("click", () => { + const completed = new Set(bookState.completed); + if (completed.has(chapterId)) { + completed.delete(chapterId); + delete bookState.progress[chapterId]; + } else { + completed.add(chapterId); + bookState.progress[chapterId] = 100; + } + bookState.completed = [...completed]; + renderCompletionState(); + renderProgress(); +}); + +applySettings(settings); +synchronizeControls(); synchronizeTableOfContents(desktopReader); desktopReader.addEventListener("change", synchronizeTableOfContents); +renderCompletionState(); +requestAnimationFrame(() => { + restorePosition(bookState.positions[chapterId]); + renderProgress(bookState.positions[chapterId]); +}); diff --git a/src/study_reader/reader/templates/chapter.html b/src/study_reader/reader/templates/chapter.html index 3abd8be..393c93f 100644 --- a/src/study_reader/reader/templates/chapter.html +++ b/src/study_reader/reader/templates/chapter.html @@ -7,12 +7,12 @@ {{ chapter.title }} · {{ book.exam_code }} Study Reader - + - + + +