fix: editor
This commit is contained in:
parent
c828bf7767
commit
994a13da52
70
CLAUDE.md
70
CLAUDE.md
|
|
@ -127,12 +127,82 @@ http://localhost:3300/tasks/{type}/editor.html?file={docId}
|
||||||
4. Server computes diff → writes `temp/{docId}.diff.json`
|
4. Server computes diff → writes `temp/{docId}.diff.json`
|
||||||
5. Server runs `generate.mjs` → regenerates output.html + screenshots
|
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:**
|
**Claude reviewing editor changes:**
|
||||||
```bash
|
```bash
|
||||||
cat tasks/{type}/temp/{docId}.diff.json # see what changed
|
cat tasks/{type}/temp/{docId}.diff.json # see what changed
|
||||||
# Read screenshot PNGs from tasks/{type}/temp/{docId}-page-{N}.png to verify visually
|
# 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
|
## Preview Pages Structure
|
||||||
|
|
||||||
Three-level navigation hierarchy, maintained manually by Claude:
|
Three-level navigation hierarchy, maintained manually by Claude:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
|
|
||||||
|
// Deduplicate rapid-fire save requests (browser-sync may relay POSTs twice)
|
||||||
|
const _lastSave = {};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
server: {
|
server: {
|
||||||
baseDir: "."
|
baseDir: "."
|
||||||
|
|
@ -10,7 +13,7 @@ module.exports = {
|
||||||
"tasks/index.html",
|
"tasks/index.html",
|
||||||
"tasks/*/index.html",
|
"tasks/*/index.html",
|
||||||
"tasks/*/docs/*.template.html",
|
"tasks/*/docs/*.template.html",
|
||||||
"tasks/*/docs/*.output.html",
|
// output.html excluded: generated by save flow, reload would wipe editor state
|
||||||
"tasks/*/editor.html",
|
"tasks/*/editor.html",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"public/**/*"
|
"public/**/*"
|
||||||
|
|
@ -48,6 +51,17 @@ module.exports = {
|
||||||
return;
|
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 docsDir = path.join(__dirname, 'tasks', taskType, 'docs');
|
||||||
const tempDir = path.join(__dirname, 'tasks', taskType, 'temp');
|
const tempDir = path.join(__dirname, 'tasks', taskType, 'temp');
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -59,8 +59,8 @@ window.EditorCore = (function () {
|
||||||
_wireButton('btn-reset', () => opts.onReset(_currentPage + 1));
|
_wireButton('btn-reset', () => opts.onReset(_currentPage + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load worksheet
|
// Load worksheet, then optionally load data.json
|
||||||
_loadWorksheet(_config.filePath, opts.onReady);
|
_loadWorksheet(_config.filePath, opts.onReady, opts.onDataLoaded);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get pages() { return _pages; },
|
get pages() { return _pages; },
|
||||||
|
|
@ -72,12 +72,13 @@ window.EditorCore = (function () {
|
||||||
showTooltip,
|
showTooltip,
|
||||||
hideTooltip,
|
hideTooltip,
|
||||||
setSerializer: (fn) => { _serializeFn = fn; },
|
setSerializer: (fn) => { _serializeFn = fn; },
|
||||||
|
loadData: () => _loadDataJson(_config.fileParam),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worksheet Loading ----
|
// ---- Worksheet Loading ----
|
||||||
|
|
||||||
function _loadWorksheet(filePath, onReady) {
|
function _loadWorksheet(filePath, onReady, onDataLoaded) {
|
||||||
fetch(filePath)
|
fetch(filePath)
|
||||||
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
|
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||||
.then(html => {
|
.then(html => {
|
||||||
|
|
@ -111,6 +112,12 @@ window.EditorCore = (function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_initPages();
|
_initPages();
|
||||||
if (onReady) onReady(_pages, _mmToPx);
|
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);
|
}, 350);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.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 ----
|
// ---- Pages ----
|
||||||
|
|
||||||
function _initPages() {
|
function _initPages() {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { resolve, basename } from 'path';
|
import { resolve, basename, dirname } from 'path';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
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) {
|
async function generatePdf(htmlPath) {
|
||||||
const absolutePath = resolve(htmlPath);
|
const absolutePath = resolve(htmlPath);
|
||||||
|
|
@ -14,13 +25,33 @@ async function generatePdf(htmlPath) {
|
||||||
|
|
||||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
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 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();
|
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({
|
await page.pdf({
|
||||||
path: pdfPath,
|
path: pdfPath,
|
||||||
|
|
@ -31,6 +62,7 @@ async function generatePdf(htmlPath) {
|
||||||
});
|
});
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
server.close();
|
||||||
console.log(`PDF generated: ${pdfPath}`);
|
console.log(`PDF generated: ${pdfPath}`);
|
||||||
return pdfPath;
|
return pdfPath;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,10 @@ The editor adjusts the **scale and rotation** of asteroid images in each section
|
||||||
|
|
||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
- Editor reads `.template.html` (via EditorCore)
|
- Editor reads `.template.html` (via EditorCore) and `.data.json` (via `onDataLoaded`)
|
||||||
- On load, fetches `.data.json` and applies transforms to DOM (`img.style.transform`)
|
- On load, applies saved transforms to DOM (`img.style.transform`)
|
||||||
- On Save, reads transforms FROM DOM → sync XHR POST → server writes `.data.json`
|
- 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`
|
- `generate.mjs` reads `.data.json`, applies `style="transform: ..."` to img elements in `.output.html`
|
||||||
|
|
||||||
### Status: BROKEN
|
This editor serves as the **reference implementation** for building new task-type editors (see root CLAUDE.md "Building a New Editor").
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"scale": 1,
|
"scale": 1.2,
|
||||||
"rotate": 0
|
"rotate": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
|
|
@ -150,8 +150,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
"scale": 1,
|
"scale": 2,
|
||||||
"rotate": 0
|
"rotate": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 2,
|
"index": 2,
|
||||||
|
|
@ -165,8 +165,8 @@
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"scale": 1,
|
"scale": 2,
|
||||||
"rotate": 0
|
"rotate": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
#toolbar button:hover { background: #0f3460; }
|
#toolbar button:hover { background: #0f3460; }
|
||||||
#toolbar button.primary { background: #533483; border-color: #7b2d8e; }
|
#toolbar button.primary { background: #533483; border-color: #7b2d8e; }
|
||||||
#toolbar button.save { background: #1b8a5a; border-color: #239b6e; }
|
#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; }
|
#page-indicator { color: #a0a0c0; font-weight: 600; min-width: 100px; text-align: center; }
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
#statusbar {
|
#statusbar {
|
||||||
|
|
@ -37,6 +36,7 @@
|
||||||
padding: 2px 12px; border-radius: 4px;
|
padding: 2px 12px; border-radius: 4px;
|
||||||
}
|
}
|
||||||
.editor-selected { outline: 3px solid #7c3aed !important; outline-offset: 4px; }
|
.editor-selected { outline: 3px solid #7c3aed !important; outline-offset: 4px; }
|
||||||
|
.editor-changed { position: relative; }
|
||||||
.editor-changed::after {
|
.editor-changed::after {
|
||||||
content: ''; position: absolute; top: -2px; right: -2px;
|
content: ''; position: absolute; top: -2px; right: -2px;
|
||||||
width: 8px; height: 8px; background: #f97316; border-radius: 50%; z-index: 10;
|
width: 8px; height: 8px; background: #f97316; border-radius: 50%; z-index: 10;
|
||||||
|
|
@ -63,8 +63,10 @@
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<span id="selection-info" style="color: #c4b5fd; font-size: 12px;">Loading...</span>
|
<span id="selection-info" style="color: #c4b5fd; font-size: 12px;">Loading...</span>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<button id="btn-reset">Reset Page</button>
|
||||||
<button id="btn-copy" class="primary">Copy JSON</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>
|
||||||
<div id="worksheet-container"></div>
|
<div id="worksheet-container"></div>
|
||||||
<div id="statusbar">
|
<div id="statusbar">
|
||||||
|
|
@ -75,21 +77,19 @@
|
||||||
|
|
||||||
<script src="../../src/editor/editor-core.js"></script>
|
<script src="../../src/editor/editor-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ============================================================
|
(function() {
|
||||||
// Simple approach: ALL state lives in the DOM (img.style.transform).
|
'use strict';
|
||||||
// No in-memory state. buildConfig reads from DOM.
|
|
||||||
// Like collecting-asteroids editor pattern.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
var sectionEls = []; // [{container, bigImg, smallImgs, pageNum, secIndex}]
|
const sectionEls = []; // [{container, bigImg, smallImgs, pageNum, secIndex}]
|
||||||
var selected = null; // currently selected section
|
const originalTransforms = new Map(); // "pageNum-secIndex" → {scale, rotate}
|
||||||
var ready = false;
|
let selected = null;
|
||||||
|
|
||||||
|
// --- Transform helpers ---
|
||||||
|
|
||||||
// --- Parse transform string from DOM ---
|
|
||||||
function parseTransform(img) {
|
function parseTransform(img) {
|
||||||
var t = img.style.transform || '';
|
const t = img.style.transform || '';
|
||||||
var sm = t.match(/scale\(([\d.]+)\)/);
|
const sm = t.match(/scale\(([\d.]+)\)/);
|
||||||
var rm = t.match(/rotate\(([-\d.]+)deg\)/);
|
const rm = t.match(/rotate\(([-\d.]+)deg\)/);
|
||||||
return {
|
return {
|
||||||
scale: sm ? parseFloat(sm[1]) : 1,
|
scale: sm ? parseFloat(sm[1]) : 1,
|
||||||
rotate: rm ? parseFloat(rm[1]) : 0
|
rotate: rm ? parseFloat(rm[1]) : 0
|
||||||
|
|
@ -97,29 +97,42 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTransform(img, scale, rotate) {
|
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 ---
|
// --- Find all sections in loaded pages ---
|
||||||
|
|
||||||
function findSections(pages) {
|
function findSections(pages) {
|
||||||
sectionEls = [];
|
sectionEls.length = 0;
|
||||||
pages.forEach(function(page, pi) {
|
pages.forEach(function(page, pi) {
|
||||||
var glows = page.querySelectorAll('.asteroid-glow');
|
const glows = page.querySelectorAll('.asteroid-glow');
|
||||||
glows.forEach(function(container, si) {
|
glows.forEach(function(container, si) {
|
||||||
var bigImg = container.querySelector('img');
|
const bigImg = container.querySelector('img');
|
||||||
var sectionRow = container.parentElement;
|
// Walk up to find the section row containing formula small imgs
|
||||||
|
let sectionRow = container.parentElement;
|
||||||
while (sectionRow && !sectionRow.classList.contains('gap-[8mm]')) {
|
while (sectionRow && !sectionRow.classList.contains('gap-[8mm]')) {
|
||||||
sectionRow = sectionRow.parentElement;
|
sectionRow = sectionRow.parentElement;
|
||||||
}
|
}
|
||||||
if (!sectionRow) sectionRow = container.parentElement;
|
if (!sectionRow) sectionRow = container.parentElement;
|
||||||
|
|
||||||
var allImgs = sectionRow.querySelectorAll('img[src*="pack3-asteroids"]');
|
const allImgs = sectionRow.querySelectorAll('img[src*="pack3-asteroids"]');
|
||||||
var smallImgs = [];
|
const smallImgs = [];
|
||||||
allImgs.forEach(function(img) { if (img !== bigImg) smallImgs.push(img); });
|
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);
|
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.style.cursor = 'pointer';
|
||||||
container.addEventListener('click', function(e) {
|
container.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
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) {
|
function selectSection(sec) {
|
||||||
if (selected) selected.container.classList.remove('editor-selected');
|
deselectAll();
|
||||||
selected = sec;
|
selected = sec;
|
||||||
sec.container.classList.add('editor-selected');
|
sec.container.classList.add('editor-selected');
|
||||||
showInfo();
|
updateInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showInfo() {
|
function deselectAll() {
|
||||||
var el = document.getElementById('selection-info');
|
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; }
|
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) +
|
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) {
|
function applyToSection(sec, scale, rotate) {
|
||||||
setTransform(sec.bigImg, scale, rotate);
|
setTransform(sec.bigImg, scale, rotate);
|
||||||
sec.smallImgs.forEach(function(img) { setTransform(img, scale, rotate); });
|
sec.smallImgs.forEach(function(img) { setTransform(img, scale, rotate); });
|
||||||
sec.container.classList.toggle('editor-changed', scale !== 1 || rotate !== 0);
|
markChanged(sec);
|
||||||
showInfo();
|
updateInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Load data.json and apply transforms to DOM ---
|
// --- Change tracking ---
|
||||||
function loadData(docId, cb) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
function markChanged(sec) {
|
||||||
xhr.open('GET', 'docs/' + docId + '.data.json', true);
|
const orig = originalTransforms.get(secKey(sec));
|
||||||
xhr.onload = function() {
|
const cur = parseTransform(sec.bigImg);
|
||||||
if (xhr.status === 200) {
|
const changed = Math.abs(cur.scale - orig.scale) > 0.001 || Math.abs(cur.rotate - orig.rotate) > 0.1;
|
||||||
try {
|
sec.container.classList.toggle('editor-changed', changed);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Serialize: read ALL transforms from DOM ---
|
// --- Apply data.json to DOM (called by EditorCore.onDataLoaded) ---
|
||||||
function buildConfig() {
|
|
||||||
var pagesMap = {};
|
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) {
|
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: [] };
|
if (!pagesMap[sec.pageNum]) pagesMap[sec.pageNum] = { page: sec.pageNum, sections: [] };
|
||||||
pagesMap[sec.pageNum].sections.push({
|
pagesMap[sec.pageNum].sections.push({
|
||||||
index: sec.secIndex,
|
index: sec.secIndex,
|
||||||
scale: +t.scale.toFixed(3),
|
scale: +cur.scale.toFixed(3),
|
||||||
rotate: +t.rotate.toFixed(1)
|
rotate: +cur.rotate.toFixed(1)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { pages: Object.values(pagesMap) };
|
return { pages: Object.values(pagesMap) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Save: sync XHR, read from DOM ---
|
// --- Reset ---
|
||||||
function saveToServer() {
|
|
||||||
if (!ready) return;
|
function resetCurrentPage(pageNum) {
|
||||||
var data = buildConfig();
|
sectionEls.forEach(function(sec) {
|
||||||
try {
|
if (sec.pageNum !== pageNum) return;
|
||||||
var xhr = new XMLHttpRequest();
|
const orig = originalTransforms.get(secKey(sec));
|
||||||
xhr.open('POST', '/api/save-edits', false);
|
applyToSection(sec, orig.scale, orig.rotate);
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
sec.container.classList.remove('editor-changed');
|
||||||
xhr.send(JSON.stringify({ taskType: 'asteroid-splitting', docId: core.docId, data: data }));
|
});
|
||||||
core.showToast(xhr.status === 200 ? 'Saved!' : 'Error: ' + xhr.statusText);
|
if (selected && selected.pageNum === pageNum) updateInfo();
|
||||||
} catch(e) {
|
|
||||||
core.showToast('Save failed: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Keyboard ---
|
// --- Keyboard ---
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') { deselectAll(); return; }
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
var t = parseTransform(selected.bigImg);
|
|
||||||
var step = e.shiftKey ? 0.01 : 0.05;
|
const t = parseTransform(selected.bigImg);
|
||||||
var rotStep = e.shiftKey ? 1 : 5;
|
const step = e.shiftKey ? 0.01 : 0.05;
|
||||||
|
const rotStep = e.shiftKey ? 1 : 5;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case '+': case '=':
|
case '+': case '=':
|
||||||
applyToSection(selected, Math.min(2, +(t.scale + step).toFixed(3)), t.rotate);
|
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);
|
applyToSection(selected, Math.max(0.3, +(t.scale - step).toFixed(3)), t.rotate);
|
||||||
e.preventDefault(); break;
|
e.preventDefault(); break;
|
||||||
case '[':
|
case '[':
|
||||||
applyToSection(selected, t.scale, t.rotate - rotStep);
|
applyToSection(selected, t.scale, +(t.rotate - rotStep).toFixed(1));
|
||||||
e.preventDefault(); break;
|
e.preventDefault(); break;
|
||||||
case ']':
|
case ']':
|
||||||
applyToSection(selected, t.scale, t.rotate + rotStep);
|
applyToSection(selected, t.scale, +(t.rotate + rotStep).toFixed(1));
|
||||||
e.preventDefault(); break;
|
e.preventDefault(); break;
|
||||||
case '0':
|
case '0':
|
||||||
applyToSection(selected, 1, 0);
|
applyToSection(selected, 1, 0);
|
||||||
e.preventDefault(); break;
|
e.preventDefault(); break;
|
||||||
case 'Escape':
|
|
||||||
if (selected) { selected.container.classList.remove('editor-selected'); selected = null; showInfo(); }
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (selected && !e.target.closest('.asteroid-glow')) {
|
if (selected && !e.target.closest('.asteroid-glow')) {
|
||||||
selected.container.classList.remove('editor-selected');
|
deselectAll();
|
||||||
selected = null; showInfo();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init via EditorCore ---
|
||||||
// NOTE: do NOT pass serialize to EditorCore — we handle Save ourselves
|
|
||||||
// to avoid double-handler (EditorCore's async + ours sync) race condition
|
const core = EditorCore.init({
|
||||||
var core = EditorCore.init({
|
|
||||||
taskType: 'asteroid-splitting',
|
taskType: 'asteroid-splitting',
|
||||||
|
serialize: buildConfig,
|
||||||
|
onReset: resetCurrentPage,
|
||||||
onReady: function(pages) {
|
onReady: function(pages) {
|
||||||
findSections(pages);
|
findSections(pages);
|
||||||
document.getElementById('status-text').textContent =
|
},
|
||||||
sectionEls.length + ' sections. Keys: +/- scale \u2022 [/] rotate (Shift=fine) \u2022 0 reset';
|
onDataLoaded: function(data) {
|
||||||
// Load saved data, then enable Save
|
if (data) applyData(data);
|
||||||
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!'); });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue