Harden selection UX and add undo/redo history controls
This commit is contained in:
parent
6c5431a6e5
commit
85e5a345f1
163
frontend/app.js
163
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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -69,6 +69,11 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user