Files
dmtools/assets/controllers/chapter_notes_controller.js
FrankandClaude Sonnet 5 90345ecd38 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>
2026-09-08 19:00:49 +02:00

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);
}
}