Harden selection UX and add undo/redo history controls

This commit is contained in:
Rbanh 2026-02-16 22:00:56 -05:00
parent 6c5431a6e5
commit 85e5a345f1
3 changed files with 168 additions and 2 deletions

View File

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

View File

@ -19,6 +19,8 @@
<button id="exportBtn">Export JSON</button>
<button id="autoLayoutBtn">Auto Layout</button>
<button id="autoTidyBtn">Auto Tidy</button>
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
<label class="inlineSelect">
Mode
<select id="renderModeSelect">

View File

@ -69,6 +69,11 @@ button {
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
color: #fff;