793 lines
29 KiB
HTML
793 lines
29 KiB
HTML
<!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">←</button>
|
||
<span id="page-indicator">Page 1 / ?</span>
|
||
<button id="btn-next">→</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>
|