From 85e5a345f1470c4020243af1a56f7837a021f604 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Mon, 16 Feb 2026 22:00:56 -0500 Subject: [PATCH] Harden selection UX and add undo/redo history controls --- frontend/app.js | 163 +++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 2 + frontend/styles.css | 5 ++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index e36c946..b99ac24 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -33,7 +33,11 @@ const state = { boxStartY: 0, boxMoved: false, suppressCanvasClick: false, - compileDebounceId: null + compileDebounceId: null, + historyPast: [], + historyFuture: [], + historyLimit: 80, + historyRestoring: false }; const el = { @@ -109,6 +113,8 @@ const el = { copyReproBtn: document.getElementById("copyReproBtn"), autoLayoutBtn: document.getElementById("autoLayoutBtn"), autoTidyBtn: document.getElementById("autoTidyBtn"), + undoBtn: document.getElementById("undoBtn"), + redoBtn: document.getElementById("redoBtn"), renderModeSelect: document.getElementById("renderModeSelect"), isolateNetBtn: document.getElementById("isolateNetBtn"), isolateComponentBtn: document.getElementById("isolateComponentBtn"), @@ -128,6 +134,76 @@ function clone(obj) { return JSON.parse(JSON.stringify(obj)); } +function captureHistorySnapshot() { + if (!state.model) { + return null; + } + return { + model: clone(state.model), + selectedRefs: [...state.selectedRefs], + selectedNet: state.selectedNet, + selectedPin: state.selectedPin ? { ...state.selectedPin, nets: [...(state.selectedPin.nets ?? [])] } : null, + isolateNet: Boolean(state.isolateNet), + isolateComponent: Boolean(state.isolateComponent) + }; +} + +function restoreHistorySnapshot(snapshot) { + if (!snapshot) { + return; + } + state.model = clone(snapshot.model); + setSelectedRefs(snapshot.selectedRefs ?? []); + state.selectedNet = snapshot.selectedNet ?? null; + state.selectedPin = snapshot.selectedPin ? { ...snapshot.selectedPin } : null; + state.isolateNet = Boolean(snapshot.isolateNet); + state.isolateComponent = Boolean(snapshot.isolateComponent); +} + +function pushHistory(reason = "edit") { + const snap = captureHistorySnapshot(); + if (!snap) { + return; + } + state.historyPast.push({ reason, snapshot: snap }); + if (state.historyPast.length > state.historyLimit) { + state.historyPast.splice(0, state.historyPast.length - state.historyLimit); + } + state.historyFuture = []; +} + +async function performUndo() { + if (!state.historyPast.length || !state.model) { + return; + } + const current = captureHistorySnapshot(); + const prev = state.historyPast.pop(); + if (!prev?.snapshot || !current) { + return; + } + state.historyFuture.push({ reason: prev.reason, snapshot: current }); + state.historyRestoring = true; + restoreHistorySnapshot(prev.snapshot); + await compileModel(state.model, { keepView: true, source: "undo" }); + state.historyRestoring = false; +} + +async function performRedo() { + if (!state.historyFuture.length || !state.model) { + return; + } + const current = captureHistorySnapshot(); + const next = state.historyFuture.pop(); + if (!next?.snapshot || !current) { + return; + } + state.historyPast.push({ reason: next.reason, snapshot: current }); + state.historyRestoring = true; + restoreHistorySnapshot(next.snapshot); + await compileModel(state.model, { keepView: true, source: "redo" }); + state.historyRestoring = false; +} + function hasSelectionModifier(evt) { return Boolean(evt?.ctrlKey || evt?.metaKey || evt?.shiftKey); } @@ -945,12 +1021,14 @@ function focusIssue(issueId) { state.selectedNet = target.net; setSelectedRefs([]); state.selectedPin = null; + state.isolateComponent = false; renderAll(); flashElements(`[data-net="${target.net}"], [data-net-label="${target.net}"], [data-net-junction="${target.net}"], [data-net-tie="${target.net}"]`); } else if (target.type === "component" && target.ref) { selectSingleRef(target.ref); state.selectedNet = null; state.selectedPin = null; + state.isolateNet = false; renderAll(); flashElements(`[data-ref="${target.ref}"]`); } else if (target.type === "pin" && target.ref && target.pin) { @@ -961,6 +1039,7 @@ function focusIssue(issueId) { pin: target.pin, nets: [] }; + state.isolateNet = false; renderAll(); flashElements(`[data-pin-ref="${target.ref}"][data-pin-name="${target.pin}"]`); } @@ -990,6 +1069,15 @@ function bindSvgInteractions() { svg.querySelectorAll("[data-ref]").forEach((node) => { node.addEventListener("pointerdown", (evt) => { + if ( + evt.target.closest("[data-pin-ref]") || + evt.target.closest("[data-net]") || + evt.target.closest("[data-net-label]") || + evt.target.closest("[data-net-junction]") || + evt.target.closest("[data-net-tie]") + ) { + return; + } evt.stopPropagation(); evt.preventDefault(); const ref = node.getAttribute("data-ref"); @@ -1001,6 +1089,7 @@ function bindSvgInteractions() { toggleSelectedRef(ref); state.selectedNet = null; state.selectedPin = null; + state.isolateNet = false; renderAll(); return; } @@ -1010,6 +1099,7 @@ function bindSvgInteractions() { } state.selectedNet = null; state.selectedPin = null; + state.isolateNet = false; renderInstances(); renderNets(); renderSelected(); @@ -1061,6 +1151,7 @@ function bindSvgInteractions() { state.selectedNet = net; setSelectedRefs([]); state.selectedPin = null; + state.isolateComponent = false; renderAll(); }); }); @@ -1090,6 +1181,7 @@ function bindSvgInteractions() { state.selectedPin = { ref, pin, nets }; selectSingleRef(ref); state.selectedNet = null; + state.isolateNet = false; renderAll(); }); }); @@ -1117,6 +1209,8 @@ function renderAll() { el.isolateNetBtn.classList.toggle("activeChip", state.isolateNet); el.isolateComponentBtn.classList.toggle("activeChip", state.isolateComponent); + el.undoBtn.disabled = state.historyPast.length === 0; + el.redoBtn.disabled = state.historyFuture.length === 0; } async function compileModel(model, opts = {}) { @@ -1550,6 +1644,7 @@ async function runLayoutAction(path) { return; } + pushHistory(path.includes("auto") ? "auto-layout" : "auto-tidy"); setStatus(path.includes("auto") ? "Auto layout..." : "Auto tidy..."); try { const out = await apiPost(path, { @@ -1579,6 +1674,9 @@ async function loadSample() { } const model = await res.json(); + if (state.model) { + pushHistory("load-sample"); + } setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; @@ -1602,6 +1700,7 @@ function setupEvents() { } state.selectedNet = null; state.selectedPin = null; + state.isolateNet = false; renderAll(); }); @@ -1613,6 +1712,7 @@ function setupEvents() { state.selectedNet = item.getAttribute("data-net-item"); setSelectedRefs([]); state.selectedPin = null; + state.isolateComponent = false; renderAll(); }); @@ -1644,6 +1744,7 @@ function setupEvents() { return; } + pushHistory("component-edit"); const oldRef = inst.ref; updateInstance(oldRef, { ref: nextRef, @@ -1687,6 +1788,7 @@ function setupEvents() { if (!inst) { return; } + pushHistory("rotate"); const current = Number(inst.placement.rotation ?? 0); inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360; inst.placement.locked = true; @@ -1700,6 +1802,7 @@ function setupEvents() { return; } const { ref, pin } = state.selectedPin; + pushHistory("pin-ui"); if (!setPinUi(ref, pin, { show_net_label: el.showPinNetLabelInput.checked })) { return; } @@ -1742,6 +1845,7 @@ function setupEvents() { return; } + pushHistory("pin-props"); const beforeName = sym.pins[idx].name; sym.pins[idx] = { ...sym.pins[idx], @@ -1766,6 +1870,7 @@ function setupEvents() { el.jsonFeedback.textContent = "Choose a net first."; return; } + pushHistory("connect-pin"); const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; @@ -1782,6 +1887,7 @@ function setupEvents() { } const name = normalizeNetName(el.newNetNameInput.value || el.newNetNameInput.placeholder || nextAutoNetName()); const cls = el.newNetClassInput.value; + pushHistory("create-net"); const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, name, { netClass: cls }); if (!out.ok) { el.jsonFeedback.textContent = out.message; @@ -1799,6 +1905,7 @@ function setupEvents() { return; } const netName = btn.getAttribute("data-disconnect-net"); + pushHistory("disconnect-pin"); const out = disconnectPinFromNet(state.selectedPin.ref, state.selectedPin.pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; @@ -1812,6 +1919,7 @@ function setupEvents() { if (!state.selectedNet) { return; } + pushHistory("net-edit"); const oldName = state.selectedNet; const renamed = renameNet(oldName, el.netNameInput.value); if (!renamed.ok) { @@ -1838,6 +1946,7 @@ function setupEvents() { el.jsonFeedback.textContent = "Provide both ref and pin."; return; } + pushHistory("net-node-add"); const out = connectPinToNet(ref, pin, state.selectedNet); if (!out.ok) { el.jsonFeedback.textContent = out.message; @@ -1856,6 +1965,7 @@ function setupEvents() { const netName = state.selectedNet; const [ref, ...pinParts] = String(btn.getAttribute("data-remove-node")).split("."); const pin = pinParts.join("."); + pushHistory("net-node-remove"); const out = disconnectPinFromNet(ref, pin, netName); if (!out.ok) { el.jsonFeedback.textContent = out.message; @@ -1933,6 +2043,7 @@ function setupEvents() { return; } + pushHistory("symbol-edit"); for (const entry of parsedPins) { if (entry.oldName && entry.oldName !== entry.pin.name) { renamePinAcrossSymbolInstances(inst.symbol, entry.oldName, entry.pin.name); @@ -2154,6 +2265,7 @@ function setupEvents() { state.dragMoved = false; if (wasDragging && moved && state.model) { + pushHistory("drag-move"); for (const [ref, pos] of Object.entries(dragSnapshot ?? {})) { const inst = state.model.instances.find((x) => x.ref === ref); if (inst) { @@ -2178,12 +2290,29 @@ function setupEvents() { }); window.addEventListener("keydown", (evt) => { + const mod = evt.ctrlKey || evt.metaKey; + if (mod && evt.key.toLowerCase() === "z") { + evt.preventDefault(); + if (evt.shiftKey) { + void performRedo(); + } else { + void performUndo(); + } + return; + } + if (mod && evt.key.toLowerCase() === "y") { + evt.preventDefault(); + void performRedo(); + return; + } + if (evt.code === "Space") { if (isTypingContext(evt.target)) { return; } if (state.selectedRefs.length && state.model && !evt.repeat) { + pushHistory("rotate-hotkey"); for (const ref of state.selectedRefs) { const inst = state.model.instances.find((x) => x.ref === ref); if (!inst) { @@ -2212,7 +2341,20 @@ function setupEvents() { } } if (evt.code === "Escape") { - closeSchemaModal(); + if (!el.schemaModal.classList.contains("hidden")) { + closeSchemaModal(); + return; + } + const hadSelection = Boolean(state.selectedRefs.length || state.selectedNet || state.selectedPin); + const hadIsolation = Boolean(state.isolateNet || state.isolateComponent); + if (hadSelection || hadIsolation) { + setSelectedRefs([]); + state.selectedNet = null; + state.selectedPin = null; + state.isolateNet = false; + state.isolateComponent = false; + renderAll(); + } } }); @@ -2290,6 +2432,9 @@ function setupEvents() { try { const parsed = JSON.parse(el.jsonEditor.value); const before = state.model ? clone(state.model) : null; + if (state.model) { + pushHistory("apply-json"); + } el.jsonFeedback.textContent = "Applying JSON..."; setSelectedRefs([]); state.selectedNet = null; @@ -2303,6 +2448,9 @@ function setupEvents() { }); el.newProjectBtn.addEventListener("click", async () => { + if (state.model) { + pushHistory("new-project"); + } setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; @@ -2319,6 +2467,14 @@ function setupEvents() { await runLayoutAction("/layout/tidy"); }); + el.undoBtn.addEventListener("click", async () => { + await performUndo(); + }); + + el.redoBtn.addEventListener("click", async () => { + await performRedo(); + }); + el.importBtn.addEventListener("click", () => { el.fileInput.click(); }); @@ -2332,6 +2488,9 @@ function setupEvents() { try { const content = await file.text(); const parsed = JSON.parse(content); + if (state.model) { + pushHistory("import-json"); + } setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; diff --git a/frontend/index.html b/frontend/index.html index e39c967..1b5e9de 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,8 @@ + +