/** * EditorCore — shared framework for all task-type visual editors. * * Handles: worksheet loading, page management, toolbar wiring, * toast/tooltip, save endpoint, copy/export. * * Task-specific logic (selectors, drag, keyboard, serialization) * stays in each editor.html. * * Usage: * const core = EditorCore.init({ * taskType: 'collecting-asteroids', * onReady: (pages, mmToPx) => { ... setup draggables, keyboard, etc. } * }); */ window.EditorCore = (function () { let _pages = []; let _currentPage = 0; let _mmToPx = 1; let _config = {}; let _serializeFn = null; /** * Initialize the editor core. * @param {Object} opts * @param {string} opts.taskType - e.g. 'collecting-asteroids' * @param {Function} opts.onReady - called after worksheet loaded: (pages, mmToPx) => void * @param {Function} [opts.serialize] - fn(changesOnly) => config object * @param {Function} [opts.onReset] - fn(pageNum) => void */ function init(opts) { const params = new URLSearchParams(location.search); const fileParam = params.get('file'); if (!fileParam) { document.getElementById('worksheet-container').innerHTML = '
No file specified. Use ?file=<docId>
'; return null; } _config = { taskType: opts.taskType || '', fileParam: fileParam, docId: fileParam, filePath: 'docs/' + fileParam + '.template.html', }; if (opts.serialize) _serializeFn = opts.serialize; // Wire standard toolbar buttons _wireButton('btn-prev', () => scrollToPage(_currentPage - 1)); _wireButton('btn-next', () => scrollToPage(_currentPage + 1)); _wireButton('btn-copy', () => _copyConfig(false)); _wireButton('btn-copy-changes', () => _copyConfig(true)); _wireButton('btn-save', _saveToServer); if (opts.onReset) { _wireButton('btn-reset', () => opts.onReset(_currentPage + 1)); } // Load worksheet, then optionally load data.json _loadWorksheet(_config.filePath, opts.onReady, opts.onDataLoaded); return { get pages() { return _pages; }, get currentPage() { return _currentPage; }, get mmToPx() { return _mmToPx; }, get docId() { return _config.docId; }, scrollToPage, showToast, showTooltip, hideTooltip, setSerializer: (fn) => { _serializeFn = fn; }, loadData: () => _loadDataJson(_config.fileParam), }; } // ---- Worksheet Loading ---- function _loadWorksheet(filePath, onReady, onDataLoaded) { fetch(filePath) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const container = document.getElementById('worksheet-container'); // Tailwind CDN const tw = document.createElement('script'); tw.src = 'https://cdn.tailwindcss.com'; document.head.appendChild(tw); // Copy head elements doc.querySelectorAll('head script:not([src])').forEach(s => { const ns = document.createElement('script'); ns.textContent = s.textContent; document.head.appendChild(ns); }); doc.querySelectorAll('head style').forEach(s => { const ns = document.createElement('style'); ns.textContent = s.textContent; document.head.appendChild(ns); }); doc.querySelectorAll('head link').forEach(l => { document.head.appendChild(l.cloneNode(true)); }); container.innerHTML = doc.body.innerHTML; // Wait for Tailwind, then init pages and call onReady setTimeout(() => { _initPages(); if (onReady) onReady(_pages, _mmToPx); // Load data.json after onReady so DOM references are set up if (onDataLoaded) { _loadDataJson(_config.fileParam).then(data => { onDataLoaded(data, _pages, _mmToPx); }); } }, 350); }) .catch(err => { document.getElementById('worksheet-container').innerHTML = `Failed to load ${filePath}: ${err.message}
`; }); } async function _loadDataJson(docId) { try { const resp = await fetch('docs/' + docId + '.data.json'); if (!resp.ok) return null; return await resp.json(); } catch (e) { return null; } } // ---- Pages ---- function _initPages() { _pages = Array.from(document.querySelectorAll('.w-\\[210mm\\]')); _pages.forEach((p, i) => { p.dataset.page = i + 1; p.style.position = 'relative'; const label = document.createElement('div'); label.className = 'page-label'; label.textContent = `Page ${i + 1}`; p.appendChild(label); }); document.getElementById('page-indicator').textContent = `Page 1 / ${_pages.length}`; if (_pages.length > 0) { _mmToPx = _pages[0].getBoundingClientRect().width / 210; } // Scroll tracking const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && entry.intersectionRatio > 0.3) { _currentPage = parseInt(entry.target.dataset.page) - 1; document.getElementById('page-indicator').textContent = `Page ${_currentPage + 1} / ${_pages.length}`; } }); }, { threshold: [0.3] }); _pages.forEach(p => observer.observe(p)); // Resize handler window.addEventListener('resize', () => { if (_pages.length > 0) _mmToPx = _pages[0].getBoundingClientRect().width / 210; }); } function scrollToPage(index) { if (index < 0 || index >= _pages.length) return; _pages[index].scrollIntoView({ behavior: 'smooth', block: 'start' }); } // ---- Toast ---- function showToast(msg) { const toast = document.getElementById('toast'); if (!toast) return; toast.textContent = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ---- Tooltip ---- function showTooltip(e, text) { const tooltip = document.getElementById('coord-tooltip'); if (!tooltip) return; tooltip.style.display = 'block'; tooltip.textContent = text; tooltip.style.left = (e.clientX + 15) + 'px'; tooltip.style.top = (e.clientY - 10) + 'px'; } function hideTooltip() { const tooltip = document.getElementById('coord-tooltip'); if (tooltip) tooltip.style.display = 'none'; } // ---- Save / Copy ---- async function _saveToServer() { if (!_serializeFn) { showToast('No serializer'); return; } const data = _serializeFn(false); try { const resp = await fetch('/api/save-edits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskType: _config.taskType, docId: _config.docId, data: data }) }); const result = await resp.json(); showToast(result.ok ? 'Saved!' : 'Error: ' + result.error); } catch (e) { showToast('Save failed: ' + e.message); } } function _copyConfig(changesOnly) { if (!_serializeFn) { showToast('No serializer'); return; } const data = _serializeFn(changesOnly); const json = JSON.stringify(data, null, 2); navigator.clipboard.writeText(json).then(() => { showToast(changesOnly ? 'Changes copied!' : 'Config copied!'); }).catch(() => { console.log(json); showToast('Copied to console (clipboard blocked)'); }); } // ---- Helpers ---- function _wireButton(id, handler) { const btn = document.getElementById(id); if (btn) btn.addEventListener('click', handler); } return { init }; })();