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 @@
+
+