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}
-
- + <> +
+
{logoText}
+
+ {currentIdx + 1} / {total} +
+
+ +
+
+ + -
- - -
- -
- {pages.map(({ n, content }) => ( -
- ))} -
+
+ +
+ {pages.map(({ n, content }) => ( +
+ ))}
- - +
+ +`; } 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",