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>
114 lines
3.6 KiB
JavaScript
114 lines
3.6 KiB
JavaScript
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);
|
|
}
|
|
}
|