math-tasks/tasks/space-route/editor.html

793 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Route Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1e1e2e; color: #cdd6f4; font-family: 'Segoe UI', system-ui, sans-serif; }
#toolbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
background: #181825; border-bottom: 1px solid #313244;
display: flex; align-items: center; gap: 8px; padding: 8px 12px; height: 48px;
flex-wrap: nowrap; overflow-x: auto;
}
#toolbar button {
background: #313244; color: #cdd6f4; border: 1px solid #45475a;
padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
transition: background 0.15s; white-space: nowrap;
}
#toolbar button:hover { background: #45475a; }
#toolbar button.primary { background: #6366f1; border-color: #818cf8; color: white; }
#toolbar button.primary:hover { background: #818cf8; }
#toolbar button.active { background: #f59e0b; border-color: #fbbf24; color: #1e1e2e; }
#toolbar .page-nav { display: flex; align-items: center; gap: 4px; }
#toolbar .page-nav span { font-size: 13px; min-width: 80px; text-align: center; }
#toolbar .spacer { flex: 1; }
#toolbar .sep { width: 1px; height: 24px; background: #45475a; }
#toolbar .title { font-weight: 600; font-size: 13px; color: #a6adc8; }
#statusbar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: #181825; border-top: 1px solid #313244;
padding: 6px 16px; font-size: 12px; height: 32px;
display: flex; align-items: center; gap: 16px;
}
#statusbar .pos { color: #89b4fa; }
#statusbar .info { color: #a6adc8; }
#statusbar .keys { color: #6c7086; font-size: 11px; }
#worksheet-container {
padding: 56px 0 32px;
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
.obj-draggable { cursor: grab; user-select: none; z-index: 20 !important; position: absolute; }
/* In node mode, objects become click-through */
body.node-mode .obj-draggable { pointer-events: none !important; z-index: 4 !important; }
body.node-mode .node-draggable { z-index: 20 !important; cursor: grab; }
.obj-draggable:hover { outline: 2px solid rgba(99, 102, 241, 0.6); outline-offset: 2px; }
.obj-selected { outline: 3px solid #6366f1 !important; outline-offset: 2px; }
.obj-changed::after {
content: ''; position: absolute; top: -2px; right: -2px;
width: 8px; height: 8px; background: #f59e0b; border-radius: 50%;
border: 1px solid #1e1e2e; z-index: 100;
}
/* Node dragging (Shift+click) */
.node-draggable { cursor: grab; user-select: none; }
.node-draggable:hover { box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.4) !important; }
.node-selected { box-shadow: 0 0 0 3px #f59e0b !important; }
.node-changed { box-shadow: 0 0 0 2px #f59e0b, 0 0 8px rgba(251, 191, 36, 0.3) !important; }
body.dragging, body.dragging * { cursor: grabbing !important; }
#coord-tooltip {
position: fixed; z-index: 2000; pointer-events: none;
background: #181825; color: #89b4fa; border: 1px solid #6366f1;
padding: 3px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
display: none;
}
#toast {
position: fixed; top: 60px; right: 16px; z-index: 2000;
background: #6366f1; color: white; padding: 8px 16px; border-radius: 6px;
font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none;
}
#toast.show { opacity: 1; }
.page-label {
position: absolute; top: 4px; right: 4px; z-index: 100;
background: rgba(0,0,0,0.6); color: white; padding: 2px 8px;
border-radius: 4px; font-size: 12px; pointer-events: none;
}
/* Route highlight */
line.route-highlight {
stroke: #f87171 !important; stroke-width: 2.5 !important;
opacity: 0.8 !important; stroke-dasharray: none !important;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="title">Route Editor</span>
<div class="page-nav">
<button id="btn-prev">&larr;</button>
<span id="page-indicator">Page 1 / ?</span>
<button id="btn-next">&rarr;</button>
</div>
<div class="sep"></div>
<button id="btn-mode" title="Switch between Objects and Nodes mode (M)">Mode: Objects</button>
<button id="btn-route" title="Toggle enemy route highlight">Route HL</button>
<button id="btn-flip" title="Flip selected object horizontally (H)">Flip H</button>
<div class="sep"></div>
<button id="btn-reset">Reset Page</button>
<button id="btn-copy-changes">Copy Changes</button>
<button id="btn-copy" class="primary">Copy All JSON</button>
<button id="btn-save" class="primary" style="background:#22c55e;border-color:#4ade80;">Save</button>
</div>
<div id="worksheet-container"></div>
<div id="statusbar">
<span id="status-info" class="info">Click an object to select</span>
<span id="status-pos" class="pos"></span>
<span class="keys">M: switch mode | Arrows: move | [ ] rotate | - + scale | H: flip | R: route</span>
</div>
<div id="coord-tooltip"></div>
<div id="toast"></div>
<script>
(function() {
const params = new URLSearchParams(location.search);
const fileParam = params.get('file') || 'space-route-1';
const file = 'docs/' + fileParam + '.template.html';
const docId = fileParam;
let pages = [];
let objects = []; // draggable objects (images with data-node-id)
let nodeEls = []; // all node divs
let originalState = new Map(); // obj originals
let nodeOrigState = new Map(); // node originals
let selectedObj = null; // selected object OR node
let selectedType = null; // 'obj' or 'node'
let dragging = null;
let dragStart = null;
let mmToPx = 1;
let currentPage = 0;
let routeHighlight = false;
let editMode = 'objects'; // 'objects' or 'nodes'
fetch(file)
.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');
const tw = document.createElement('script');
tw.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(tw);
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;
setTimeout(() => initEditor(), 400);
})
.catch(err => {
document.getElementById('worksheet-container').innerHTML =
`<p style="padding:80px 20px;color:#f38ba8;">Failed to load ${file}: ${err.message}</p>`;
});
function initEditor() {
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;
}
// Find all space objects (images with data-node-id)
document.querySelectorAll('img.space-obj').forEach((img, idx) => {
const page = img.closest('[data-page]');
const pageNum = page ? parseInt(page.dataset.page) : 0;
const nodeId = img.dataset.nodeId;
// Wrap in a container div for positioning if not already
// Objects are already absolutely positioned images
const left = parseFloat(img.style.left) || 0;
const top = parseFloat(img.style.top) || 0;
const w = parseFloat(img.style.width) || 24;
const h = parseFloat(img.style.height) || 24;
// Parse transform
let rotate = 0, scaleX = 1, scaleY = 1;
if (img.style.transform) {
const rotM = img.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = img.style.transform.match(/scaleX\(([-\d.]+)\)/);
const scyM = img.style.transform.match(/scaleY\(([-\d.]+)\)/);
const scM = img.style.transform.match(/scale\(([-\d.]+)\)/);
if (rotM) rotate = parseFloat(rotM[1]);
if (scxM) scaleX = parseFloat(scxM[1]);
if (scyM) scaleY = parseFloat(scyM[1]);
if (scM) scaleX = scaleY = parseFloat(scM[1]);
}
const id = `p${pageNum}-obj${idx}`;
img.dataset.objId = id;
img.dataset.pageNum = pageNum;
img.style.position = 'absolute';
originalState.set(id, { left, top, w, h, rotate, scaleX, scaleY });
img.classList.add('obj-draggable');
img.draggable = false;
objects.push({
el: img, id, pageNum, nodeId,
type: img.dataset.type,
src: img.src.split('/').pop()
});
img.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
selectObj(img, id);
startDrag(img, e);
});
});
// Init nodes (Shift+click to select)
document.querySelectorAll('[data-node-id]').forEach(nodeDiv => {
if (nodeDiv.tagName === 'IMG') return; // skip objects, only div nodes
const page = nodeDiv.closest('[data-page]');
const pageNum = page ? parseInt(page.dataset.page) : 0;
const nodeId = nodeDiv.dataset.nodeId;
const left = parseFloat(nodeDiv.style.left) || 0;
const top = parseFloat(nodeDiv.style.top) || 0;
const nid = `p${pageNum}-n${nodeId}`;
nodeDiv.dataset.nid = nid;
nodeDiv.dataset.pageNum = pageNum;
nodeOrigState.set(nid, { left, top });
nodeDiv.classList.add('node-draggable');
nodeEls.push({ el: nodeDiv, nid, pageNum, nodeId });
nodeDiv.addEventListener('mousedown', (e) => {
if (editMode !== 'nodes') return;
e.preventDefault();
e.stopPropagation();
selectNode(nodeDiv, nid);
startDrag(nodeDiv, e);
});
});
// Page 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));
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.getElementById('worksheet-container').addEventListener('mousedown', (e) => {
if (!e.target.closest('.obj-draggable')) deselectAll();
});
document.addEventListener('keydown', onKeyDown);
document.getElementById('btn-prev').addEventListener('click', () => scrollToPage(currentPage - 1));
document.getElementById('btn-next').addEventListener('click', () => scrollToPage(currentPage + 1));
document.getElementById('btn-copy').addEventListener('click', () => copyConfig(false));
document.getElementById('btn-copy-changes').addEventListener('click', () => copyConfig(true));
document.getElementById('btn-reset').addEventListener('click', resetCurrentPage);
document.getElementById('btn-flip').addEventListener('click', flipSelected);
document.getElementById('btn-mode').addEventListener('click', toggleMode);
document.getElementById('btn-route').addEventListener('click', toggleRouteHL);
document.getElementById('btn-save').addEventListener('click', saveToServer);
window.getConfig = () => buildConfig(false);
window.getChanges = () => buildConfig(true);
window.addEventListener('resize', () => {
if (pages.length > 0) mmToPx = pages[0].getBoundingClientRect().width / 210;
});
}
function selectObj(el, id) {
deselectAll();
el.classList.add('obj-selected');
selectedObj = el;
selectedType = 'obj';
updateStatus(el, id);
}
function selectNode(el, nid) {
deselectAll();
el.classList.add('node-selected');
selectedObj = el;
selectedType = 'node';
const left = parseFloat(el.style.left).toFixed(1);
const top = parseFloat(el.style.top).toFixed(1);
const val = el.querySelector('span')?.textContent || '?';
document.getElementById('status-info').textContent =
`NODE | Page ${el.dataset.pageNum} | id:${el.dataset.nodeId} | value:${val}`;
document.getElementById('status-pos').textContent = `pos: ${left}, ${top}mm`;
}
function deselectAll() {
document.querySelectorAll('.obj-selected').forEach(el => el.classList.remove('obj-selected'));
document.querySelectorAll('.node-selected').forEach(el => el.classList.remove('node-selected'));
selectedObj = null;
selectedType = null;
document.getElementById('status-info').textContent = 'Click object / Shift+click node';
document.getElementById('status-pos').textContent = '';
}
function updateStatus(el, id) {
const left = parseFloat(el.style.left).toFixed(1);
const top = parseFloat(el.style.top).toFixed(1);
const w = parseFloat(el.style.width).toFixed(0);
const h = parseFloat(el.style.height).toFixed(0);
const obj = objects.find(o => o.id === id);
const page = el.dataset.pageNum || '?';
let rotStr = '0', flipStr = '';
if (el.style.transform) {
const rotM = el.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = el.style.transform.match(/scaleX\(([-\d.]+)\)/);
if (rotM) rotStr = Math.round(parseFloat(rotM[1]));
if (scxM && parseFloat(scxM[1]) < 0) flipStr = ' FLIP';
}
document.getElementById('status-info').textContent =
`Page ${page} | node:${obj?.nodeId} | ${obj?.src || ''}`;
document.getElementById('status-pos').textContent =
`pos: ${left}, ${top}mm | size: ${w}×${h}mm | rot: ${rotStr}°${flipStr}`;
}
// Move the corresponding node and connected edges when an object moves
function moveNodeAndEdges(el, newLeft, newTop) {
const nodeId = el.dataset.nodeId;
if (!nodeId) return;
const page = el.closest('[data-page]');
if (!page) return;
// Find the node div
const nodeDiv = page.querySelector(`[data-node-id="${nodeId}"]`);
if (nodeDiv) {
nodeDiv.style.left = newLeft.toFixed(1) + 'mm';
nodeDiv.style.top = newTop.toFixed(1) + 'mm';
}
// Update all edges connected to this node
const svg = page.querySelector('svg');
if (!svg) return;
svg.querySelectorAll(`line[data-edge]`).forEach(line => {
const [a, b] = line.dataset.edge.split('-');
if (a === nodeId) {
line.setAttribute('x1', newLeft.toFixed(1) + 'mm');
line.setAttribute('y1', newTop.toFixed(1) + 'mm');
}
if (b === nodeId) {
line.setAttribute('x2', newLeft.toFixed(1) + 'mm');
line.setAttribute('y2', newTop.toFixed(1) + 'mm');
}
});
}
// Move only edges for a node (without moving the node itself — used when dragging node directly)
function moveEdgesForNode(nodeDiv, newLeft, newTop) {
const nodeId = nodeDiv.dataset.nodeId;
const page = nodeDiv.closest('[data-page]');
if (!page) return;
const svg = page.querySelector('svg');
if (!svg) return;
svg.querySelectorAll('line[data-edge]').forEach(line => {
const [a, b] = line.dataset.edge.split('-');
if (a === nodeId) {
line.setAttribute('x1', newLeft.toFixed(1) + 'mm');
line.setAttribute('y1', newTop.toFixed(1) + 'mm');
}
if (b === nodeId) {
line.setAttribute('x2', newLeft.toFixed(1) + 'mm');
line.setAttribute('y2', newTop.toFixed(1) + 'mm');
}
});
}
function markNodeChanged(nodeDiv) {
const nid = nodeDiv.dataset.nid;
const orig = nodeOrigState.get(nid);
if (!orig) return;
const curLeft = parseFloat(nodeDiv.style.left);
const curTop = parseFloat(nodeDiv.style.top);
const changed = Math.abs(orig.left - curLeft) > 0.3 || Math.abs(orig.top - curTop) > 0.3;
nodeDiv.classList.toggle('node-changed', changed);
}
function startDrag(el, e) {
dragging = el;
dragStart = {
mouseX: e.clientX, mouseY: e.clientY,
left: parseFloat(el.style.left) || 0,
top: parseFloat(el.style.top) || 0
};
document.body.classList.add('dragging');
document.getElementById('coord-tooltip').style.display = 'block';
}
function onMouseMove(e) {
if (!dragging) return;
e.preventDefault();
const dx = (e.clientX - dragStart.mouseX) / mmToPx;
const dy = (e.clientY - dragStart.mouseY) / mmToPx;
const newLeft = Math.round((dragStart.left + dx) * 2) / 2;
const newTop = Math.round((dragStart.top + dy) * 2) / 2;
dragging.style.left = newLeft.toFixed(1) + 'mm';
dragging.style.top = newTop.toFixed(1) + 'mm';
if (selectedType === 'obj') {
// Update margin to keep object centered
const w = parseFloat(dragging.style.width) || 24;
const h = parseFloat(dragging.style.height) || 24;
dragging.style.marginLeft = (-w / 2) + 'mm';
dragging.style.marginTop = (-h / 2) + 'mm';
updateStatus(dragging, dragging.dataset.objId);
markChanged(dragging);
} else if (selectedType === 'node') {
// Update margin for node centering
const size = parseFloat(dragging.style.width) || 10;
dragging.style.marginLeft = (-size / 2) + 'mm';
dragging.style.marginTop = (-size / 2) + 'mm';
// Move connected edges
moveEdgesForNode(dragging, newLeft, newTop);
markNodeChanged(dragging);
// Update status
const val = dragging.querySelector('span')?.textContent || '?';
document.getElementById('status-info').textContent =
`NODE | Page ${dragging.dataset.pageNum} | id:${dragging.dataset.nodeId} | value:${val}`;
document.getElementById('status-pos').textContent = `pos: ${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`;
}
const tooltip = document.getElementById('coord-tooltip');
tooltip.textContent = `${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`;
tooltip.style.left = (e.clientX + 15) + 'px';
tooltip.style.top = (e.clientY - 10) + 'px';
}
function onMouseUp() {
if (!dragging) return;
document.body.classList.remove('dragging');
document.getElementById('coord-tooltip').style.display = 'none';
dragging = null;
dragStart = null;
}
function onKeyDown(e) {
if (e.key === 'm' || e.key === 'M') { toggleMode(); return; }
if (e.key === 'r' || e.key === 'R') { toggleRouteHL(); return; }
if (e.key === 'h' || e.key === 'H') { if (selectedType === 'obj') flipSelected(); return; }
if (e.key === 'Escape') { deselectAll(); return; }
if (!selectedObj) return;
// Node mode: only arrows
if (selectedType === 'node') {
const step = e.shiftKey ? 5 : 1;
let left = parseFloat(selectedObj.style.left) || 0;
let top = parseFloat(selectedObj.style.top) || 0;
switch (e.key) {
case 'ArrowLeft': left -= step; break;
case 'ArrowRight': left += step; break;
case 'ArrowUp': top -= step; break;
case 'ArrowDown': top += step; break;
default: return;
}
e.preventDefault();
selectedObj.style.left = left.toFixed(1) + 'mm';
selectedObj.style.top = top.toFixed(1) + 'mm';
const size = parseFloat(selectedObj.style.width) || 10;
selectedObj.style.marginLeft = (-size / 2) + 'mm';
selectedObj.style.marginTop = (-size / 2) + 'mm';
moveEdgesForNode(selectedObj, left, top);
markNodeChanged(selectedObj);
const val = selectedObj.querySelector('span')?.textContent || '?';
document.getElementById('status-pos').textContent = `pos: ${left.toFixed(1)}, ${top.toFixed(1)}mm`;
return;
}
// Object mode: arrows + rotate + resize
const step = e.shiftKey ? 5 : 1;
let left = parseFloat(selectedObj.style.left) || 0;
let top = parseFloat(selectedObj.style.top) || 0;
let w = parseFloat(selectedObj.style.width) || 24;
let h = parseFloat(selectedObj.style.height) || 24;
const sizeStep = e.shiftKey ? 1 : 2;
const rotStep = e.shiftKey ? 1 : 5;
switch (e.key) {
case 'ArrowLeft': left -= step; break;
case 'ArrowRight': left += step; break;
case 'ArrowUp': top -= step; break;
case 'ArrowDown': top += step; break;
case '[': applyRotate(selectedObj, -rotStep); break;
case ']': applyRotate(selectedObj, rotStep); break;
case '-': case '_':
w = Math.max(8, w - sizeStep);
h = Math.max(8, h - sizeStep * (h / w));
selectedObj.style.width = w + 'mm';
selectedObj.style.height = h + 'mm';
selectedObj.style.marginLeft = (-w / 2) + 'mm';
selectedObj.style.marginTop = (-h / 2) + 'mm';
break;
case '=': case '+':
w = Math.min(60, w + sizeStep);
h = Math.min(60, h + sizeStep * (h / w));
selectedObj.style.width = w + 'mm';
selectedObj.style.height = h + 'mm';
selectedObj.style.marginLeft = (-w / 2) + 'mm';
selectedObj.style.marginTop = (-h / 2) + 'mm';
break;
default: return;
}
e.preventDefault();
selectedObj.style.left = left.toFixed(1) + 'mm';
selectedObj.style.top = top.toFixed(1) + 'mm';
selectedObj.style.marginLeft = (-parseFloat(selectedObj.style.width) / 2) + 'mm';
selectedObj.style.marginTop = (-parseFloat(selectedObj.style.height) / 2) + 'mm';
updateStatus(selectedObj, selectedObj.dataset.objId);
markChanged(selectedObj);
}
function applyRotate(el, delta) {
let transform = el.style.transform || '';
const rotM = transform.match(/rotate\(([-\d.]+)deg\)/);
let rot = rotM ? parseFloat(rotM[1]) : 0;
rot += delta;
if (rotM) {
transform = transform.replace(/rotate\([-\d.]+deg\)/, `rotate(${rot}deg)`);
} else {
transform += ` rotate(${rot}deg)`;
}
el.style.transform = transform.trim();
markChanged(el);
}
function flipSelected() {
if (!selectedObj) return;
let transform = selectedObj.style.transform || '';
const scxM = transform.match(/scaleX\(([-\d.]+)\)/);
let scx = scxM ? parseFloat(scxM[1]) : 1;
scx = scx < 0 ? 1 : -1;
if (scxM) {
transform = transform.replace(/scaleX\([-\d.]+\)/, `scaleX(${scx})`);
} else {
transform += ` scaleX(${scx})`;
}
selectedObj.style.transform = transform.trim();
updateStatus(selectedObj, selectedObj.dataset.objId);
markChanged(selectedObj);
}
function toggleMode() {
deselectAll();
editMode = editMode === 'objects' ? 'nodes' : 'objects';
document.body.classList.toggle('node-mode', editMode === 'nodes');
const btn = document.getElementById('btn-mode');
btn.textContent = editMode === 'objects' ? 'Mode: Objects' : 'Mode: Nodes';
btn.classList.toggle('active', editMode === 'nodes');
document.getElementById('status-info').textContent =
editMode === 'objects' ? 'Click an object to select' : 'Click a node to select';
}
function toggleRouteHL() {
routeHighlight = !routeHighlight;
const btn = document.getElementById('btn-route');
btn.classList.toggle('active', routeHighlight);
pages.forEach(pageEl => {
const routeStr = pageEl.dataset.enemyRoute;
if (!routeStr) return;
const route = routeStr.split(',');
// Build set of edge keys on the route
const routeEdges = new Set();
for (let i = 0; i < route.length - 1; i++) {
const a = parseInt(route[i]), b = parseInt(route[i + 1]);
routeEdges.add(`${Math.min(a, b)}-${Math.max(a, b)}`);
}
pageEl.querySelectorAll('line[data-edge]').forEach(line => {
const [ea, eb] = line.dataset.edge.split('-').map(Number);
const key = `${Math.min(ea, eb)}-${Math.max(ea, eb)}`;
if (routeEdges.has(key)) {
line.classList.toggle('route-highlight', routeHighlight);
}
});
});
}
function markChanged(el) {
const id = el.dataset.objId;
const orig = originalState.get(id);
if (!orig) return;
const curLeft = parseFloat(el.style.left);
const curTop = parseFloat(el.style.top);
const curW = parseFloat(el.style.width);
const curH = parseFloat(el.style.height);
const changed = (
Math.abs(orig.left - curLeft) > 0.3 ||
Math.abs(orig.top - curTop) > 0.3 ||
Math.abs(orig.w - curW) > 0.3 ||
Math.abs(orig.h - curH) > 0.3 ||
(el.style.transform || '') !== buildOrigTransform(orig)
);
el.classList.toggle('obj-changed', changed);
}
function buildOrigTransform(orig) {
let t = '';
if (orig.scaleX < 0) t += `scaleX(${orig.scaleX})`;
if (orig.rotate) t += ` rotate(${orig.rotate}deg)`;
return t.trim();
}
function scrollToPage(index) {
if (index < 0 || index >= pages.length) return;
pages[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function buildConfig(changesOnly) {
const result = { file, pages: [] };
pages.forEach((pageEl, pageIndex) => {
const pageObjs = objects.filter(o => o.pageNum === pageIndex + 1);
const items = [];
pageObjs.forEach(obj => {
const el = obj.el;
const left = parseFloat(el.style.left);
const top = parseFloat(el.style.top);
const w = parseFloat(el.style.width);
const h = parseFloat(el.style.height);
let rotate = 0, flipH = false;
if (el.style.transform) {
const rotM = el.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = el.style.transform.match(/scaleX\(([-\d.]+)\)/);
if (rotM) rotate = Math.round(parseFloat(rotM[1]));
if (scxM && parseFloat(scxM[1]) < 0) flipH = true;
}
const orig = originalState.get(obj.id);
if (changesOnly && orig) {
const origTransform = buildOrigTransform(orig);
const same = Math.abs(orig.left - left) < 0.3 &&
Math.abs(orig.top - top) < 0.3 &&
Math.abs(orig.w - w) < 0.3 &&
Math.abs(orig.h - h) < 0.3 &&
(el.style.transform || '') === origTransform;
if (same) return;
}
items.push({
nodeId: parseInt(obj.nodeId),
type: obj.type,
src: obj.src,
left: left.toFixed(1) + 'mm',
top: top.toFixed(1) + 'mm',
w: w.toFixed(0) + 'mm',
h: h.toFixed(0) + 'mm',
rotate, flipH
});
});
// Collect changed nodes for this page
const pageNodes = nodeEls.filter(n => n.pageNum === pageIndex + 1);
const movedNodes = [];
pageNodes.forEach(nd => {
const el = nd.el;
const left = parseFloat(el.style.left);
const top = parseFloat(el.style.top);
const orig = nodeOrigState.get(nd.nid);
if (changesOnly && orig && Math.abs(orig.left - left) < 0.3 && Math.abs(orig.top - top) < 0.3) return;
movedNodes.push({
nodeId: parseInt(nd.nodeId),
left: left.toFixed(1) + 'mm',
top: top.toFixed(1) + 'mm'
});
});
if (items.length > 0 || movedNodes.length > 0) {
const pageData = { page: pageIndex + 1 };
if (items.length > 0) pageData.objects = items;
if (movedNodes.length > 0) pageData.nodes = movedNodes;
result.pages.push(pageData);
}
});
return result;
}
function copyConfig(changesOnly) {
const config = buildConfig(changesOnly);
const json = JSON.stringify(config, null, 2);
navigator.clipboard.writeText(json).then(() => {
showToast(changesOnly ? 'Changes copied!' : 'Full config copied!');
}).catch(() => {
console.log(json);
showToast('Copied to console (clipboard blocked)');
});
}
function resetCurrentPage() {
const pageNum = currentPage + 1;
objects.filter(o => o.pageNum === pageNum).forEach(obj => {
const orig = originalState.get(obj.id);
if (!orig) return;
obj.el.style.left = orig.left + 'mm';
obj.el.style.top = orig.top + 'mm';
obj.el.style.width = orig.w + 'mm';
obj.el.style.height = orig.h + 'mm';
obj.el.style.marginLeft = (-orig.w / 2) + 'mm';
obj.el.style.marginTop = (-orig.h / 2) + 'mm';
obj.el.style.transform = buildOrigTransform(orig);
obj.el.classList.remove('obj-changed');
// Reset node and edges too
moveNodeAndEdges(obj.el, orig.left, orig.top);
});
showToast(`Page ${pageNum} reset`);
}
async function saveToServer() {
const config = buildConfig(false);
try {
const resp = await fetch('/api/save-edits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskType: 'space-route',
docId: docId,
data: config
})
});
const result = await resp.json();
if (result.ok) {
showToast('Saved!');
} else {
showToast('Error: ' + result.error);
}
} catch (e) {
showToast('Save failed: ' + e.message);
}
}
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
}
})();
</script>
</body>
</html>