553 lines
22 KiB
HTML
553 lines
22 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; }
|
||
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-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;
|
||
}
|
||
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 src="/src/editor/editor-core.js"></script>
|
||
<script>
|
||
(function() {
|
||
|
||
// === Space-route specific state ===
|
||
let objects = [];
|
||
let nodeEls = [];
|
||
let originalState = new Map();
|
||
let nodeOrigState = new Map();
|
||
let selectedObj = null;
|
||
let selectedType = null; // 'obj' or 'node'
|
||
let dragging = null;
|
||
let dragStart = null;
|
||
let routeHighlight = false;
|
||
let editMode = 'objects';
|
||
|
||
// === Init via EditorCore ===
|
||
const core = EditorCore.init({
|
||
taskType: 'space-route',
|
||
serialize: buildConfig,
|
||
onReset: resetCurrentPage,
|
||
onReady: function(pages, mmToPx) {
|
||
setupObjects();
|
||
setupNodes();
|
||
setupGlobalEvents();
|
||
|
||
document.getElementById('btn-flip').addEventListener('click', flipSelected);
|
||
document.getElementById('btn-mode').addEventListener('click', toggleMode);
|
||
document.getElementById('btn-route').addEventListener('click', toggleRouteHL);
|
||
|
||
window.getConfig = () => buildConfig(false);
|
||
window.getChanges = () => buildConfig(true);
|
||
}
|
||
});
|
||
|
||
if (!core) return;
|
||
|
||
// === Setup objects ===
|
||
function setupObjects() {
|
||
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;
|
||
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;
|
||
|
||
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 scM = img.style.transform.match(/scale\(([-\d.]+)\)/);
|
||
if (rotM) rotate = parseFloat(rotM[1]);
|
||
if (scxM) scaleX = parseFloat(scxM[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);
|
||
});
|
||
});
|
||
}
|
||
|
||
// === Setup nodes ===
|
||
function setupNodes() {
|
||
document.querySelectorAll('[data-node-id]').forEach(nodeDiv => {
|
||
if (nodeDiv.tagName === 'IMG') return;
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
// === Global events ===
|
||
function setupGlobalEvents() {
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
document.addEventListener('keydown', onKeyDown);
|
||
|
||
document.getElementById('worksheet-container').addEventListener('mousedown', (e) => {
|
||
if (!e.target.closest('.obj-draggable')) deselectAll();
|
||
});
|
||
}
|
||
|
||
// === Selection ===
|
||
function selectObj(el, id) {
|
||
deselectAll();
|
||
el.classList.add('obj-selected');
|
||
selectedObj = el; selectedType = 'obj';
|
||
updateObjStatus(el, id);
|
||
}
|
||
|
||
function selectNode(el, nid) {
|
||
deselectAll();
|
||
el.classList.add('node-selected');
|
||
selectedObj = el; selectedType = 'node';
|
||
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: ${parseFloat(el.style.left).toFixed(1)}, ${parseFloat(el.style.top).toFixed(1)}mm`;
|
||
}
|
||
|
||
function deselectAll() {
|
||
document.querySelectorAll('.obj-selected, .node-selected').forEach(el => el.classList.remove('obj-selected', 'node-selected'));
|
||
selectedObj = null; selectedType = null;
|
||
document.getElementById('status-info').textContent = editMode === 'objects' ? 'Click an object to select' : 'Click a node to select';
|
||
document.getElementById('status-pos').textContent = '';
|
||
}
|
||
|
||
function updateObjStatus(el, id) {
|
||
const obj = objects.find(o => o.id === id);
|
||
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 ${el.dataset.pageNum} | node:${obj?.nodeId} | ${obj?.src || ''}`;
|
||
document.getElementById('status-pos').textContent = `pos: ${parseFloat(el.style.left).toFixed(1)}, ${parseFloat(el.style.top).toFixed(1)}mm | size: ${parseFloat(el.style.width).toFixed(0)}×${parseFloat(el.style.height).toFixed(0)}mm | rot: ${rotStr}°${flipStr}`;
|
||
}
|
||
|
||
// === Graph helpers ===
|
||
function moveNodeAndEdges(el, newLeft, newTop) {
|
||
const nodeId = el.dataset.nodeId;
|
||
if (!nodeId) return;
|
||
const page = el.closest('[data-page]');
|
||
if (!page) return;
|
||
const nodeDiv = page.querySelector(`[data-node-id="${nodeId}"]`);
|
||
if (nodeDiv) { nodeDiv.style.left = newLeft.toFixed(1) + 'mm'; nodeDiv.style.top = newTop.toFixed(1) + 'mm'; }
|
||
_updateEdges(page, nodeId, newLeft, newTop);
|
||
}
|
||
|
||
function moveEdgesForNode(nodeDiv, newLeft, newTop) {
|
||
const page = nodeDiv.closest('[data-page]');
|
||
if (!page) return;
|
||
_updateEdges(page, nodeDiv.dataset.nodeId, newLeft, newTop);
|
||
}
|
||
|
||
function _updateEdges(page, nodeId, left, top) {
|
||
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', left.toFixed(1) + 'mm'); line.setAttribute('y1', top.toFixed(1) + 'mm'); }
|
||
if (b === nodeId) { line.setAttribute('x2', left.toFixed(1) + 'mm'); line.setAttribute('y2', top.toFixed(1) + 'mm'); }
|
||
});
|
||
}
|
||
|
||
// === Dragging ===
|
||
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');
|
||
}
|
||
|
||
function onMouseMove(e) {
|
||
if (!dragging) return;
|
||
e.preventDefault();
|
||
const dx = (e.clientX - dragStart.mouseX) / core.mmToPx;
|
||
const dy = (e.clientY - dragStart.mouseY) / core.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') {
|
||
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';
|
||
updateObjStatus(dragging, dragging.dataset.objId);
|
||
markObjChanged(dragging);
|
||
} else if (selectedType === 'node') {
|
||
const size = parseFloat(dragging.style.width) || 10;
|
||
dragging.style.marginLeft = (-size / 2) + 'mm';
|
||
dragging.style.marginTop = (-size / 2) + 'mm';
|
||
moveEdgesForNode(dragging, newLeft, newTop);
|
||
markNodeChanged(dragging);
|
||
document.getElementById('status-pos').textContent = `pos: ${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`;
|
||
}
|
||
|
||
core.showTooltip(e, `${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`);
|
||
}
|
||
|
||
function onMouseUp() {
|
||
if (!dragging) return;
|
||
document.body.classList.remove('dragging');
|
||
core.hideTooltip();
|
||
dragging = null; dragStart = null;
|
||
}
|
||
|
||
// === Keyboard ===
|
||
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;
|
||
|
||
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);
|
||
document.getElementById('status-pos').textContent = `pos: ${left.toFixed(1)}, ${top.toFixed(1)}mm`;
|
||
return;
|
||
}
|
||
|
||
// Object mode
|
||
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';
|
||
updateObjStatus(selectedObj, selectedObj.dataset.objId);
|
||
markObjChanged(selectedObj);
|
||
}
|
||
|
||
// === Transform helpers ===
|
||
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;
|
||
transform = rotM ? transform.replace(/rotate\([-\d.]+deg\)/, `rotate(${rot}deg)`) : transform + ` rotate(${rot}deg)`;
|
||
el.style.transform = transform.trim();
|
||
markObjChanged(el);
|
||
}
|
||
|
||
function flipSelected() {
|
||
if (!selectedObj) return;
|
||
let transform = selectedObj.style.transform || '';
|
||
const scxM = transform.match(/scaleX\(([-\d.]+)\)/);
|
||
let scx = scxM ? (parseFloat(scxM[1]) < 0 ? 1 : -1) : -1;
|
||
transform = scxM ? transform.replace(/scaleX\([-\d.]+\)/, `scaleX(${scx})`) : transform + ` scaleX(${scx})`;
|
||
selectedObj.style.transform = transform.trim();
|
||
updateObjStatus(selectedObj, selectedObj.dataset.objId);
|
||
markObjChanged(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');
|
||
}
|
||
|
||
function toggleRouteHL() {
|
||
routeHighlight = !routeHighlight;
|
||
document.getElementById('btn-route').classList.toggle('active', routeHighlight);
|
||
core.pages.forEach(pageEl => {
|
||
const routeStr = pageEl.dataset.enemyRoute;
|
||
if (!routeStr) return;
|
||
const route = routeStr.split(',');
|
||
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);
|
||
if (routeEdges.has(`${Math.min(ea, eb)}-${Math.max(ea, eb)}`)) {
|
||
line.classList.toggle('route-highlight', routeHighlight);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// === Change tracking ===
|
||
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 markObjChanged(el) {
|
||
const id = el.dataset.objId;
|
||
const orig = originalState.get(id);
|
||
if (!orig) return;
|
||
const changed = Math.abs(orig.left - parseFloat(el.style.left)) > 0.3 ||
|
||
Math.abs(orig.top - parseFloat(el.style.top)) > 0.3 ||
|
||
Math.abs(orig.w - parseFloat(el.style.width)) > 0.3 ||
|
||
Math.abs(orig.h - parseFloat(el.style.height)) > 0.3 ||
|
||
(el.style.transform || '') !== buildOrigTransform(orig);
|
||
el.classList.toggle('obj-changed', changed);
|
||
}
|
||
|
||
function markNodeChanged(nodeDiv) {
|
||
const nid = nodeDiv.dataset.nid;
|
||
const orig = nodeOrigState.get(nid);
|
||
if (!orig) return;
|
||
const changed = Math.abs(orig.left - parseFloat(nodeDiv.style.left)) > 0.3 || Math.abs(orig.top - parseFloat(nodeDiv.style.top)) > 0.3;
|
||
nodeDiv.classList.toggle('node-changed', changed);
|
||
}
|
||
|
||
// === Serialization ===
|
||
function buildConfig(changesOnly) {
|
||
const result = { file: core.docId, pages: [] };
|
||
|
||
core.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), top = parseFloat(el.style.top);
|
||
const w = parseFloat(el.style.width), 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 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 || '') === buildOrigTransform(orig);
|
||
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 });
|
||
});
|
||
|
||
const pageNodes = nodeEls.filter(n => n.pageNum === pageIndex + 1);
|
||
const movedNodes = [];
|
||
pageNodes.forEach(nd => {
|
||
const left = parseFloat(nd.el.style.left), top = parseFloat(nd.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;
|
||
}
|
||
|
||
// === Reset ===
|
||
function resetCurrentPage(pageNum) {
|
||
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');
|
||
moveNodeAndEdges(obj.el, orig.left, orig.top);
|
||
});
|
||
core.showToast(`Page ${pageNum} reset`);
|
||
}
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|