fix: editor

This commit is contained in:
Oleg Proskurin 2026-04-22 20:20:09 +07:00
parent c828bf7767
commit 994a13da52
8 changed files with 286 additions and 127 deletions

View File

@ -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
<script src="../../src/editor/editor-core.js"></script>
```
**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:

View File

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

Binary file not shown.

View File

@ -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() {

View File

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

View File

@ -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").

View File

@ -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,

View File

@ -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 @@
<div class="spacer"></div>
<span id="selection-info" style="color: #c4b5fd; font-size: 12px;">Loading...</span>
<div class="spacer"></div>
<button id="btn-reset">Reset Page</button>
<button id="btn-copy" class="primary">Copy JSON</button>
<button id="btn-save" class="save" disabled>Save</button>
<button id="btn-copy-changes" class="primary">Copy Changes</button>
<button id="btn-save" class="save">Save</button>
</div>
<div id="worksheet-container"></div>
<div id="statusbar">
@ -75,21 +77,19 @@
<script src="../../src/editor/editor-core.js"></script>
<script>
// ============================================================
// Simple approach: ALL state lives in the DOM (img.style.transform).
// No in-memory state. buildConfig reads from DOM.
// Like collecting-asteroids editor pattern.
// ============================================================
(function() {
'use strict';
var sectionEls = []; // [{container, bigImg, smallImgs, pageNum, secIndex}]
var selected = null; // currently selected section
var ready = false;
const sectionEls = []; // [{container, bigImg, smallImgs, pageNum, secIndex}]
const originalTransforms = new Map(); // "pageNum-secIndex" → {scale, rotate}
let selected = null;
// --- Transform helpers ---
// --- Parse transform string from DOM ---
function parseTransform(img) {
var t = img.style.transform || '';
var sm = t.match(/scale\(([\d.]+)\)/);
var rm = t.match(/rotate\(([-\d.]+)deg\)/);
const t = img.style.transform || '';
const sm = t.match(/scale\(([\d.]+)\)/);
const rm = t.match(/rotate\(([-\d.]+)deg\)/);
return {
scale: sm ? parseFloat(sm[1]) : 1,
rotate: rm ? parseFloat(rm[1]) : 0
@ -97,29 +97,42 @@
}
function setTransform(img, scale, rotate) {
img.style.transform = 'scale(' + scale + ') rotate(' + rotate + 'deg)';
if (scale === 1 && rotate === 0) {
img.style.transform = '';
} else {
img.style.transform = 'scale(' + scale + ') rotate(' + rotate + 'deg)';
}
}
function secKey(sec) {
return sec.pageNum + '-' + sec.secIndex;
}
// --- Find all sections in loaded pages ---
function findSections(pages) {
sectionEls = [];
sectionEls.length = 0;
pages.forEach(function(page, pi) {
var glows = page.querySelectorAll('.asteroid-glow');
const glows = page.querySelectorAll('.asteroid-glow');
glows.forEach(function(container, si) {
var bigImg = container.querySelector('img');
var sectionRow = container.parentElement;
const bigImg = container.querySelector('img');
// Walk up to find the section row containing formula small imgs
let sectionRow = container.parentElement;
while (sectionRow && !sectionRow.classList.contains('gap-[8mm]')) {
sectionRow = sectionRow.parentElement;
}
if (!sectionRow) sectionRow = container.parentElement;
var allImgs = sectionRow.querySelectorAll('img[src*="pack3-asteroids"]');
var smallImgs = [];
const allImgs = sectionRow.querySelectorAll('img[src*="pack3-asteroids"]');
const smallImgs = [];
allImgs.forEach(function(img) { if (img !== bigImg) smallImgs.push(img); });
var sec = { container: container, bigImg: bigImg, smallImgs: smallImgs, pageNum: pi + 1, secIndex: si };
const sec = { container, bigImg, smallImgs, pageNum: pi + 1, secIndex: si };
sectionEls.push(sec);
// Store original transform (from template, before data.json applied)
originalTransforms.set(secKey(sec), { scale: 1, rotate: 0 });
container.style.cursor = 'pointer';
container.addEventListener('click', function(e) {
e.stopPropagation();
@ -127,94 +140,124 @@
});
});
});
document.getElementById('status-text').textContent =
sectionEls.length + ' sections. Keys: +/- scale \u2022 [/] rotate (Shift=fine) \u2022 0 reset';
document.getElementById('selection-info').textContent = 'Click a big asteroid to select';
}
// --- Selection ---
function selectSection(sec) {
if (selected) selected.container.classList.remove('editor-selected');
deselectAll();
selected = sec;
sec.container.classList.add('editor-selected');
showInfo();
updateInfo();
}
function showInfo() {
var el = document.getElementById('selection-info');
function deselectAll() {
if (selected) {
selected.container.classList.remove('editor-selected');
selected = null;
}
updateInfo();
}
function updateInfo() {
const el = document.getElementById('selection-info');
if (!selected) { el.textContent = 'Click a big asteroid to select'; return; }
var t = parseTransform(selected.bigImg);
const t = parseTransform(selected.bigImg);
el.textContent = 'Page ' + selected.pageNum + ' Sec ' + (selected.secIndex + 1) +
' \u2014 scale: ' + t.scale.toFixed(2) + ' rotate: ' + t.rotate + '\u00B0';
' \u2014 scale: ' + t.scale.toFixed(2) + ' rotate: ' + t.rotate.toFixed(1) + '\u00B0';
}
// --- Apply scale/rotate to big + all small imgs ---
// --- Apply transform to section (big + small imgs) ---
function applyToSection(sec, scale, rotate) {
setTransform(sec.bigImg, scale, rotate);
sec.smallImgs.forEach(function(img) { setTransform(img, scale, rotate); });
sec.container.classList.toggle('editor-changed', scale !== 1 || rotate !== 0);
showInfo();
markChanged(sec);
updateInfo();
}
// --- Load data.json and apply transforms to DOM ---
function loadData(docId, cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'docs/' + docId + '.data.json', true);
xhr.onload = function() {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
var n = 0;
(data.pages || []).forEach(function(p) {
(p.sections || []).forEach(function(s) {
if (s.scale === 1 && s.rotate === 0) return;
var sec = sectionEls.find(function(el) {
return el.pageNum === p.page && el.secIndex === s.index;
});
if (sec) { applyToSection(sec, s.scale, s.rotate); n++; }
});
});
if (n > 0) core.showToast('Loaded ' + n + ' edits');
} catch(e) {}
}
if (cb) cb();
};
xhr.onerror = function() { if (cb) cb(); };
xhr.send();
// --- Change tracking ---
function markChanged(sec) {
const orig = originalTransforms.get(secKey(sec));
const cur = parseTransform(sec.bigImg);
const changed = Math.abs(cur.scale - orig.scale) > 0.001 || Math.abs(cur.rotate - orig.rotate) > 0.1;
sec.container.classList.toggle('editor-changed', changed);
}
// --- Serialize: read ALL transforms from DOM ---
function buildConfig() {
var pagesMap = {};
// --- Apply data.json to DOM (called by EditorCore.onDataLoaded) ---
function applyData(data) {
if (!data || !data.pages) return;
let count = 0;
data.pages.forEach(function(p) {
(p.sections || []).forEach(function(s) {
const sec = sectionEls.find(function(el) {
return el.pageNum === p.page && el.secIndex === s.index;
});
if (sec) {
const scale = s.scale ?? 1;
const rotate = s.rotate ?? 0;
applyToSection(sec, scale, rotate);
// After applying saved data, update originals so "changed" is relative to saved state
originalTransforms.set(secKey(sec), { scale, rotate });
sec.container.classList.remove('editor-changed');
count++;
}
});
});
if (count > 0) core.showToast('Loaded ' + count + ' edits');
}
// --- Serialization ---
function buildConfig(changesOnly) {
const pagesMap = {};
sectionEls.forEach(function(sec) {
var t = parseTransform(sec.bigImg);
const cur = parseTransform(sec.bigImg);
const orig = originalTransforms.get(secKey(sec));
if (changesOnly) {
const changed = Math.abs(cur.scale - orig.scale) > 0.001 || Math.abs(cur.rotate - orig.rotate) > 0.1;
if (!changed) return;
}
if (!pagesMap[sec.pageNum]) pagesMap[sec.pageNum] = { page: sec.pageNum, sections: [] };
pagesMap[sec.pageNum].sections.push({
index: sec.secIndex,
scale: +t.scale.toFixed(3),
rotate: +t.rotate.toFixed(1)
scale: +cur.scale.toFixed(3),
rotate: +cur.rotate.toFixed(1)
});
});
return { pages: Object.values(pagesMap) };
}
// --- Save: sync XHR, read from DOM ---
function saveToServer() {
if (!ready) return;
var data = buildConfig();
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/save-edits', false);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ taskType: 'asteroid-splitting', docId: core.docId, data: data }));
core.showToast(xhr.status === 200 ? 'Saved!' : 'Error: ' + xhr.statusText);
} catch(e) {
core.showToast('Save failed: ' + e.message);
}
// --- Reset ---
function resetCurrentPage(pageNum) {
sectionEls.forEach(function(sec) {
if (sec.pageNum !== pageNum) return;
const orig = originalTransforms.get(secKey(sec));
applyToSection(sec, orig.scale, orig.rotate);
sec.container.classList.remove('editor-changed');
});
if (selected && selected.pageNum === pageNum) updateInfo();
}
// --- Keyboard ---
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { deselectAll(); return; }
if (!selected) return;
var t = parseTransform(selected.bigImg);
var step = e.shiftKey ? 0.01 : 0.05;
var rotStep = e.shiftKey ? 1 : 5;
const t = parseTransform(selected.bigImg);
const step = e.shiftKey ? 0.01 : 0.05;
const rotStep = e.shiftKey ? 1 : 5;
switch (e.key) {
case '+': case '=':
applyToSection(selected, Math.min(2, +(t.scale + step).toFixed(3)), t.rotate);
@ -223,53 +266,37 @@
applyToSection(selected, Math.max(0.3, +(t.scale - step).toFixed(3)), t.rotate);
e.preventDefault(); break;
case '[':
applyToSection(selected, t.scale, t.rotate - rotStep);
applyToSection(selected, t.scale, +(t.rotate - rotStep).toFixed(1));
e.preventDefault(); break;
case ']':
applyToSection(selected, t.scale, t.rotate + rotStep);
applyToSection(selected, t.scale, +(t.rotate + rotStep).toFixed(1));
e.preventDefault(); break;
case '0':
applyToSection(selected, 1, 0);
e.preventDefault(); break;
case 'Escape':
if (selected) { selected.container.classList.remove('editor-selected'); selected = null; showInfo(); }
break;
}
});
document.addEventListener('click', function(e) {
if (selected && !e.target.closest('.asteroid-glow')) {
selected.container.classList.remove('editor-selected');
selected = null; showInfo();
deselectAll();
}
});
// --- Init ---
// NOTE: do NOT pass serialize to EditorCore — we handle Save ourselves
// to avoid double-handler (EditorCore's async + ours sync) race condition
var core = EditorCore.init({
// --- Init via EditorCore ---
const core = EditorCore.init({
taskType: 'asteroid-splitting',
serialize: buildConfig,
onReset: resetCurrentPage,
onReady: function(pages) {
findSections(pages);
document.getElementById('status-text').textContent =
sectionEls.length + ' sections. Keys: +/- scale \u2022 [/] rotate (Shift=fine) \u2022 0 reset';
// Load saved data, then enable Save
loadData(core.docId, function() {
ready = true;
document.getElementById('btn-save').disabled = false;
document.getElementById('selection-info').textContent = 'Click a big asteroid to select';
});
// Wire Save button directly (EditorCore won't touch it without serialize)
document.getElementById('btn-save').addEventListener('click', function(e) {
saveToServer();
});
// Wire Copy JSON
document.getElementById('btn-copy').addEventListener('click', function(e) {
var json = JSON.stringify(buildConfig(), null, 2);
navigator.clipboard.writeText(json).then(function() { core.showToast('Copied!'); });
});
},
onDataLoaded: function(data) {
if (data) applyData(data);
}
});
})();
</script>
</body>
</html>