diff --git a/book.js b/book.js
index 81d55eb..cf160e1 100644
--- a/book.js
+++ b/book.js
@@ -1 +1,8 @@
-import './fabula-ultima-sheet.css';
\ No newline at end of file
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import BookIndex from './src/BookIndex.jsx';
+
+const { title, logoText, pages } = window.__BOOK_DATA__;
+createRoot(document.getElementById('root')).render(
+
+);
diff --git a/css/book-layout.css b/css/book-layout.css
index cf0d92e..e8abe51 100644
--- a/css/book-layout.css
+++ b/css/book-layout.css
@@ -1,3 +1,6 @@
+/* React mounts into #root; display:contents removes it from the flex chain */
+#root { display: contents; }
+
/* Reset body to a layout container (book-page.css targets body for page content) */
html, body {
height: 100vh;
@@ -10,6 +13,54 @@ html, body {
background-image: none;
}
+/* ── App header ── */
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 20px;
+ background: var(--surface2);
+ border-bottom: 1px solid var(--border-bright);
+ flex-shrink: 0;
+}
+
+.logo {
+ font-family: var(--font-display);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--teal);
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+/* ── Prev / Next buttons ── */
+.tab {
+ background: var(--surface3);
+ border: 1px solid var(--border-bright);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ padding: 5px 14px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.tab:hover:not(:disabled) {
+ background: var(--surface);
+ color: var(--teal);
+ border-color: var(--teal-dim);
+}
+
+.tab:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
#layout {
display: flex;
flex: 1;
diff --git a/package-lock.json b/package-lock.json
index b67660f..92620ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/register": "^7.29.7",
+ "babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
@@ -1850,6 +1851,97 @@
"node": ">=12.0.0"
}
},
+ "node_modules/babel-loader": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.1.tgz",
+ "integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.20.0 || ^20.10.0 || >=22.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0 || ^8.0.0-beta.1",
+ "@rspack/core": "^1.0.0 || ^2.0.0-0",
+ "webpack": ">=5.61.0"
+ },
+ "peerDependenciesMeta": {
+ "@rspack/core": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/babel-loader/node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/babel-loader/node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
@@ -6931,6 +7023,19 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
diff --git a/package.json b/package.json
index 581113e..c02897e 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/register": "^7.29.7",
+ "babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
diff --git a/src/BookIndex.jsx b/src/BookIndex.jsx
index d59e69d..02204f8 100644
--- a/src/BookIndex.jsx
+++ b/src/BookIndex.jsx
@@ -1,153 +1,162 @@
-import React from 'react';
-
-function buildScript(pageNums) {
- return `
-const PAGES = [${pageNums.join(',')}];
-const total = PAGES.length;
-let currentIdx = 0;
-
-const content = document.getElementById('content');
-const btnPrev = document.getElementById('btn-prev');
-const btnNext = document.getElementById('btn-next');
-const indicator = document.getElementById('page-indicator');
-const titleEl = document.getElementById('page-title');
-
-function updateNav(idx, push) {
- if (idx === currentIdx && indicator.textContent) return;
- currentIdx = idx;
- const n = PAGES[idx];
- indicator.textContent = (idx + 1) + ' / ' + total;
- titleEl.textContent = 'Page ' + n;
- btnPrev.disabled = idx === 0;
- btnNext.disabled = idx === total - 1;
- document.querySelectorAll('#page-list li').forEach(li => {
- li.classList.toggle('active', Number(li.dataset.page) === n);
- });
- document.querySelector('#page-list li[data-page="' + n + '"]')
- ?.scrollIntoView({ block: 'nearest' });
- if (push) history.pushState(null, '', '#page-' + n);
- else history.replaceState(null, '', '#page-' + n);
-}
-
-function goTo(n, smooth, push) {
- const idx = PAGES.indexOf(n);
- if (idx === -1) return;
- const sec = document.getElementById('page-' + n);
- if (!sec) return;
- sec.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'start' });
- updateNav(idx, push);
-}
-
-btnPrev.addEventListener('click', () => { if (currentIdx > 0) goTo(PAGES[currentIdx - 1], true, true); });
-btnNext.addEventListener('click', () => { if (currentIdx < total - 1) goTo(PAGES[currentIdx + 1], true, true); });
-
-document.getElementById('page-list').addEventListener('click', e => {
- const li = e.target.closest('li[data-page]');
- if (li) goTo(Number(li.dataset.page), false, true);
-});
-
-document.addEventListener('keydown', e => {
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
- if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { if (currentIdx > 0) goTo(PAGES[currentIdx - 1], true, true); }
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { if (currentIdx < total - 1) goTo(PAGES[currentIdx + 1], true, true); }
-});
-
-// Track current page by scroll position (replaceState — no new history entry)
-content.addEventListener('scroll', () => {
- const containerTop = content.getBoundingClientRect().top;
- let found = 0;
- for (let i = 0; i < PAGES.length; i++) {
- const sec = document.getElementById('page-' + PAGES[i]);
- if (sec && sec.getBoundingClientRect().top - containerTop <= 40) found = i;
- else break;
- }
- if (found !== currentIdx) updateNav(found, false);
-}, { passive: true });
-
-// Browser back/forward
-window.addEventListener('popstate', () => {
- const pm = location.hash.match(/^#page-(\\d+)$/);
- if (pm) {
- const n = parseInt(pm[1], 10);
- if (PAGES.includes(n)) goTo(n, false, false);
- }
-});
-
-// Initial navigation from URL hash
-const m = location.hash.match(/^#page-(\\d+)$/);
-const startPage = m ? parseInt(m[1], 10) : PAGES[0];
-goTo(PAGES.includes(startPage) ? startPage : PAGES[0], false, false);
-`;
-}
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
export default function BookIndex({ title, logoText, pages }) {
- const pageNums = pages.map(p => p.n);
+ const pageNums = useMemo(() => pages.map(p => p.n), [pages]);
+ const total = pageNums.length;
+
+ const [currentIdx, setCurrentIdx] = useState(() => {
+ const m = window.location.hash.match(/^#page-(\d+)$/);
+ if (m) {
+ const idx = pageNums.indexOf(parseInt(m[1], 10));
+ if (idx !== -1) return idx;
+ }
+ return 0;
+ });
+
+ const contentRef = useRef(null);
+
+ const goTo = useCallback((n, smooth, push) => {
+ const idx = pageNums.indexOf(n);
+ if (idx === -1) return;
+ const sec = document.getElementById(`page-${n}`);
+ if (!sec) return;
+ sec.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'start' });
+ setCurrentIdx(idx);
+ if (push) history.pushState(null, '', `#page-${n}`);
+ else history.replaceState(null, '', `#page-${n}`);
+ }, [pageNums]);
+
+ // Scroll active sidebar item into view when page changes
+ useEffect(() => {
+ document.querySelector(`#page-list li[data-page="${pageNums[currentIdx]}"]`)
+ ?.scrollIntoView({ block: 'nearest' });
+ }, [currentIdx, pageNums]);
+
+ // Initial scroll to hash page
+ useEffect(() => {
+ const n = pageNums[currentIdx];
+ goTo(n, false, false);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Scroll tracking — update currentIdx as user scrolls
+ useEffect(() => {
+ const container = contentRef.current;
+ if (!container) return;
+ const handleScroll = () => {
+ const top = container.getBoundingClientRect().top;
+ let found = 0;
+ for (let i = 0; i < pageNums.length; i++) {
+ const sec = document.getElementById(`page-${pageNums[i]}`);
+ if (sec && sec.getBoundingClientRect().top - top <= 40) found = i;
+ else break;
+ }
+ setCurrentIdx(prev => {
+ if (found === prev) return prev;
+ history.replaceState(null, '', `#page-${pageNums[found]}`);
+ return found;
+ });
+ };
+ container.addEventListener('scroll', handleScroll, { passive: true });
+ return () => container.removeEventListener('scroll', handleScroll);
+ }, [pageNums]);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = e => {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+ setCurrentIdx(prev => {
+ if ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') && prev > 0) {
+ goTo(pageNums[prev - 1], true, true);
+ }
+ if ((e.key === 'ArrowRight' || e.key === 'ArrowDown') && prev < total - 1) {
+ goTo(pageNums[prev + 1], true, true);
+ }
+ return prev;
+ });
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [goTo, pageNums, total]);
+
+ // Browser back/forward
+ useEffect(() => {
+ const handlePopState = () => {
+ const m = location.hash.match(/^#page-(\d+)$/);
+ if (m) {
+ const n = parseInt(m[1], 10);
+ if (pageNums.includes(n)) goTo(n, false, false);
+ }
+ };
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [goTo, pageNums]);
+
+ const currentPage = pageNums[currentIdx];
return (
-
-
-
-
- {title}
-
-
-
-
-
-
- {logoText}
-
-
+ <>
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- {pages.map(({ n, content }) => (
-
- ))}
-
+
+
+
+ Page {currentPage}
+
+
+
+ {pages.map(({ n, content }) => (
+
+ ))}
-
-
-
-
+
+ >
);
}
diff --git a/webpack.config.js b/webpack.config.js
index 2035906..4f6ac07 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,14 +1,9 @@
-require("@babel/register")({ presets: ["@babel/preset-react"] });
-
const path = require("path");
const fs = require("fs");
-const React = require("react");
-const { renderToStaticMarkup } = require("react-dom/server");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
-const BookIndex = require("./src/BookIndex.jsx").default;
function readPages(dir) {
const pageNums = fs
@@ -24,11 +19,24 @@ function readPages(dir) {
});
}
-function bookTemplateContent(title, logoText, dir) {
+function bookTemplate(title, logoText, dir) {
const pages = readPages(dir);
- return () =>
- "" +
- renderToStaticMarkup(React.createElement(BookIndex, { title, logoText, pages }));
+ const data = JSON.stringify({ title, logoText, pages });
+ return `
+
+
+
+
+
${title}
+
+
+
+
+
+
+
+
+`;
}
module.exports = (env, argv) => {
@@ -37,7 +45,6 @@ module.exports = (env, argv) => {
return {
entry: {
sheet: "./fabula-ultima-sheet.js",
- // Imports the shared CSS so HtmlWebpackPlugin can inject it into the book page
book: "./book.js",
},
output: {
@@ -48,6 +55,14 @@ module.exports = (env, argv) => {
},
module: {
rules: [
+ {
+ test: /\.(js|jsx)$/,
+ exclude: /node_modules/,
+ use: {
+ loader: "babel-loader",
+ options: { presets: ["@babel/preset-react"] },
+ },
+ },
{
test: /\.css$/,
use: [
@@ -57,6 +72,9 @@ module.exports = (env, argv) => {
},
],
},
+ resolve: {
+ extensions: [".js", ".jsx"],
+ },
plugins: [
...(isProd
? [new MiniCssExtractPlugin({ filename: "[name].[contenthash].css" })]
@@ -68,7 +86,7 @@ module.exports = (env, argv) => {
scriptLoading: "blocking",
}),
new HtmlWebpackPlugin({
- templateContent: bookTemplateContent(
+ templateContent: bookTemplate(
"Fabula Ultima - Core Rulebook",
"Core Rules",
"./books/core"
@@ -78,7 +96,7 @@ module.exports = (env, argv) => {
scriptLoading: "blocking",
}),
new HtmlWebpackPlugin({
- templateContent: bookTemplateContent(
+ templateContent: bookTemplate(
"Fabula Ultima - Natural Fantasy Atlas",
"Natural Fantasy Atlas",
"./books/natural-fantasy-atlas"
@@ -87,7 +105,6 @@ module.exports = (env, argv) => {
chunks: ["book"],
scriptLoading: "blocking",
}),
- // Copy book pages to dist/books/ (excluding index.html, managed by HtmlWebpackPlugin)
new CopyWebpackPlugin({
patterns: [
{
@@ -97,7 +114,6 @@ module.exports = (env, argv) => {
},
],
}),
- // Copy static CSS
new CopyWebpackPlugin({
patterns: [
{
@@ -112,7 +128,6 @@ module.exports = (env, argv) => {
minimizer: ["...", new CssMinimizerPlugin()],
splitChunks: {
cacheGroups: {
- // Merge CSS from all entries into a single shared file
styles: {
name: "styles",
type: "css/mini-extract",