From 90345ecd38129fa3e22ae38828785e45c7322dea Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 8 Sep 2026 19:00:49 +0200 Subject: [PATCH] Storyline: split the chapter page, dock the notes at the bottom The chapter page now fills the viewport as two horizontal panes: the chapter scrolls in the top ~2/3, the notes sit in a fixed dock in the bottom ~1/3, always visible while you read. Drag the divider to change the ratio, click the "Chapter notes" header to collapse the dock to a bar; both the height and the collapsed state persist in localStorage. - base.html.twig: add a {% block body_class %} hook on . - chapter.html.twig sets body class "storyline-chapter-page"; the split layout CSS is scoped to it and falls back to normal stacked flow under 768px wide or 560px tall. - app.js publishes the navbar height as --app-nav-h so the split can size itself to viewport-minus-navbar even when the navbar wraps. - new chapter_notes Stimulus controller: drag-resize + collapse + persist. Co-Authored-By: Claude Sonnet 5 --- assets/app.js | 13 ++ .../controllers/chapter_notes_controller.js | 113 +++++++++++++++ assets/styles/app.css | 132 ++++++++++++++++-- templates/base.html.twig | 2 +- templates/storyline/chapter.html.twig | 132 ++++++++++-------- 5 files changed, 318 insertions(+), 74 deletions(-) create mode 100644 assets/controllers/chapter_notes_controller.js diff --git a/assets/app.js b/assets/app.js index 6641d89..19adcf4 100644 --- a/assets/app.js +++ b/assets/app.js @@ -145,3 +145,16 @@ document.addEventListener('shown.bs.collapse', (e) => rememberStatblock(e.target document.addEventListener('hidden.bs.collapse', (e) => rememberStatblock(e.target, false)); document.addEventListener('DOMContentLoaded', restoreStatblocks); document.addEventListener('turbo:load', restoreStatblocks); + +// --- Publish the navbar height as --app-nav-h ------------------------- +// The storyline chapter page sizes its split view to the viewport minus the +// navbar; the navbar grows when it wraps, so measure it rather than guess. +function publishNavHeight() { + const nav = document.querySelector('.navbar'); + const h = nav ? Math.round(nav.getBoundingClientRect().height) : 56; + document.documentElement.style.setProperty('--app-nav-h', `${h}px`); +} + +document.addEventListener('DOMContentLoaded', publishNavHeight); +document.addEventListener('turbo:load', publishNavHeight); +window.addEventListener('resize', publishNavHeight); diff --git a/assets/controllers/chapter_notes_controller.js b/assets/controllers/chapter_notes_controller.js new file mode 100644 index 0000000..e17dea7 --- /dev/null +++ b/assets/controllers/chapter_notes_controller.js @@ -0,0 +1,113 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * The docked "Chapter notes" panel on a storyline chapter page. + * + *
+ * ...chapter... + *
+ *
+ * + * ... + *
+ *
+ * + * Drag the handle to resize; click the header to collapse. Both the pixel + * height and the collapsed flag persist per browser (localStorage). + */ +const H_KEY = 'dmtools.chapterNotesHeight'; +const C_KEY = 'dmtools.chapterNotesCollapsed'; +const MIN_PX = 44; +const MIN_TOP_PX = 140; + +export default class extends Controller { + static targets = ['dock', 'handle', 'toggle']; + + connect() { + this.onMove = this.onMove.bind(this); + this.endResize = this.endResize.bind(this); + + let stored = null; + try { + stored = localStorage.getItem(H_KEY); + } catch (e) { + /* private mode */ + } + if (stored) { + this.element.style.setProperty('--notes-h', `${this.clamp(parseInt(stored, 10))}px`); + } + + let collapsed = false; + try { + collapsed = localStorage.getItem(C_KEY) === '1'; + } catch (e) { + /* ignore */ + } + this.setCollapsed(collapsed); + } + + disconnect() { + this.endResize(); + } + + toggle() { + this.setCollapsed(!this.dockTarget.classList.contains('is-collapsed')); + } + + setCollapsed(on) { + this.dockTarget.classList.toggle('is-collapsed', on); + if (this.hasToggleTarget) { + this.toggleTarget.setAttribute('aria-expanded', on ? 'false' : 'true'); + } + try { + localStorage.setItem(C_KEY, on ? '1' : '0'); + } catch (e) { + /* ignore */ + } + } + + startResize(event) { + if (this.dockTarget.classList.contains('is-collapsed')) return; + event.preventDefault(); + this.resizing = true; + this.handleTarget.setPointerCapture?.(event.pointerId); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'row-resize'; + window.addEventListener('pointermove', this.onMove); + window.addEventListener('pointerup', this.endResize); + window.addEventListener('pointercancel', this.endResize); + } + + onMove(event) { + if (!this.resizing) return; + const rect = this.element.getBoundingClientRect(); + const px = this.clamp(rect.bottom - event.clientY); + this.element.style.setProperty('--notes-h', `${px}px`); + } + + endResize() { + window.removeEventListener('pointermove', this.onMove); + window.removeEventListener('pointerup', this.endResize); + window.removeEventListener('pointercancel', this.endResize); + if (!this.resizing) return; + this.resizing = false; + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + const current = this.element.style.getPropertyValue('--notes-h'); + if (current.endsWith('px')) { + try { + localStorage.setItem(H_KEY, parseInt(current, 10)); + } catch (e) { + /* ignore */ + } + } + } + + clamp(px) { + if (!Number.isFinite(px)) return MIN_PX; + const max = Math.max(MIN_PX, this.element.getBoundingClientRect().height - MIN_TOP_PX); + return Math.min(Math.max(px, MIN_PX), max); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f505d64..1900a55 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -192,7 +192,7 @@ table.dataTable { /* --- Storyline: chapter layout + table of contents ------------------- */ .storyline-toc { position: sticky; - top: 1.5rem; + top: 0.25rem; font-size: 0.9375rem; } .storyline-toc-link { @@ -429,22 +429,126 @@ table.dataTable { } .storyline-pager-title { font-weight: 600; } -/* Chapter notes: collapsible card above the chapter body */ -.storyline-notes > summary { - cursor: pointer; - list-style: none; +/* --- Storyline chapter page: split view (content above, notes docked) --- */ +/* On wide/tall screens the chapter page fills the viewport: the top pane + scrolls the chapter, the bottom pane is a fixed-height notes dock. Falls + back to normal document flow on small screens (see media query below). */ +.storyline-chapter-page { overflow: hidden; } +.storyline-chapter-page main.app-main { + min-height: 0; + padding: 0; + height: calc(100vh - var(--app-nav-h, 3.5rem)); + height: calc(100dvh - var(--app-nav-h, 3.5rem)); +} +.storyline-chapter-page main.app-main > .container { + height: 100%; + display: flex; + flex-direction: column; + padding-top: 0.75rem; +} +.storyline-split { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + /* --notes-h is set by the resize handle; default is one third */ + --notes-h: 33%; +} +.storyline-split-main { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: 1.5rem; +} +.storyline-notes-resize { + flex: 0 0 auto; + height: 10px; + margin: 0 -0.5rem; + cursor: row-resize; + background: + linear-gradient(var(--bs-border-color), var(--bs-border-color)) center/2.5rem 2px no-repeat; + border-top: 1px solid var(--bs-border-color); +} +.storyline-notes-resize:hover { background-color: var(--bs-tertiary-bg); } +.storyline-notes-dock { + flex: 0 0 var(--notes-h); + min-height: 2.75rem; + display: flex; + flex-direction: column; + background: var(--bs-body-bg); + border-top: 1px solid var(--bs-border-color); +} +.storyline-notes-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.4rem 0.1rem; +} +.storyline-notes-toggle { + border: 0; + background: none; + padding: 0.15rem 0.25rem; font-weight: 600; + color: var(--bs-body-color); + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; } -.storyline-notes > summary::-webkit-details-marker { display: none; } -.storyline-notes > summary::before { - content: "\25B8"; /* right-pointing triangle */ - display: inline-block; - margin-right: 0.5rem; - color: var(--bs-secondary-color); - transition: transform 0.15s ease; +.storyline-notes-caret { transition: transform 0.15s ease; display: inline-block; } +.storyline-notes-dock.is-collapsed { flex: 0 0 auto !important; } +.storyline-notes-dock.is-collapsed .storyline-notes-body { display: none; } +.storyline-notes-dock.is-collapsed .storyline-notes-caret { transform: rotate(-90deg); } +.storyline-notes-resize:has(+ .storyline-notes-dock.is-collapsed) { display: none; } + +.storyline-notes-body { + flex: 1 1 auto; + min-height: 0; + padding-bottom: 0.5rem; +} +.storyline-notes-form { + height: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.storyline-notes-text { + flex: 1 1 auto; + min-height: 0; + resize: none; + font-size: 0.95rem; + line-height: 1.55; +} +.storyline-notes-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Small or short screens: no fixed split, just stack notes under the chapter. */ +@media (max-width: 767.98px), (max-height: 560px) { + .storyline-chapter-page { overflow: visible; } + .storyline-chapter-page main.app-main { + height: auto; + padding-top: 1.5rem; + padding-bottom: 3rem; + } + .storyline-chapter-page main.app-main > .container { display: block; height: auto; } + .storyline-split { display: block; } + .storyline-split-main { overflow: visible; } + .storyline-notes-resize { display: none; } + .storyline-notes-dock { + flex-basis: auto; + margin-top: 1.5rem; + border-top-width: 2px; + padding-top: 0.5rem; + } + .storyline-notes-text { min-height: 9rem; } } -.storyline-notes[open] > summary::before { transform: rotate(90deg); } -.storyline-notes textarea { font-size: 0.95rem; line-height: 1.6; } /* Chapter list: kicker line above each title */ .storyline-chapter-kicker { diff --git a/templates/base.html.twig b/templates/base.html.twig index 529f370..0d30134 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -12,7 +12,7 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - + {% block navbar %} - - -
-
-
Storyline · {{ chapter.eyebrow }}
-

{{ chapter.title }}

-

{{ chapter.summary }}

-
- - {{ include(chapter.template) }} -
- - + + + + +
+
+ + {% if note.updatedAt %} + saved {{ note.updatedAt|date('Y-m-d H:i') }} + {% endif %} +
+
+ {{ form_start(form, {attr: {class: 'storyline-notes-form'}}) }} + {{ form_errors(form.notes) }} + {{ form_widget(form.notes, {attr: {class: 'form-control storyline-notes-text'}}) }} +
+ + This chapter only. Saved to the server. +
+ {{ form_end(form) }} +
+
{% endblock %}