JavaScript 776 lines
// ==========================================================================
// App State & DOM References
// ==========================================================================
let state = {
chapters: [],
currentChapterIndex: 0,
themeMode: 'auto', // 'auto' | 'light' | 'dark' — what the user picked
theme: 'dark', // 'dark' | 'light' — resolved theme actually applied
layout: 'split', // 'split' | 'reader' | 'editor'
flashEditor: null,
zigEditor: null,
editorLoaded: false,
backend: true // false on static hosting (GitHub Pages) — no transpile API
};
const dom = {
chaptersList: document.getElementById('chapters-list'),
chapterContent: document.getElementById('chapter-content'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
themeToggle: document.getElementById('theme-toggle'),
layoutToggle: document.getElementById('layout-toggle'),
transpileBtn: document.getElementById('transpile-btn'),
clearTerminal: document.getElementById('clear-terminal'),
terminalBody: document.getElementById('terminal-body'),
terminalStatus: document.getElementById('terminal-status'),
workspacePane: document.querySelector('.workspace-pane'),
playgroundPanel: document.getElementById('playground-panel'),
contentPanel: document.getElementById('content-panel'),
tabButtons: document.querySelectorAll('.tab-btn'),
tabContents: document.querySelectorAll('.tab-content'),
flashEditorContainer: document.getElementById('editor-container'),
zigEditorContainer: document.getElementById('output-container'),
sidebar: document.getElementById('sidebar'),
sidebarToggle: document.getElementById('sidebar-toggle'),
sidebarBackdrop: document.getElementById('sidebar-backdrop'),
terminalDrawer: document.getElementById('terminal-drawer'),
terminalHeader: document.getElementById('terminal-header')
};
// Narrow screens get drawer navigation + touch-friendly editor defaults.
const mobileQuery = window.matchMedia('(max-width: 992px)');
const phoneQuery = window.matchMedia('(max-width: 600px)');
// ==========================================================================
// Off-canvas Sidebar (narrow screens)
// ==========================================================================
function setSidebarOpen(open) {
dom.sidebar.classList.toggle('open', open);
dom.sidebarBackdrop.classList.toggle('visible', open);
dom.sidebarToggle.setAttribute('aria-expanded', String(open));
}
function initSidebarDrawer() {
dom.sidebarToggle.addEventListener('click', () => {
setSidebarOpen(!dom.sidebar.classList.contains('open'));
});
dom.sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
// Leaving the narrow layout: drop the drawer state so the static sidebar shows normally.
mobileQuery.addEventListener('change', (e) => {
if (!e.matches) setSidebarOpen(false);
});
}
// ==========================================================================
// Collapsible Terminal Drawer
// ==========================================================================
function setTerminalCollapsed(collapsed) {
dom.terminalDrawer.classList.toggle('collapsed', collapsed);
// Monaco shares the column with the drawer — re-measure after the height transition.
setTimeout(() => {
if (state.flashEditor) state.flashEditor.layout();
if (state.zigEditor) state.zigEditor.layout();
}, 280);
}
function initTerminalDrawer() {
dom.terminalHeader.addEventListener('click', (e) => {
if (e.target.closest('#clear-terminal')) return; // clear button keeps its own action
setTerminalCollapsed(!dom.terminalDrawer.classList.contains('collapsed'));
});
// Phones: start collapsed so the editor gets the vertical space.
if (phoneQuery.matches) setTerminalCollapsed(true);
}
// ==========================================================================
// Theme and Layout Controls
// ==========================================================================
function initThemeAndLayout() {
// Theme: 'auto' follows the OS; 'light'/'dark' are manual overrides. Default 'auto'.
setThemeMode(localStorage.getItem('theme') || 'auto');
// Cycle auto -> light -> dark -> auto on each click.
dom.themeToggle.addEventListener('click', () => {
const cycle = ['auto', 'light', 'dark'];
const next = cycle[(cycle.indexOf(state.themeMode) + 1) % cycle.length];
setThemeMode(next);
});
// While in 'auto', live-follow the OS theme.
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (state.themeMode === 'auto') applyResolvedTheme(getSystemTheme());
});
}
// Layout
const savedLayout = localStorage.getItem('layout') || 'split';
setLayout(savedLayout);
dom.layoutToggle.addEventListener('click', () => {
const layoutModes = ['split', 'reader', 'editor'];
const nextIndex = (layoutModes.indexOf(state.layout) + 1) % layoutModes.length;
setLayout(layoutModes[nextIndex]);
});
// Tab buttons for Editor/Zig Output switcher
dom.tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.getAttribute('data-tab');
switchTab(targetTab);
});
});
}
function getSystemTheme() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
// Set the user's chosen mode, persist it, update the button icon, and apply the result.
function setThemeMode(mode) {
state.themeMode = mode;
localStorage.setItem('theme', mode);
dom.themeToggle.dataset.mode = mode;
dom.themeToggle.setAttribute('aria-label', `Theme: ${mode} (click to cycle auto/light/dark)`);
dom.themeToggle.title = `Theme: ${mode}`;
applyResolvedTheme(mode === 'auto' ? getSystemTheme() : mode);
}
// Apply a concrete 'dark'/'light' theme to the body and Monaco — no persistence.
function applyResolvedTheme(theme) {
state.theme = theme;
if (theme === 'dark') {
document.body.classList.remove('light-theme');
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
document.body.classList.add('light-theme');
}
// Update Monaco editor themes if loaded
if (state.editorLoaded && window.monaco) {
const monacoTheme = theme === 'dark' ? 'atomo-one-dark' : 'atomo-one-light';
monaco.editor.setTheme(monacoTheme);
// Re-colourise the reader's static blocks so they track the new theme.
highlightFlashBlocks(dom.chapterContent);
}
}
function setLayout(layout) {
state.layout = layout;
localStorage.setItem('layout', layout);
// Reset layout classes
dom.workspacePane.classList.remove('reader-only', 'editor-only');
if (layout === 'reader') {
dom.workspacePane.classList.add('reader-only');
} else if (layout === 'editor') {
dom.workspacePane.classList.add('editor-only');
}
// Trigger monaco layout adjustment
setTimeout(() => {
if (state.flashEditor) state.flashEditor.layout();
if (state.zigEditor) state.zigEditor.layout();
}, 250);
}
function switchTab(tabName) {
dom.tabButtons.forEach(btn => {
if (btn.getAttribute('data-tab') === tabName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
dom.tabContents.forEach(content => {
if (content.id === `tab-${tabName}`) {
content.classList.add('active');
} else {
content.classList.remove('active');
}
});
// Trigger editor layout adjustments
setTimeout(() => {
if (tabName === 'editor' && state.flashEditor) state.flashEditor.layout();
if (tabName === 'output' && state.zigEditor) state.zigEditor.layout();
}, 50);
}
// ==========================================================================
// Monaco Editor Integration & Custom Highlight Definition
// ==========================================================================
function initMonaco() {
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.39.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
// 1. Register a new custom language for Flash
monaco.languages.register({ id: 'flash' });
// 2. Define tokens for the Flash language
monaco.languages.setMonarchTokensProvider('flash', {
keywords: [
'use', 'link', 'fn', 'const', 'var', 'export', 'noreturn', 'orelse',
'try', 'catch', 'defer', 'errdefer', 'struct', 'enum', 'union',
'if', 'else', 'while', 'for', 'in', 'pub', 'as', 'break', 'continue', 'undefined'
],
typeKeywords: [
'u8', 'u16', 'u32', 'u64', 'i8', 'i16', 'i32', 'i64', 'usize', 'isize',
'cstr', 'argv', 'bool', 'void', 'f32', 'f64'
],
operators: [
'=', '+=', '-=', '*=', '/=', '%=', '==', '!=', '<', '<=', '>', '>=',
'&&', '||', '!', '&', '|', '^', '<<', '>>', '->', ':=', ':', '::', '.'
],
symbols: /[=><!~?:&|+\-*\/\^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
tokenizer: {
root: [
// Identifiers and keywords
[/[a-zA-Z_]\w*/, {
cases: {
'@keywords': 'keyword',
'@typeKeywords': 'type',
'@default': 'identifier'
}
}],
// Comments
[/\/\/.*$/, 'comment'],
// Numbers
[/\d+/, 'number'],
// Strings
[/"([^"\\]|\\.)*"/, 'string'],
// Operators and symbols
[/@symbols/, {
cases: {
'@operators': 'operator',
'@default': ''
}
}],
// Bracket matches
[/[{}()\[\]]/, '@brackets']
]
}
});
// 3. Define custom editor themes
monaco.editor.defineTheme('atomo-one-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'c678dd', fontStyle: 'bold' },
{ token: 'type', foreground: 'e5c07b' },
{ token: 'comment', foreground: '5c6370', fontStyle: 'italic' },
{ token: 'string', foreground: '98c379' },
{ token: 'number', foreground: 'd19a66' },
{ token: 'operator', foreground: '56b6c2' },
{ token: 'identifier', foreground: 'abb2bf' },
{ token: 'delimiter', foreground: 'abb2bf' }
],
colors: {
'editor.background': '#282c34',
'editor.foreground': '#abb2bf',
'editor.lineHighlightBackground': '#2c313c',
'editorCursor.foreground': '#528bff',
'editor.selectionBackground': '#3e4451',
'editorLineNumber.foreground': '#4b5263',
'editorLineNumber.activeForeground': '#c8ccd4'
}
});
monaco.editor.defineTheme('atomo-one-light', {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'a626a4', fontStyle: 'bold' },
{ token: 'type', foreground: 'c18401' },
{ token: 'comment', foreground: 'a0a1a7', fontStyle: 'italic' },
{ token: 'string', foreground: '50a14f' },
{ token: 'number', foreground: '986801' },
{ token: 'operator', foreground: '0184bc' },
{ token: 'identifier', foreground: '383a42' },
{ token: 'delimiter', foreground: '383a42' }
],
colors: {
'editor.background': '#fafafa',
'editor.foreground': '#383a42',
'editor.lineHighlightBackground': '#f0f0f0',
'editorCursor.foreground': '#526fff',
'editor.selectionBackground': '#e1e1e1',
'editorLineNumber.foreground': '#9d9d9f',
'editorLineNumber.activeForeground': '#383a42'
}
});
// 4. Create Flash editor (editable)
state.flashEditor = monaco.editor.create(dom.flashEditorContainer, {
value: `// Select a chapter on the left to load examples\n// Or write your own Flash code here!\n\nuse flibc\n\nexport fn main(_ usize, _ argv) noreturn {\n msg := "Hello World from Flash!\\n"\n _ = flibc.sys.write_fd(1, msg.ptr, msg.len)\n flibc.exit()\n}`,
language: 'flash',
theme: state.theme === 'dark' ? 'atomo-one-dark' : 'atomo-one-light',
automaticLayout: true,
fontFamily: 'JetBrains Mono, Fira Code, monospace',
// ≥16px on phones, or iOS auto-zooms the page when the editor gets focus
fontSize: phoneQuery.matches ? 16 : 13,
lineHeight: phoneQuery.matches ? 24 : 20,
minimap: { enabled: false },
scrollbar: {
vertical: 'auto',
horizontal: 'auto'
}
});
// 5. Create Zig editor (read-only)
state.zigEditor = monaco.editor.create(dom.zigEditorContainer, {
value: `// Click 'Transpile' to see the lowered Zig output`,
language: 'rust', // Monaco doesn't have Zig built-in by default, Rust/C highlight matches Zig syntax tags fairly closely
theme: state.theme === 'dark' ? 'atomo-one-dark' : 'atomo-one-light',
readOnly: true,
automaticLayout: true,
fontFamily: 'JetBrains Mono, Fira Code, monospace',
fontSize: phoneQuery.matches ? 16 : 13,
lineHeight: phoneQuery.matches ? 24 : 20,
minimap: { enabled: false }
});
state.editorLoaded = true;
console.log("Monaco Editors initialized.");
// The first chapter may have rendered before Monaco was ready — colour
// its example blocks now that the Flash grammar exists.
highlightFlashBlocks(dom.chapterContent);
});
}
// ==========================================================================
// Chapters Loading & Navigation
// ==========================================================================
async function loadChapters() {
try {
// chapters.json is a plain static file (relative path), so the chapter
// list works identically on the dev server and on static hosting.
const response = await fetch('chapters.json');
state.chapters = await response.json();
renderChaptersSidebar();
// Load initial chapter based on URL hash or default to first
const hash = window.location.hash.substring(1);
const activeChapterIndex = state.chapters.findIndex(c => c.id === hash);
if (activeChapterIndex !== -1) {
await selectChapter(activeChapterIndex);
} else {
await selectChapter(0);
}
} catch (err) {
console.error("Failed to fetch chapters metadata:", err);
dom.chaptersList.innerHTML = `<div class="nav-loading">Error loading chapters. Check backend status.</div>`;
}
}
function renderChaptersSidebar() {
dom.chaptersList.innerHTML = '';
state.chapters.forEach((chapter, index) => {
const button = document.createElement('button');
button.className = `nav-item ${index === state.currentChapterIndex ? 'active' : ''}`;
button.textContent = chapter.title;
button.addEventListener('click', () => {
selectChapter(index);
});
dom.chaptersList.appendChild(button);
});
}
async function selectChapter(index) {
if (index < 0 || index >= state.chapters.length) return;
state.currentChapterIndex = index;
const chapter = state.chapters[index];
// Update active class on sidebar items
const navItems = dom.chaptersList.querySelectorAll('.nav-item');
navItems.forEach((item, i) => {
if (i === index) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// Update Prev/Next buttons
dom.prevBtn.disabled = index === 0;
dom.nextBtn.disabled = index === state.chapters.length - 1;
// Narrow screens: picking a chapter closes the drawer so content is readable.
if (mobileQuery.matches) setSidebarOpen(false);
// Update URL hash
window.location.hash = chapter.id;
// Fetch and render markdown content
dom.chapterContent.innerHTML = `
<div class="content-loading">
<div class="spinner"></div>
<p>Loading chapter: ${chapter.title}...</p>
</div>
`;
try {
const response = await fetch(chapter.file);
if (!response.ok) throw new Error(`Status: ${response.status}`);
const markdown = await response.text();
// Configure marked options
marked.setOptions({
gfm: true,
breaks: true
});
// Set custom marked renderer to detect code blocks and output example blocks
const renderer = new marked.Renderer();
let codeBlockCounter = 0;
renderer.code = function(codeOrToken, language) {
let codeText = "";
let lang = "";
// Handle token parameter as object (marked v11+) or string (marked <v11)
if (codeOrToken && typeof codeOrToken === 'object') {
codeText = codeOrToken.text || "";
lang = codeOrToken.lang || "";
} else {
codeText = codeOrToken || "";
lang = language || "";
}
if (lang === 'flash' || lang === 'go') {
codeBlockCounter++;
const escapedCode = codeText
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
return `
<div class="example-card">
<div class="example-header">
<span class="example-title">Example ${codeBlockCounter}</span>
<button class="load-example-btn" data-code="${escapedCode}">
<i data-lucide="terminal" style="width:12px; height:12px;"></i> Try in Editor
</button>
</div>
<pre><code class="language-flash">${escapedCode}</code></pre>
</div>
`;
}
return `<pre><code class="language-${lang || 'text'}">${codeText}</code></pre>`;
};
renderer.blockquote = function(token) {
let text = "";
let htmlContent = "";
// Handle token parameter as object (marked v11+) or string (marked <v11)
if (token && typeof token === 'object') {
text = token.text || "";
htmlContent = this.parser.parse(token.tokens);
} else {
text = token || "";
htmlContent = token || "";
}
let quoteText = text.trim();
let alertType = null;
let titleText = 'NOTE';
let calloutClass = 'callout-note';
if (quoteText.includes('[!NOTE]')) {
alertType = 'note';
titleText = 'NOTE';
calloutClass = 'callout-note';
} else if (quoteText.includes('[!TIP]')) {
alertType = 'tip';
titleText = 'TIP';
calloutClass = 'callout-tip';
} else if (quoteText.includes('[!WARNING]')) {
alertType = 'warning';
titleText = 'WARNING';
calloutClass = 'callout-warning';
} else if (quoteText.includes('[!CAUTION]')) {
alertType = 'caution';
titleText = 'CAUTION';
calloutClass = 'callout-warning';
} else if (quoteText.includes('[!IMPORTANT]')) {
alertType = 'important';
titleText = 'IMPORTANT';
calloutClass = 'callout-note';
}
if (alertType) {
let innerHtml = htmlContent;
innerHtml = innerHtml.replace(/\[![A-Z]+\]/i, '');
innerHtml = innerHtml.replace(/<p>\s*(?:<br\s*\/?>)?\s*/i, '<p>');
return `
<div class="callout ${calloutClass}">
<div class="callout-title">${titleText}</div>
${innerHtml}
</div>
`;
}
return `<blockquote>${htmlContent}</blockquote>`;
};
dom.chapterContent.innerHTML = marked.parse(markdown, { renderer });
// Re-run lucide icons rendering
lucide.createIcons();
// Syntax-highlight the static Flash example blocks (no-op until Monaco
// is ready; initMonaco re-runs this for the first-loaded chapter).
highlightFlashBlocks(dom.chapterContent);
// Bind load events to the dynamically generated "Try in Editor" buttons
dom.chapterContent.querySelectorAll('.load-example-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const code = btn.getAttribute('data-code')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'");
loadCodeIntoEditor(code);
});
});
// Scroll reader panel back to top
dom.contentPanel.querySelector('.content-body').scrollTop = 0;
} catch (err) {
console.error("Failed to load chapter file:", err);
dom.chapterContent.innerHTML = `
<div class="callout callout-warning">
<div class="callout-title">Error Loading Content</div>
<p>Could not load the markdown documentation for this chapter. Make sure the file exists under <code>public/${chapter.file}</code>.</p>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 10px;">Details: ${err.message}</p>
</div>
`;
}
}
function loadCodeIntoEditor(code) {
if (state.flashEditor) {
state.flashEditor.setValue(code.trim());
logTerminal("Loaded example code into Flash Editor.", "info");
if (phoneQuery.matches) {
// Phones: a stacked split leaves the editor a sliver — go editor-only.
setLayout('editor');
} else if (state.layout === 'reader') {
// If the layout is 'reader-only', expand to 'split' so the user can see the editor
setLayout('split');
}
// Focus on the editor tab
switchTab('editor');
}
}
// ==========================================================================
// Static Code-Block Highlighting (reader pane)
// --------------------------------------------------------------------------
// Reuse the Flash grammar already registered with Monaco (Monarch tokenizer +
// the One Dark / One Light themes) to colourise the example <pre> blocks in
// the reader, so reader and editor highlight identically — no extra grammar
// or wasm in the browser. Re-run on theme switch so colours track the theme.
async function highlightFlashBlocks(root) {
if (!(state.editorLoaded && window.monaco)) return;
const scope = root || document;
const blocks = scope.querySelectorAll('pre code.language-flash, pre code.language-go');
for (const code of blocks) {
// Stash the original source the first time so re-highlights start from
// plain text, not already-colourised markup.
if (code.dataset.raw === undefined) code.dataset.raw = code.textContent;
try {
code.innerHTML = await monaco.editor.colorize(code.dataset.raw, 'flash', { tabSize: 4 });
} catch (err) {
console.warn('Flash highlight failed:', err);
}
}
}
// ==========================================================================
// Transpiler API Pipeline Integration
// ==========================================================================
// One-line notice shown whenever the transpile backend is absent (static build).
function logStaticNotice() {
logTerminal(
"Static build — reading chapters and loading examples into the editor work, " +
"but live transpilation needs the local dev server.\n" +
"Clone https://github.com/ajhahnde/Flash and run `npm start` in tutorial/.",
"warning"
);
}
async function runTranspilation() {
if (!state.flashEditor) return;
if (!state.backend) {
updateTerminalStatus('idle', 'Static');
logStaticNotice();
return;
}
const code = state.flashEditor.getValue();
updateTerminalStatus('transpiling', 'Transpiling...');
logTerminal("Invoking compiler backend...", "info");
try {
const response = await fetch('/api/transpile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const result = await response.json();
if (result.success) {
// Update output editor
if (state.zigEditor) {
state.zigEditor.setValue(result.output || '// Empty output.');
}
// Switch view to output
switchTab('output');
// Log status
updateTerminalStatus('success', 'Success');
let logMsg = "Transpilation succeeded.\n";
if (result.error) {
logMsg += `\nCompiler Warnings:\n${result.error}`;
logTerminal(logMsg, "warning");
} else {
logTerminal(logMsg + "No compiler errors or warnings.", "success");
}
} else {
// Transpilation failed (compiler returned non-zero code)
updateTerminalStatus('error', 'Compiler Error');
logTerminal(`Compiler Execution Failed:\n\n${result.error}`, "error");
// Focus on terminal drawer if hidden/small
console.warn("Compilation failed. Visual logs added to terminal drawer.");
}
} catch (err) {
console.error("Transpilation API call failed:", err);
updateTerminalStatus('error', 'Network Error');
logTerminal(`HTTP network error while communicating with backend: ${err.message}\nEnsure the local server is running on http://localhost:3000.`, "error");
}
}
// ==========================================================================
// Terminal Drawer Logger Utilities
// ==========================================================================
function logTerminal(message, type = 'info') {
dom.terminalBody.classList.remove('log-error', 'log-success', 'log-warning');
const timestamp = new Date().toLocaleTimeString();
const prefix = `[${timestamp}] `;
dom.terminalBody.innerText = prefix + message;
if (type === 'error') {
dom.terminalBody.classList.add('log-error');
} else if (type === 'success') {
dom.terminalBody.classList.add('log-success');
} else if (type === 'warning') {
dom.terminalBody.classList.add('log-warning');
}
// Errors and warnings must be readable — pop the drawer open if collapsed.
if ((type === 'error' || type === 'warning') && dom.terminalDrawer.classList.contains('collapsed')) {
setTerminalCollapsed(false);
}
}
function updateTerminalStatus(statusClass, label) {
dom.terminalStatus.className = `status-indicator ${statusClass}`;
dom.terminalStatus.textContent = label;
}
// ==========================================================================
// Initialization
// ==========================================================================
document.addEventListener('DOMContentLoaded', () => {
// 0. Probe the transpile backend once; static hosting (GitHub Pages) has none.
fetch('/api/chapters')
.then(r => { state.backend = r.ok; })
.catch(() => { state.backend = false; })
.finally(() => {
if (!state.backend) {
updateTerminalStatus('idle', 'Static');
logStaticNotice();
}
});
// 1. Initialize icons
lucide.createIcons();
// 2. Initialize UI configuration (Themes & layout)
initThemeAndLayout();
initSidebarDrawer();
initTerminalDrawer();
// 3. Load Monaco Code Editors
initMonaco();
// 4. Fetch chapters navigation list
loadChapters();
// 5. Bind action buttons
dom.transpileBtn.addEventListener('click', runTranspilation);
dom.clearTerminal.addEventListener('click', () => {
dom.terminalBody.innerHTML = 'Terminal cleared.';
updateTerminalStatus('idle', 'Idle');
});
dom.prevBtn.addEventListener('click', () => {
if (state.currentChapterIndex > 0) {
selectChapter(state.currentChapterIndex - 1);
}
});
dom.nextBtn.addEventListener('click', () => {
if (state.currentChapterIndex < state.chapters.length - 1) {
selectChapter(state.currentChapterIndex + 1);
}
});
// Handle back/forward history navigation via hash change
window.addEventListener('hashchange', () => {
const hash = window.location.hash.substring(1);
const activeChapterIndex = state.chapters.findIndex(c => c.id === hash);
if (activeChapterIndex !== -1 && activeChapterIndex !== state.currentChapterIndex) {
selectChapter(activeChapterIndex);
}
});
});