diff --git a/assets/controllers/storyline_toc_controller.js b/assets/controllers/storyline_toc_controller.js new file mode 100644 index 0000000..832a14f --- /dev/null +++ b/assets/controllers/storyline_toc_controller.js @@ -0,0 +1,72 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Sidebar table of contents on a storyline chapter page. + * + * - the caret unfolds/folds a chapter's section list + * - links for the chapter being read (target "link", href="#section-…") + * jump inside the split view's scrolling pane + * - the section currently at the top of the pane is highlighted + */ +export default class extends Controller { + static targets = ['link']; + + connect() { + this.pane = this.element.closest('.storyline-split-main'); + this.onScroll = () => { + if (this.frame) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.highlight(); + }); + }; + (this.pane || window).addEventListener('scroll', this.onScroll, { passive: true }); + window.addEventListener('scroll', this.onScroll, { passive: true }); + this.highlight(); + } + + disconnect() { + (this.pane || window).removeEventListener('scroll', this.onScroll); + window.removeEventListener('scroll', this.onScroll); + } + + fold(event) { + const item = event.currentTarget.closest('.storyline-toc-item'); + const open = item.classList.toggle('is-open'); + event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + jump(event) { + const id = event.currentTarget.getAttribute('href').slice(1); + const el = document.getElementById(id); + if (!el) return; + event.preventDefault(); + el.scrollIntoView({ block: 'start' }); + history.replaceState(null, '', `#${id}`); + } + + linkTargetConnected(link) { + link.addEventListener('click', (e) => this.jump({ currentTarget: link, preventDefault: () => e.preventDefault() })); + } + + highlight() { + if (!this.hasLinkTarget) return; + // In the split view the pane scrolls; on small screens the window does. + const paneScrolls = this.pane && getComputedStyle(this.pane).overflowY !== 'visible'; + const top = paneScrolls ? this.pane.getBoundingClientRect().top : 0; + let current = null; + for (const link of this.linkTargets) { + const el = document.getElementById(link.getAttribute('href').slice(1)); + if (el && el.getBoundingClientRect().top - top <= 90) current = link; + } + this.linkTargets.forEach((l) => l.classList.toggle('is-current', l === current)); + if (current && this.pane) { + const list = current.closest('.storyline-toc-sections'); + if (list && list.scrollHeight > list.clientHeight) { + const c = current.getBoundingClientRect(); + const l = list.getBoundingClientRect(); + if (c.top < l.top || c.bottom > l.bottom) current.scrollIntoView({ block: 'nearest' }); + } + } + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f0fa77b..e78a68e 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -260,6 +260,49 @@ table.dataTable { color: var(--bs-secondary-color); } +/* Unfoldable per-chapter section lists */ +.storyline-toc-row { display: flex; align-items: flex-start; gap: 0.15rem; } +.storyline-toc-row .storyline-toc-link { flex: 1 1 auto; min-width: 0; } +.storyline-toc-fold { + flex: 0 0 1.4rem; + height: 1.9rem; + padding: 0; + border: 0; + background: none; + color: var(--bs-secondary-color); + cursor: pointer; + transition: transform 0.15s ease; +} +button.storyline-toc-fold:hover { color: var(--bs-body-color); } +.storyline-toc-item.is-open > .storyline-toc-row > button.storyline-toc-fold { transform: rotate(90deg); } +.storyline-toc-sections { + display: none; + margin: 0.1rem 0 0.4rem 1.4rem; + padding: 0; + max-height: 45vh; + overflow-y: auto; + scrollbar-width: none; + font-size: 0.875rem; +} +.storyline-toc-sections::-webkit-scrollbar { display: none; } +.storyline-toc-item.is-open > .storyline-toc-sections { display: block; } +.storyline-toc-sub { + display: flex; + gap: 0.5rem; + padding: 0.2rem 0.5rem; + border-left: 2px solid var(--bs-border-color); + text-decoration: none; + color: var(--bs-secondary-color); +} +.storyline-toc-sub:hover { color: var(--bs-body-color); background: var(--bs-tertiary-bg); } +.storyline-toc-sub.is-current { + color: var(--bs-body-color); + border-left-color: var(--bs-primary); + font-weight: 600; +} +.storyline-toc-sub .storyline-toc-num { flex: 0 0 1.4rem; } +.storyline-section { scroll-margin-top: 0.75rem; } + /* --- Storyline: the document itself --------------------------------- */ .storyline-doc { max-width: 46rem; diff --git a/src/Controller/StorylineController.php b/src/Controller/StorylineController.php index 2448a9c..312bb92 100644 --- a/src/Controller/StorylineController.php +++ b/src/Controller/StorylineController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Form\ChapterNoteType; use App\Repository\ChapterNoteRepository; +use App\Storyline\ChapterOutline; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -51,7 +52,7 @@ class StorylineController extends AbstractController } #[Route('/storyline/{slug}', name: 'app_storyline_chapter', requirements: ['slug' => '[a-z0-9-]+'], methods: ['GET', 'POST'])] - public function chapter(string $slug, Request $request, ChapterNoteRepository $notes, EntityManagerInterface $em): Response + public function chapter(string $slug, Request $request, ChapterNoteRepository $notes, EntityManagerInterface $em, ChapterOutline $outline): Response { if (!isset(self::CHAPTERS[$slug])) { throw $this->createNotFoundException('No such storyline chapter.'); @@ -71,6 +72,18 @@ class StorylineController extends AbstractController return $this->redirectToRoute('app_storyline_chapter', ['slug' => $slug]); } + // Render every chapter once: the current one is shown (with anchors), + // the rest only feed the sidebar's per-section jump lists. + $sections = []; + $body = ''; + foreach (self::CHAPTERS as $s => $c) { + $processed = $outline->process($this->renderView($c['template'])); + $sections[$s] = $processed['sections']; + if ($s === $slug) { + $body = $processed['html']; + } + } + $slugs = array_keys(self::CHAPTERS); $pos = array_search($slug, $slugs, true); @@ -78,6 +91,8 @@ class StorylineController extends AbstractController 'chapters' => self::CHAPTERS, 'slug' => $slug, 'chapter' => self::CHAPTERS[$slug], + 'body' => $body, + 'sections' => $sections, 'note' => $note, 'form' => $form, 'prev' => $pos > 0 ? $slugs[$pos - 1] : null, diff --git a/src/Storyline/ChapterOutline.php b/src/Storyline/ChapterOutline.php new file mode 100644 index 0000000..43dcfeb --- /dev/null +++ b/src/Storyline/ChapterOutline.php @@ -0,0 +1,37 @@ +21 Title), gives it + * an anchor id, and reports it so the sidebar can link straight to it. + */ +final class ChapterOutline +{ + private const HEADING = '~
{{ chapter.summary }}
- {{ include(chapter.template) }} + {{ body|raw }}