diff --git a/CLAUDE.md b/CLAUDE.md index 2a734b4..adf89f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,12 +127,82 @@ http://localhost:3300/tasks/{type}/editor.html?file={docId} 4. Server computes diff → writes `temp/{docId}.diff.json` 5. Server runs `generate.mjs` → regenerates output.html + screenshots +**Browser-sync reload protection:** Editor pages are not reloaded when `output.html` changes (removed from watch list). The server also deduplicates rapid-fire save requests (browser-sync quirk: may relay POSTs twice). + **Claude reviewing editor changes:** ```bash cat tasks/{type}/temp/{docId}.diff.json # see what changed # Read screenshot PNGs from tasks/{type}/temp/{docId}-page-{N}.png to verify visually ``` +### Building a New Editor + +Reference implementation: `tasks/asteroid-splitting/editor.html` (simplest, cleanest pattern). + +**1. HTML scaffold** — copy toolbar/statusbar/container from asteroid-splitting editor: +- `#toolbar` with btn-prev, btn-next, btn-reset, btn-copy, btn-copy-changes, btn-save +- `#worksheet-container` (EditorCore fills this) +- `#statusbar`, `#toast`, `#coord-tooltip` +- CSS for `.editor-selected`, `.editor-changed`, toolbar, page labels + +**2. Load editor-core.js:** +```html + +``` + +**3. Initialize EditorCore** (wrap everything in IIFE): +```javascript +(function() { + 'use strict'; + const core = EditorCore.init({ + taskType: 'your-type-name', + serialize: buildConfig, // required: (changesOnly) => data object + onReset: resetCurrentPage, // optional: (pageNum) => void + onReady(pages, mmToPx) { // setup DOM refs, event handlers + findElements(pages); + setupClickHandlers(); + setupKeyboard(); + }, + onDataLoaded(data) { // apply saved data.json to DOM + if (data) applyData(data); + } + }); + // ... function implementations +})(); +``` + +**4. State management pattern:** +- Store original element state in a `Map` on init (for change detection + reset) +- Track in-memory references to editable elements in arrays +- Read current state from DOM when serializing +- Use `markChanged(el)` to toggle `.editor-changed` class by comparing to original + +**5. Serialization:** +- `buildConfig(changesOnly)` returns `{ pages: [{ page: N, ...task-specific fields }] }` +- When `changesOnly=true`, skip elements matching original state +- EditorCore calls this for Save and Copy + +**6. Do NOT:** +- Use synchronous XHR (use EditorCore's async save) +- Bypass EditorCore's save (no manual fetch to `/api/save-edits`) +- Add duplicate event listeners for Save/Copy buttons (core wires them) +- Use `var` (use `const`/`let`) +- Store state only in DOM without in-memory backup + +**7. Element identification in templates:** +- For complex/reorderable elements: use `data-*` attributes (see space-route `data-node-id`) +- For simple sequential elements: index-based matching is fine +- Match the strategy in your `generate.mjs` + +**8. Mandatory e2e verification** (use Chrome DevTools MCP): +1. Open editor → verify worksheet loads and sections are found +2. Click element → verify selection highlight and status info +3. Modify via keyboard → verify DOM and info update +4. Click Save → verify toast "Saved!" and no page reload +5. Read `data.json` → verify saved values match editor state +6. Run `generate.mjs` → verify transforms in `output.html` +7. Reload editor → verify saved state restored via `onDataLoaded` + ## Preview Pages Structure Three-level navigation hierarchy, maintained manually by Claude: diff --git a/bs-config.cjs b/bs-config.cjs index 944aedc..7552e52 100644 --- a/bs-config.cjs +++ b/bs-config.cjs @@ -2,6 +2,9 @@ const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); +// Deduplicate rapid-fire save requests (browser-sync may relay POSTs twice) +const _lastSave = {}; + module.exports = { server: { baseDir: "." @@ -10,7 +13,7 @@ module.exports = { "tasks/index.html", "tasks/*/index.html", "tasks/*/docs/*.template.html", - "tasks/*/docs/*.output.html", + // output.html excluded: generated by save flow, reload would wipe editor state "tasks/*/editor.html", "assets/**/*", "public/**/*" @@ -48,6 +51,17 @@ module.exports = { return; } + // Deduplicate: skip if same docId saved within 500ms + const saveKey = `${taskType}/${docId}`; + const now = Date.now(); + if (_lastSave[saveKey] && now - _lastSave[saveKey].time < 500) { + console.log(`[save] dedup skip ${saveKey} (${now - _lastSave[saveKey].time}ms since last)`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, dedup: true })); + return; + } + _lastSave[saveKey] = { time: now, body: body.length }; + const docsDir = path.join(__dirname, 'tasks', taskType, 'docs'); const tempDir = path.join(__dirname, 'tasks', taskType, 'temp'); fs.mkdirSync(tempDir, { recursive: true }); diff --git a/output/pdf/asteroid-splitting-1.pdf b/output/pdf/asteroid-splitting-1.pdf new file mode 100644 index 0000000..6450cd7 Binary files /dev/null and b/output/pdf/asteroid-splitting-1.pdf differ diff --git a/src/editor/editor-core.js b/src/editor/editor-core.js index cd0c0aa..d6a5ddd 100644 --- a/src/editor/editor-core.js +++ b/src/editor/editor-core.js @@ -59,8 +59,8 @@ window.EditorCore = (function () { _wireButton('btn-reset', () => opts.onReset(_currentPage + 1)); } - // Load worksheet - _loadWorksheet(_config.filePath, opts.onReady); + // Load worksheet, then optionally load data.json + _loadWorksheet(_config.filePath, opts.onReady, opts.onDataLoaded); return { get pages() { return _pages; }, @@ -72,12 +72,13 @@ window.EditorCore = (function () { showTooltip, hideTooltip, setSerializer: (fn) => { _serializeFn = fn; }, + loadData: () => _loadDataJson(_config.fileParam), }; } // ---- Worksheet Loading ---- - function _loadWorksheet(filePath, onReady) { + function _loadWorksheet(filePath, onReady, onDataLoaded) { fetch(filePath) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { @@ -111,6 +112,12 @@ window.EditorCore = (function () { 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 => { @@ -119,6 +126,16 @@ window.EditorCore = (function () { }); } + 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() { diff --git a/src/scripts/generate-pdf.mjs b/src/scripts/generate-pdf.mjs index 0e89d81..1b40028 100644 --- a/src/scripts/generate-pdf.mjs +++ b/src/scripts/generate-pdf.mjs @@ -1,9 +1,20 @@ import puppeteer from 'puppeteer'; -import { resolve, basename } from 'path'; +import { resolve, basename, dirname } from 'path'; import { existsSync, mkdirSync } from 'fs'; import { fileURLToPath } from 'url'; +import { createServer } from 'http'; +import { readFile } from 'fs/promises'; +import { extname } from 'path'; -const OUTPUT_DIR = resolve(fileURLToPath(import.meta.url), '../../../output/pdf'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname, '../..'); +const OUTPUT_DIR = resolve(PROJECT_ROOT, 'output/pdf'); + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json', +}; async function generatePdf(htmlPath) { const absolutePath = resolve(htmlPath); @@ -14,13 +25,33 @@ async function generatePdf(htmlPath) { mkdirSync(OUTPUT_DIR, { recursive: true }); - const pdfName = basename(absolutePath, '.html') + '.pdf'; + // Strip .output and .template from PDF filename + let pdfName = basename(absolutePath, '.html'); + pdfName = pdfName.replace(/\.(output|template)$/, '') + '.pdf'; const pdfPath = resolve(OUTPUT_DIR, pdfName); - const browser = await puppeteer.launch({ headless: true }); + // Start temp HTTP server to resolve /assets/ paths correctly + const server = createServer(async (req, res) => { + const filePath = resolve(PROJECT_ROOT, decodeURIComponent(req.url).replace(/^\//, '')); + try { + const data = await readFile(filePath); + const ext = extname(filePath); + res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' }); + res.end(data); + } catch { + res.writeHead(404); + res.end('Not found'); + } + }); + + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + const relPath = absolutePath.replace(PROJECT_ROOT + '/', ''); + + const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); - await page.goto(`file://${absolutePath}`, { waitUntil: 'networkidle0' }); + await page.goto(`http://127.0.0.1:${port}/${relPath}`, { waitUntil: 'networkidle0', timeout: 30000 }); await page.pdf({ path: pdfPath, @@ -31,6 +62,7 @@ async function generatePdf(htmlPath) { }); await browser.close(); + server.close(); console.log(`PDF generated: ${pdfPath}`); return pdfPath; } diff --git a/tasks/asteroid-splitting/CLAUDE.md b/tasks/asteroid-splitting/CLAUDE.md index 9c19a70..d0f281d 100644 --- a/tasks/asteroid-splitting/CLAUDE.md +++ b/tasks/asteroid-splitting/CLAUDE.md @@ -124,11 +124,10 @@ The editor adjusts the **scale and rotation** of asteroid images in each section ### Data flow -- Editor reads `.template.html` (via EditorCore) -- On load, fetches `.data.json` and applies transforms to DOM (`img.style.transform`) -- On Save, reads transforms FROM DOM → sync XHR POST → server writes `.data.json` +- Editor reads `.template.html` (via EditorCore) and `.data.json` (via `onDataLoaded`) +- On load, applies saved transforms to DOM (`img.style.transform`) +- Original transforms stored in Map for change detection and reset +- On Save, serializes transforms from DOM → async POST via EditorCore → server writes `.data.json` - `generate.mjs` reads `.data.json`, applies `style="transform: ..."` to img elements in `.output.html` -### Status: BROKEN - -The editor has a reliability problem with the save cycle. The server's save endpoint runs `generate.mjs` asynchronously after writing `data.json`. `generate.mjs` writes `.output.html`, which triggers browser-sync live-reload. The reload can interfere with the save flow, causing data loss or incomplete saves. This needs to be investigated and fixed before the editor can be used reliably. +This editor serves as the **reference implementation** for building new task-type editors (see root CLAUDE.md "Building a New Editor"). diff --git a/tasks/asteroid-splitting/docs/asteroid-splitting-1.data.json b/tasks/asteroid-splitting/docs/asteroid-splitting-1.data.json index 1095e44..0b3f48a 100644 --- a/tasks/asteroid-splitting/docs/asteroid-splitting-1.data.json +++ b/tasks/asteroid-splitting/docs/asteroid-splitting-1.data.json @@ -65,8 +65,8 @@ "sections": [ { "index": 0, - "scale": 1, - "rotate": 0 + "scale": 1.2, + "rotate": 10 }, { "index": 1, @@ -150,8 +150,8 @@ }, { "index": 1, - "scale": 1, - "rotate": 0 + "scale": 2, + "rotate": 30 }, { "index": 2, @@ -165,8 +165,8 @@ "sections": [ { "index": 0, - "scale": 1, - "rotate": 0 + "scale": 2, + "rotate": 30 }, { "index": 1, diff --git a/tasks/asteroid-splitting/editor.html b/tasks/asteroid-splitting/editor.html index a0da146..e3f1f32 100644 --- a/tasks/asteroid-splitting/editor.html +++ b/tasks/asteroid-splitting/editor.html @@ -19,7 +19,6 @@ #toolbar button:hover { background: #0f3460; } #toolbar button.primary { background: #533483; border-color: #7b2d8e; } #toolbar button.save { background: #1b8a5a; border-color: #239b6e; } - #toolbar button[disabled] { opacity: 0.4; cursor: not-allowed; } #page-indicator { color: #a0a0c0; font-weight: 600; min-width: 100px; text-align: center; } .spacer { flex: 1; } #statusbar { @@ -37,6 +36,7 @@ padding: 2px 12px; border-radius: 4px; } .editor-selected { outline: 3px solid #7c3aed !important; outline-offset: 4px; } + .editor-changed { position: relative; } .editor-changed::after { content: ''; position: absolute; top: -2px; right: -2px; width: 8px; height: 8px; background: #f97316; border-radius: 50%; z-index: 10; @@ -63,8 +63,10 @@
Loading... + - + +