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>
161 lines
6.5 KiB
JavaScript
161 lines
6.5 KiB
JavaScript
/*
|
|
* dmtools front-end entrypoint (Symfony AssetMapper / importmap).
|
|
* Loaded from base.html.twig via {{ importmap('app') }}.
|
|
*/
|
|
import './stimulus_bootstrap.js';
|
|
|
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css';
|
|
import './styles/app.css';
|
|
|
|
import * as bootstrap from 'bootstrap';
|
|
import DataTable from 'datatables.net-bs5';
|
|
|
|
window.bootstrap = bootstrap;
|
|
|
|
const esc = (v) => {
|
|
if (v === null || v === undefined) return '';
|
|
return String(v).replace(/[&<>"']/g, (c) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[c]));
|
|
};
|
|
|
|
// Build a DataTables column definition from a <th> in the table head.
|
|
// data-data key in the row object (required for server-side tables)
|
|
// data-orderable "false" to disable sorting on the column
|
|
// data-format "link" | "truncate" | (default: plain escaped text)
|
|
// data-row-url (on the <table>) URL template with __ID__ for "link" columns
|
|
// NB: "format" not "render" - DataTables itself consumes a data-render attribute.
|
|
function columnFromTh(th, rowUrl) {
|
|
const key = th.dataset.data;
|
|
const col = { data: key || null, orderable: th.dataset.orderable !== 'false' };
|
|
|
|
if (th.dataset.format === 'link' && rowUrl) {
|
|
col.render = (val, type, row) => {
|
|
if (type !== 'display') return val ?? '';
|
|
return `<a href="${esc(rowUrl.replace('__ID__', row.id))}">${esc(val)}</a>`;
|
|
};
|
|
} else if (th.dataset.format === 'truncate') {
|
|
col.render = (val, type) => {
|
|
if (type !== 'display') return val ?? '';
|
|
const s = val ? String(val) : '';
|
|
const short = s.length > 70 ? s.slice(0, 70) + '…' : s;
|
|
return `<span title="${esc(s)}">${esc(short)}</span>`;
|
|
};
|
|
} else if (th.dataset.format === 'pick') {
|
|
// a row-select checkbox + quantity, for "add these to the tracker"
|
|
col.render = (val, type, row) => {
|
|
if (type !== 'display') return '';
|
|
return `<span class="track-pick d-inline-flex align-items-center gap-2">
|
|
<input type="checkbox" class="form-check-input track-check" value="${esc(row.id)}" aria-label="Select">
|
|
<input type="number" class="form-control form-control-sm track-qty" value="1" min="1" max="50"
|
|
style="width: 3.75rem" aria-label="Count">
|
|
</span>`;
|
|
};
|
|
} else {
|
|
col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? ''));
|
|
}
|
|
return col;
|
|
}
|
|
|
|
// Live DataTable instances, keyed by their <table> element. Used to tear them
|
|
// down before Turbo caches a page - otherwise the back button restores a
|
|
// snapshot that still contains DataTables' injected controls, and re-init
|
|
// stacks a second set of "search" / "entries per page" rows on top.
|
|
const instances = new Map();
|
|
|
|
function initDataTables(root = document) {
|
|
root.querySelectorAll('table.datatable').forEach((table) => {
|
|
if (table.dataset.datatable === 'false' || instances.has(table) || DataTable.isDataTable(table)) {
|
|
return;
|
|
}
|
|
|
|
let options = { pageLength: 10, order: [], responsive: false };
|
|
if (table.dataset.datatableOptions) {
|
|
try {
|
|
options = { ...options, ...JSON.parse(table.dataset.datatableOptions) };
|
|
} catch (e) {
|
|
console.error('Invalid data-datatable-options on', table, e);
|
|
}
|
|
}
|
|
|
|
// Server-side mode: the backend does the paging, sorting and searching.
|
|
if (table.dataset.datatableSrc) {
|
|
const ths = [...table.querySelectorAll('thead th')];
|
|
options = {
|
|
...options,
|
|
serverSide: true,
|
|
processing: true,
|
|
ajax: { url: table.dataset.datatableSrc },
|
|
columns: ths.map((th) => columnFromTh(th, table.dataset.rowUrl)),
|
|
order: options.order.length ? options.order : [[0, 'asc']],
|
|
};
|
|
}
|
|
|
|
instances.set(table, new DataTable(table, options));
|
|
});
|
|
}
|
|
|
|
function destroyDataTables() {
|
|
instances.forEach((dt) => dt.destroy());
|
|
instances.clear();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => initDataTables());
|
|
document.addEventListener('turbo:load', () => initDataTables());
|
|
// Restore the plain <table> before Turbo snapshots the page for its cache.
|
|
document.addEventListener('turbo:before-cache', destroyDataTables);
|
|
|
|
// --- Remember unfolded stat blocks on the tracker -----------------------
|
|
// Every damage/heal is a POST + redirect, so without this a stat block the
|
|
// DM has open snaps shut on each hit. Keyed by the collapse element id.
|
|
const SB_KEY = 'dmtools.statblocks';
|
|
|
|
function readOpenSet() {
|
|
try {
|
|
return new Set(JSON.parse(localStorage.getItem(SB_KEY) || '[]'));
|
|
} catch (e) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function restoreStatblocks() {
|
|
const open = readOpenSet();
|
|
document.querySelectorAll('.statblock-collapse').forEach((el) => {
|
|
const on = open.has(el.id);
|
|
el.classList.toggle('show', on);
|
|
document
|
|
.querySelectorAll(`[data-bs-toggle="collapse"][data-bs-target="#${el.id}"]`)
|
|
.forEach((btn) => btn.setAttribute('aria-expanded', on ? 'true' : 'false'));
|
|
});
|
|
}
|
|
|
|
function rememberStatblock(el, isOpen) {
|
|
if (!el.classList.contains('statblock-collapse')) return;
|
|
const open = readOpenSet();
|
|
isOpen ? open.add(el.id) : open.delete(el.id);
|
|
try {
|
|
localStorage.setItem(SB_KEY, JSON.stringify([...open]));
|
|
} catch (e) {
|
|
/* private mode - ignore */
|
|
}
|
|
}
|
|
|
|
document.addEventListener('shown.bs.collapse', (e) => rememberStatblock(e.target, true));
|
|
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);
|