/* * 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 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 ) 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 `${esc(val)}`; }; } 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 `${esc(short)}`; }; } 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 ` `; }; } else { col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? '')); } return col; } // Live DataTable instances, keyed by their
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
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);