diff --git a/frontend/app.js b/frontend/app.js index eb526d6..4f32b18 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -63,6 +63,9 @@ const el = { lockedInput: document.getElementById("lockedInput"), rotateSelectedBtn: document.getElementById("rotateSelectedBtn"), updatePlacementBtn: document.getElementById("updatePlacementBtn"), + duplicateComponentBtn: document.getElementById("duplicateComponentBtn"), + deleteComponentBtn: document.getElementById("deleteComponentBtn"), + isolateSelectedComponentBtn: document.getElementById("isolateSelectedComponentBtn"), symbolMeta: document.getElementById("symbolMeta"), symbolCategoryInput: document.getElementById("symbolCategoryInput"), symbolWidthInput: document.getElementById("symbolWidthInput"), @@ -87,6 +90,7 @@ const el = { netNameInput: document.getElementById("netNameInput"), netClassInput: document.getElementById("netClassInput"), updateNetBtn: document.getElementById("updateNetBtn"), + isolateSelectedNetBtn: document.getElementById("isolateSelectedNetBtn"), netNodeRefInput: document.getElementById("netNodeRefInput"), netNodePinInput: document.getElementById("netNodePinInput"), addNetNodeBtn: document.getElementById("addNetNodeBtn"), @@ -261,6 +265,19 @@ function normalizeNetName(raw) { .replace(/\s+/g, "_"); } +function nextRefLike(baseRef) { + const base = normalizeRef(baseRef || "X1"); + const m = /^([A-Za-z_]+)(\d+)?$/i.exec(base); + const prefix = m?.[1] ?? `${base}_`; + let n = Number(m?.[2] ?? 1); + let candidate = `${prefix}${n}`; + while (instanceByRef(candidate)) { + n += 1; + candidate = `${prefix}${n}`; + } + return candidate; +} + function escHtml(text) { return String(text ?? "") .replaceAll("&", "&") @@ -890,6 +907,11 @@ function renderNetEditor() { } function renderSelected() { + el.duplicateComponentBtn.disabled = true; + el.deleteComponentBtn.disabled = true; + el.isolateSelectedComponentBtn.disabled = true; + el.isolateSelectedNetBtn.disabled = true; + if (!state.model) { el.selectedSummary.textContent = "Click a component, net, or pin to inspect it."; el.componentEditor.classList.add("hidden"); @@ -931,6 +953,9 @@ function renderSelected() { el.instRefInput.value = inst.ref; el.instValueInput.value = String(inst.properties?.value ?? ""); el.instNotesInput.value = String(inst.properties?.notes ?? ""); + el.duplicateComponentBtn.disabled = false; + el.deleteComponentBtn.disabled = false; + el.isolateSelectedComponentBtn.disabled = false; renderSymbolEditorForRef(inst.ref); el.pinEditor.classList.add("hidden"); el.netEditor.classList.add("hidden"); @@ -941,6 +966,7 @@ function renderSelected() { const net = state.model.nets.find((n) => n.name === state.selectedNet); if (net) { el.selectedSummary.textContent = `${net.name} (${net.class})\nNodes: ${net.nodes.map((n) => `${n.ref}.${n.pin}`).join(", ")}`; + el.isolateSelectedNetBtn.disabled = false; el.componentEditor.classList.add("hidden"); el.symbolEditor.classList.add("hidden"); el.pinEditor.classList.add("hidden"); @@ -1915,6 +1941,63 @@ function setupEvents() { queueCompile(true, "rotate"); }); + el.duplicateComponentBtn.addEventListener("click", () => { + if (state.selectedRefs.length !== 1 || !state.selectedRef || !state.model) { + return; + } + const inst = instanceByRef(state.selectedRef); + if (!inst) { + return; + } + pushHistory("duplicate-component"); + const nextRef = nextRefLike(inst.ref); + const next = clone(inst); + next.ref = nextRef; + next.placement = { + ...next.placement, + x: toGrid(Number(next.placement.x ?? 0) + GRID * 2), + y: toGrid(Number(next.placement.y ?? 0) + GRID * 2), + locked: false + }; + state.model.instances.push(next); + selectSingleRef(nextRef); + state.selectedNet = null; + state.selectedPin = null; + state.isolateNet = false; + el.jsonFeedback.textContent = `Duplicated ${inst.ref} as ${nextRef}.`; + queueCompile(true, "duplicate-component"); + }); + + el.deleteComponentBtn.addEventListener("click", () => { + if (state.selectedRefs.length !== 1 || !state.selectedRef || !state.model) { + return; + } + const ref = state.selectedRef; + pushHistory("delete-component"); + state.model.instances = state.model.instances.filter((i) => i.ref !== ref); + for (const net of state.model.nets ?? []) { + net.nodes = (net.nodes ?? []).filter((n) => n.ref !== ref); + } + state.model.nets = (state.model.nets ?? []).filter((n) => (n.nodes ?? []).length >= 2); + setSelectedRefs([]); + state.selectedNet = null; + state.selectedPin = null; + state.isolateComponent = false; + state.isolateNet = false; + el.jsonFeedback.textContent = `Deleted component ${ref}.`; + queueCompile(true, "delete-component"); + }); + + el.isolateSelectedComponentBtn.addEventListener("click", () => { + if (!state.selectedRef) { + return; + } + state.isolateComponent = true; + state.isolateNet = false; + state.selectedNet = null; + renderAll(); + }); + el.showPinNetLabelInput.addEventListener("change", () => { if (!state.selectedPin) { return; @@ -2054,6 +2137,17 @@ function setupEvents() { queueCompile(true, "net-edit"); }); + el.isolateSelectedNetBtn.addEventListener("click", () => { + if (!state.selectedNet) { + return; + } + state.isolateNet = true; + state.isolateComponent = false; + setSelectedRefs([]); + state.selectedPin = null; + renderAll(); + }); + el.addNetNodeBtn.addEventListener("click", () => { if (!state.selectedNet) { return; diff --git a/frontend/index.html b/frontend/index.html index 1b5e9de..8330fea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -92,6 +92,9 @@