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 <body>.
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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('hidden.bs.collapse', (e) => rememberStatblock(e.target, false));
|
||||||
document.addEventListener('DOMContentLoaded', restoreStatblocks);
|
document.addEventListener('DOMContentLoaded', restoreStatblocks);
|
||||||
document.addEventListener('turbo:load', 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);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The docked "Chapter notes" panel on a storyline chapter page.
|
||||||
|
*
|
||||||
|
* <div class="storyline-split" data-controller="chapter-notes">
|
||||||
|
* ...chapter...
|
||||||
|
* <div class="storyline-notes-resize" data-chapter-notes-target="handle"
|
||||||
|
* data-action="pointerdown->chapter-notes#startResize"></div>
|
||||||
|
* <section class="storyline-notes-dock" data-chapter-notes-target="dock">
|
||||||
|
* <button data-action="chapter-notes#toggle" data-chapter-notes-target="toggle">…</button>
|
||||||
|
* ...
|
||||||
|
* </section>
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-14
@@ -192,7 +192,7 @@ table.dataTable {
|
|||||||
/* --- Storyline: chapter layout + table of contents ------------------- */
|
/* --- Storyline: chapter layout + table of contents ------------------- */
|
||||||
.storyline-toc {
|
.storyline-toc {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 1.5rem;
|
top: 0.25rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
.storyline-toc-link {
|
.storyline-toc-link {
|
||||||
@@ -429,22 +429,126 @@ table.dataTable {
|
|||||||
}
|
}
|
||||||
.storyline-pager-title { font-weight: 600; }
|
.storyline-pager-title { font-weight: 600; }
|
||||||
|
|
||||||
/* Chapter notes: collapsible card above the chapter body */
|
/* --- Storyline chapter page: split view (content above, notes docked) --- */
|
||||||
.storyline-notes > summary {
|
/* On wide/tall screens the chapter page fills the viewport: the top pane
|
||||||
cursor: pointer;
|
scrolls the chapter, the bottom pane is a fixed-height notes dock. Falls
|
||||||
list-style: none;
|
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;
|
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-caret { transition: transform 0.15s ease; display: inline-block; }
|
||||||
.storyline-notes > summary::before {
|
.storyline-notes-dock.is-collapsed { flex: 0 0 auto !important; }
|
||||||
content: "\25B8"; /* right-pointing triangle */
|
.storyline-notes-dock.is-collapsed .storyline-notes-body { display: none; }
|
||||||
display: inline-block;
|
.storyline-notes-dock.is-collapsed .storyline-notes-caret { transform: rotate(-90deg); }
|
||||||
margin-right: 0.5rem;
|
.storyline-notes-resize:has(+ .storyline-notes-dock.is-collapsed) { display: none; }
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
transition: transform 0.15s ease;
|
.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 */
|
/* Chapter list: kicker line above each title */
|
||||||
.storyline-chapter-kicker {
|
.storyline-chapter-kicker {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column">
|
<body class="d-flex flex-column {% block body_class %}{% endblock %}">
|
||||||
{% block navbar %}
|
{% block navbar %}
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -2,68 +2,82 @@
|
|||||||
|
|
||||||
{% block title %}{{ chapter.title }} · Storyline · dmtools{% endblock %}
|
{% block title %}{{ chapter.title }} · Storyline · dmtools{% endblock %}
|
||||||
|
|
||||||
|
{% block body_class %}storyline-chapter-page{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="row g-4 storyline-layout">
|
<div class="storyline-split" data-controller="chapter-notes">
|
||||||
<nav class="col-lg-3 order-lg-2">
|
<div class="storyline-split-main">
|
||||||
<div class="storyline-toc">
|
<div class="row g-4 storyline-layout">
|
||||||
<a href="{{ path('app_storyline') }}" class="text-decoration-none small d-inline-block mb-2">← All chapters</a>
|
<nav class="col-lg-3 order-lg-2">
|
||||||
<div class="text-uppercase small text-body-secondary fw-semibold mb-2" style="letter-spacing:.08em">Chapters</div>
|
<div class="storyline-toc">
|
||||||
<ol class="list-unstyled mb-0">
|
<a href="{{ path('app_storyline') }}" class="text-decoration-none small d-inline-block mb-2">← All chapters</a>
|
||||||
{% for s, c in chapters %}
|
<div class="text-uppercase small text-body-secondary fw-semibold mb-2" style="letter-spacing:.08em">Chapters</div>
|
||||||
<li>
|
<ol class="list-unstyled mb-0">
|
||||||
<a href="{{ path('app_storyline_chapter', {slug: s}) }}"
|
{% for s, c in chapters %}
|
||||||
class="storyline-toc-link{{ s == slug ? ' is-active' }}">
|
<li>
|
||||||
<span class="storyline-toc-num">{{ c.tag }}</span>{{ c.title }}
|
<a href="{{ path('app_storyline_chapter', {slug: s}) }}"
|
||||||
|
class="storyline-toc-link{{ s == slug ? ' is-active' }}">
|
||||||
|
<span class="storyline-toc-num">{{ c.tag }}</span>{{ c.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="col-lg-9 order-lg-1">
|
||||||
|
<article class="storyline-doc">
|
||||||
|
<header class="storyline-doc-head">
|
||||||
|
<div class="storyline-eyebrow">Storyline · {{ chapter.eyebrow }}</div>
|
||||||
|
<h1>{{ chapter.title }}</h1>
|
||||||
|
<p class="storyline-standfirst">{{ chapter.summary }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{ include(chapter.template) }}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<nav class="storyline-pager">
|
||||||
|
{% if prev %}
|
||||||
|
<a href="{{ path('app_storyline_chapter', {slug: prev}) }}" class="storyline-pager-link">
|
||||||
|
<span class="storyline-pager-dir">← Previous</span>
|
||||||
|
<span class="storyline-pager-title">{{ chapters[prev].title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
{% else %}<span></span>{% endif %}
|
||||||
{% endfor %}
|
{% if next %}
|
||||||
</ol>
|
<a href="{{ path('app_storyline_chapter', {slug: next}) }}" class="storyline-pager-link text-lg-end">
|
||||||
</div>
|
<span class="storyline-pager-dir">Next →</span>
|
||||||
</nav>
|
<span class="storyline-pager-title">{{ chapters[next].title }}</span>
|
||||||
|
</a>
|
||||||
<div class="col-lg-9 order-lg-1">
|
{% else %}<span></span>{% endif %}
|
||||||
<details class="card shadow-sm mb-4 storyline-notes" open>
|
</nav>
|
||||||
<summary class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<span>📝 Chapter notes</span>
|
|
||||||
{% if note.updatedAt %}
|
|
||||||
<span class="text-body-secondary small fw-normal">saved {{ note.updatedAt|date('Y-m-d H:i') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</summary>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-body-secondary small mb-2">
|
|
||||||
Your running log for this chapter — what actually happened at the table.
|
|
||||||
</p>
|
|
||||||
{{ form_start(form) }}
|
|
||||||
{{ form_row(form.notes) }}
|
|
||||||
<button class="btn btn-primary mt-2">Save notes</button>
|
|
||||||
{{ form_end(form) }}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
|
|
||||||
<article class="storyline-doc">
|
|
||||||
<header class="storyline-doc-head">
|
|
||||||
<div class="storyline-eyebrow">Storyline · {{ chapter.eyebrow }}</div>
|
|
||||||
<h1>{{ chapter.title }}</h1>
|
|
||||||
<p class="storyline-standfirst">{{ chapter.summary }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{{ include(chapter.template) }}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<nav class="storyline-pager">
|
|
||||||
{% if prev %}
|
|
||||||
<a href="{{ path('app_storyline_chapter', {slug: prev}) }}" class="storyline-pager-link">
|
|
||||||
<span class="storyline-pager-dir">← Previous</span>
|
|
||||||
<span class="storyline-pager-title">{{ chapters[prev].title }}</span>
|
|
||||||
</a>
|
|
||||||
{% else %}<span></span>{% endif %}
|
|
||||||
{% if next %}
|
|
||||||
<a href="{{ path('app_storyline_chapter', {slug: next}) }}" class="storyline-pager-link text-lg-end">
|
|
||||||
<span class="storyline-pager-dir">Next →</span>
|
|
||||||
<span class="storyline-pager-title">{{ chapters[next].title }}</span>
|
|
||||||
</a>
|
|
||||||
{% else %}<span></span>{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="storyline-notes-resize" data-chapter-notes-target="handle"
|
||||||
|
data-action="pointerdown->chapter-notes#startResize" role="separator"
|
||||||
|
aria-label="Resize notes panel" title="Drag to resize"></div>
|
||||||
|
|
||||||
|
<section class="storyline-notes-dock" data-chapter-notes-target="dock">
|
||||||
|
<div class="storyline-notes-bar">
|
||||||
|
<button type="button" class="storyline-notes-toggle" data-action="chapter-notes#toggle"
|
||||||
|
data-chapter-notes-target="toggle" aria-expanded="true">
|
||||||
|
<span class="storyline-notes-caret">▾</span> Chapter notes
|
||||||
|
</button>
|
||||||
|
{% if note.updatedAt %}
|
||||||
|
<span class="text-body-secondary small">saved {{ note.updatedAt|date('Y-m-d H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="storyline-notes-body">
|
||||||
|
{{ form_start(form, {attr: {class: 'storyline-notes-form'}}) }}
|
||||||
|
{{ form_errors(form.notes) }}
|
||||||
|
{{ form_widget(form.notes, {attr: {class: 'form-control storyline-notes-text'}}) }}
|
||||||
|
<div class="storyline-notes-actions">
|
||||||
|
<button class="btn btn-primary btn-sm">Save notes</button>
|
||||||
|
<span class="text-body-secondary small">This chapter only. Saved to the server.</span>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user