fix: editor

This commit is contained in:
2026-04-22 20:20:09 +07:00
parent c828bf7767
commit 994a13da52
8 changed files with 286 additions and 127 deletions
+5 -6
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").
@@ -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,
+133 -106
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>