Harden symbol editor with migration preview and pin reorder

This commit is contained in:
Rbanh 2026-02-18 20:51:19 -05:00
parent c6578c05fe
commit fcad4b284b
3 changed files with 260 additions and 61 deletions

View File

@ -40,7 +40,8 @@ const state = {
historyPast: [], historyPast: [],
historyFuture: [], historyFuture: [],
historyLimit: 80, historyLimit: 80,
historyRestoring: false historyRestoring: false,
symbolMigrationAckHash: null
}; };
const el = { const el = {
@ -78,8 +79,10 @@ const el = {
symbolWidthInput: document.getElementById("symbolWidthInput"), symbolWidthInput: document.getElementById("symbolWidthInput"),
symbolHeightInput: document.getElementById("symbolHeightInput"), symbolHeightInput: document.getElementById("symbolHeightInput"),
addSymbolPinBtn: document.getElementById("addSymbolPinBtn"), addSymbolPinBtn: document.getElementById("addSymbolPinBtn"),
previewSymbolBtn: document.getElementById("previewSymbolBtn"),
applySymbolBtn: document.getElementById("applySymbolBtn"), applySymbolBtn: document.getElementById("applySymbolBtn"),
symbolValidation: document.getElementById("symbolValidation"), symbolValidation: document.getElementById("symbolValidation"),
symbolMigrationPreview: document.getElementById("symbolMigrationPreview"),
symbolPinsList: document.getElementById("symbolPinsList"), symbolPinsList: document.getElementById("symbolPinsList"),
pinMeta: document.getElementById("pinMeta"), pinMeta: document.getElementById("pinMeta"),
pinNameInput: document.getElementById("pinNameInput"), pinNameInput: document.getElementById("pinNameInput"),
@ -871,10 +874,194 @@ function symbolPinRowHtml(pin) {
<select class="pinCol pinSide">${sideOptions}</select> <select class="pinCol pinSide">${sideOptions}</select>
<input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" /> <input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" />
<select class="pinCol pinType">${typeOptions}</select> <select class="pinCol pinType">${typeOptions}</select>
<button type="button" data-move-symbol-pin="up" title="Move pin up">Up</button>
<button type="button" data-move-symbol-pin="down" title="Move pin down">Down</button>
<button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button> <button type="button" data-remove-symbol-pin="${escHtml(pin.name)}">Remove</button>
</div>`; </div>`;
} }
function invalidateSymbolMigrationPreview(message = "") {
state.symbolMigrationAckHash = null;
if (el.symbolMigrationPreview) {
el.symbolMigrationPreview.textContent = message;
el.symbolMigrationPreview.classList.remove("migrationPreview");
}
}
function collectSymbolDraft(ref) {
const inst = instanceByRef(ref);
const sym = symbolForRef(ref);
if (!inst || !sym) {
return { ok: false, message: "No symbol selected." };
}
const nextCategory = String(el.symbolCategoryInput.value ?? "").trim() || String(sym.category ?? "generic");
const nextWidth = Number(el.symbolWidthInput.value);
const nextHeight = Number(el.symbolHeightInput.value);
if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth < 20 || nextHeight < 20) {
return { ok: false, message: "Symbol width/height must be >= 20." };
}
const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")];
clearSymbolRowValidation(rows);
if (!rows.length) {
return { ok: false, message: "Symbol must contain at least one pin row." };
}
const parsedPins = [];
for (const row of rows) {
const name = String(row.querySelector(".pinName")?.value ?? "").trim();
const number = String(row.querySelector(".pinNumber")?.value ?? "").trim();
const side = String(row.querySelector(".pinSide")?.value ?? "");
const offset = Number(row.querySelector(".pinOffset")?.value ?? 0);
const type = String(row.querySelector(".pinType")?.value ?? "");
if (!name || !number || !PIN_SIDES.includes(side) || !PIN_TYPES.includes(type) || !Number.isFinite(offset) || offset < 0) {
row.classList.add("invalidRow");
return { ok: false, message: "Each pin row needs name, number, valid side/type, and offset >= 0." };
}
parsedPins.push({
oldName: row.getAttribute("data-old-pin") ?? name,
pin: { name, number, side, offset: Math.round(offset), type }
});
}
const nameCounts = new Map();
const numberCounts = new Map();
for (const p of parsedPins) {
nameCounts.set(p.pin.name, (nameCounts.get(p.pin.name) ?? 0) + 1);
numberCounts.set(p.pin.number, (numberCounts.get(p.pin.number) ?? 0) + 1);
}
let dupName = null;
let dupNumber = null;
for (const [name, count] of nameCounts) {
if (count > 1) {
dupName = name;
break;
}
}
for (const [num, count] of numberCounts) {
if (count > 1) {
dupNumber = num;
break;
}
}
if (dupName || dupNumber) {
for (const row of rows) {
const name = String(row.querySelector(".pinName")?.value ?? "").trim();
const num = String(row.querySelector(".pinNumber")?.value ?? "").trim();
if ((dupName && name === dupName) || (dupNumber && num === dupNumber)) {
row.classList.add("invalidRow");
}
}
if (dupName) {
return { ok: false, message: `Duplicate pin name '${dupName}' detected.` };
}
return { ok: false, message: `Duplicate pin number '${dupNumber}' detected.` };
}
return {
ok: true,
inst,
sym,
parsedPins,
nextCategory,
nextWidth: Math.round(nextWidth),
nextHeight: Math.round(nextHeight)
};
}
function buildSymbolMigrationPlan(symbolId, beforePins, parsedPins) {
const beforeNames = new Set(beforePins.map((p) => p.name));
const afterNames = new Set(parsedPins.map((p) => p.pin.name));
const renameMap = new Map();
for (const entry of parsedPins) {
if (entry.oldName && beforeNames.has(entry.oldName) && entry.oldName !== entry.pin.name) {
renameMap.set(entry.oldName, entry.pin.name);
}
}
const removedPins = beforePins
.map((p) => p.name)
.filter((name) => !afterNames.has(name) && !renameMap.has(name))
.sort((a, b) => a.localeCompare(b));
const renamedPins = [...renameMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === symbolId).map((i) => i.ref));
let droppedNodes = 0;
const touchedNets = new Set();
for (const net of state.model.nets ?? []) {
for (const node of net.nodes ?? []) {
if (!refs.has(node.ref)) {
continue;
}
const migratedPin = renameMap.get(node.pin) ?? node.pin;
if (!afterNames.has(migratedPin)) {
droppedNodes += 1;
touchedNets.add(net.name);
}
}
}
let removedPinUiEntries = 0;
for (const inst of state.model.instances ?? []) {
if (!refs.has(inst.ref)) {
continue;
}
const pinUi = inst.properties?.pin_ui;
if (!pinUi || typeof pinUi !== "object" || Array.isArray(pinUi)) {
continue;
}
for (const key of Object.keys(pinUi)) {
const migratedPin = renameMap.get(key) ?? key;
if (!afterNames.has(migratedPin)) {
removedPinUiEntries += 1;
}
}
}
const hasDestructive = removedPins.length > 0 || droppedNodes > 0 || removedPinUiEntries > 0;
const hash = JSON.stringify({
symbolId,
renamedPins,
removedPins,
droppedNodes,
touchedNets: [...touchedNets].sort(),
removedPinUiEntries
});
return {
hash,
hasDestructive,
renamedPins,
removedPins,
droppedNodes,
touchedNets: [...touchedNets].sort(),
removedPinUiEntries
};
}
function renderSymbolMigrationPlan(plan) {
if (!el.symbolMigrationPreview) {
return;
}
const lines = [];
lines.push(`Renamed pins: ${plan.renamedPins.length}`);
lines.push(`Removed pins: ${plan.removedPins.length}`);
lines.push(`Dropped net nodes: ${plan.droppedNodes}`);
lines.push(`Removed pin-ui mappings: ${plan.removedPinUiEntries}`);
if (plan.removedPins.length) {
lines.push(`Removed: ${plan.removedPins.join(", ")}`);
}
if (plan.touchedNets.length) {
const sample = plan.touchedNets.slice(0, 8).join(", ");
const suffix = plan.touchedNets.length > 8 ? ` (+${plan.touchedNets.length - 8} more)` : "";
lines.push(`Affected nets: ${sample}${suffix}`);
}
lines.push(plan.hasDestructive ? "Destructive changes detected. Preview must be acknowledged before apply." : "No destructive migration detected.");
el.symbolMigrationPreview.innerHTML = `<div>${lines.map((line) => escHtml(line)).join("<br/>")}</div>`;
el.symbolMigrationPreview.classList.add("migrationPreview");
}
function renderSymbolEditorForRef(ref) { function renderSymbolEditorForRef(ref) {
const inst = instanceByRef(ref); const inst = instanceByRef(ref);
const sym = symbolForRef(ref); const sym = symbolForRef(ref);
@ -888,6 +1075,7 @@ function renderSymbolEditorForRef(ref) {
el.symbolHeightInput.value = String(Number(sym.body?.height ?? 80)); el.symbolHeightInput.value = String(Number(sym.body?.height ?? 80));
el.symbolValidation.textContent = ""; el.symbolValidation.textContent = "";
el.symbolValidation.classList.remove("symbolValidationError"); el.symbolValidation.classList.remove("symbolValidationError");
invalidateSymbolMigrationPreview("");
el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join(""); el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join("");
el.symbolEditor.classList.remove("hidden"); el.symbolEditor.classList.remove("hidden");
} }
@ -2318,9 +2506,26 @@ function setupEvents() {
type: "passive" type: "passive"
}; };
el.symbolPinsList.insertAdjacentHTML("beforeend", symbolPinRowHtml(row)); el.symbolPinsList.insertAdjacentHTML("beforeend", symbolPinRowHtml(row));
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
}); });
el.symbolPinsList.addEventListener("click", (evt) => { el.symbolPinsList.addEventListener("click", (evt) => {
const moveBtn = evt.target.closest("[data-move-symbol-pin]");
if (moveBtn) {
const row = moveBtn.closest(".symbolPinRow");
if (!row) {
return;
}
const dir = moveBtn.getAttribute("data-move-symbol-pin");
if (dir === "up" && row.previousElementSibling) {
row.parentElement.insertBefore(row, row.previousElementSibling);
} else if (dir === "down" && row.nextElementSibling) {
row.parentElement.insertBefore(row.nextElementSibling, row);
}
invalidateSymbolMigrationPreview("Pin order changed. Preview migration again before apply.");
return;
}
const btn = evt.target.closest("[data-remove-symbol-pin]"); const btn = evt.target.closest("[data-remove-symbol-pin]");
if (!btn) { if (!btn) {
return; return;
@ -2328,6 +2533,7 @@ function setupEvents() {
const row = btn.closest(".symbolPinRow"); const row = btn.closest(".symbolPinRow");
if (row) { if (row) {
row.remove(); row.remove();
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
} }
}); });
@ -2340,76 +2546,50 @@ function setupEvents() {
el.symbolValidation.textContent = ""; el.symbolValidation.textContent = "";
el.symbolValidation.classList.remove("symbolValidationError"); el.symbolValidation.classList.remove("symbolValidationError");
} }
invalidateSymbolMigrationPreview("Symbol changes pending. Preview migration before apply.");
});
el.previewSymbolBtn.addEventListener("click", () => {
if (!state.selectedRef) {
return;
}
const draft = collectSymbolDraft(state.selectedRef);
if (!draft.ok) {
el.jsonFeedback.textContent = draft.message;
el.symbolValidation.textContent = draft.message;
el.symbolValidation.classList.add("symbolValidationError");
invalidateSymbolMigrationPreview("");
return;
}
const plan = buildSymbolMigrationPlan(draft.inst.symbol, draft.sym.pins ?? [], draft.parsedPins);
renderSymbolMigrationPlan(plan);
state.symbolMigrationAckHash = plan.hash;
el.jsonFeedback.textContent = plan.hasDestructive
? "Migration preview acknowledged. Apply is now enabled for destructive symbol edits."
: "Preview complete. No destructive migration detected.";
}); });
el.applySymbolBtn.addEventListener("click", () => { el.applySymbolBtn.addEventListener("click", () => {
if (!state.selectedRef) { if (!state.selectedRef) {
return; return;
} }
const inst = instanceByRef(state.selectedRef); const draft = collectSymbolDraft(state.selectedRef);
const sym = symbolForRef(state.selectedRef); if (!draft.ok) {
if (!inst || !sym) { el.jsonFeedback.textContent = draft.message;
return; el.symbolValidation.textContent = draft.message;
}
const nextCategory = String(el.symbolCategoryInput.value ?? "").trim() || String(sym.category ?? "generic");
const nextWidth = Number(el.symbolWidthInput.value);
const nextHeight = Number(el.symbolHeightInput.value);
if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth < 20 || nextHeight < 20) {
el.jsonFeedback.textContent = "Symbol width/height must be >= 20.";
return;
}
const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")];
clearSymbolRowValidation(rows);
el.symbolValidation.textContent = "";
el.symbolValidation.classList.remove("symbolValidationError");
if (!rows.length) {
el.jsonFeedback.textContent = "Symbol must have at least one pin.";
el.symbolValidation.textContent = "Symbol must contain at least one pin row.";
el.symbolValidation.classList.add("symbolValidationError"); el.symbolValidation.classList.add("symbolValidationError");
return; return;
} }
const { inst, sym, parsedPins, nextCategory, nextWidth, nextHeight } = draft;
const parsedPins = []; const plan = buildSymbolMigrationPlan(inst.symbol, sym.pins ?? [], parsedPins);
const rowErrors = []; if (plan.hasDestructive && state.symbolMigrationAckHash !== plan.hash) {
for (const row of rows) { renderSymbolMigrationPlan(plan);
const name = String(row.querySelector(".pinName")?.value ?? "").trim(); el.jsonFeedback.textContent = "Destructive symbol edit detected. Click 'Preview Migration' before applying.";
const number = String(row.querySelector(".pinNumber")?.value ?? "").trim(); el.symbolValidation.textContent = "Preview migration is required for pin removals or dropped net mappings.";
const side = String(row.querySelector(".pinSide")?.value ?? "");
const offset = Number(row.querySelector(".pinOffset")?.value ?? 0);
const type = String(row.querySelector(".pinType")?.value ?? "");
if (!name || !number || !PIN_SIDES.includes(side) || !PIN_TYPES.includes(type) || !Number.isFinite(offset) || offset < 0) {
row.classList.add("invalidRow");
rowErrors.push("Each pin row needs name, number, valid side/type, and offset >= 0.");
continue;
}
parsedPins.push({
oldName: row.getAttribute("data-old-pin") ?? name,
pin: { name, number, side, offset: Math.round(offset), type }
});
}
if (rowErrors.length) {
el.jsonFeedback.textContent = "Fix invalid symbol pin rows before applying.";
el.symbolValidation.textContent = rowErrors[0];
el.symbolValidation.classList.add("symbolValidationError"); el.symbolValidation.classList.add("symbolValidationError");
return; return;
} }
const unique = new Set(parsedPins.map((p) => p.pin.name));
if (unique.size !== parsedPins.length) {
el.jsonFeedback.textContent = "Duplicate pin names are not allowed.";
el.symbolValidation.textContent = "Duplicate pin names detected. Each pin name must be unique.";
el.symbolValidation.classList.add("symbolValidationError");
for (const row of rows) {
const name = String(row.querySelector(".pinName")?.value ?? "").trim();
if (name && parsedPins.filter((p) => p.pin.name === name).length > 1) {
row.classList.add("invalidRow");
}
}
return;
}
pushHistory("symbol-edit"); pushHistory("symbol-edit");
const beforePins = new Set((sym.pins ?? []).map((p) => p.name)); const beforePins = new Set((sym.pins ?? []).map((p) => p.name));
for (const entry of parsedPins) { for (const entry of parsedPins) {
@ -2420,8 +2600,8 @@ function setupEvents() {
sym.category = nextCategory; sym.category = nextCategory;
sym.body = { sym.body = {
...(sym.body ?? {}), ...(sym.body ?? {}),
width: Math.round(nextWidth), width: nextWidth,
height: Math.round(nextHeight) height: nextHeight
}; };
sym.pins = parsedPins.map((p) => p.pin); sym.pins = parsedPins.map((p) => p.pin);
const allowedPins = new Set(sym.pins.map((p) => p.name)); const allowedPins = new Set(sym.pins.map((p) => p.name));
@ -2450,6 +2630,7 @@ function setupEvents() {
} }
el.symbolValidation.textContent = ""; el.symbolValidation.textContent = "";
el.symbolValidation.classList.remove("symbolValidationError"); el.symbolValidation.classList.remove("symbolValidationError");
invalidateSymbolMigrationPreview("");
const removedPinCount = [...beforePins].filter((p) => !allowedPins.has(p)).length; const removedPinCount = [...beforePins].filter((p) => !allowedPins.has(p)).length;
el.jsonFeedback.textContent = removedPinCount el.jsonFeedback.textContent = removedPinCount
? `Updated symbol ${inst.symbol}. Removed ${removedPinCount} pin mappings from nets/UI metadata.` ? `Updated symbol ${inst.symbol}. Removed ${removedPinCount} pin mappings from nets/UI metadata.`
@ -2457,6 +2638,12 @@ function setupEvents() {
queueCompile(true, "symbol-edit"); queueCompile(true, "symbol-edit");
}); });
[el.symbolCategoryInput, el.symbolWidthInput, el.symbolHeightInput].forEach((input) => {
input?.addEventListener("input", () => {
invalidateSymbolMigrationPreview("Symbol changes pending. Preview migration before apply.");
});
});
el.zoomInBtn.addEventListener("click", () => { el.zoomInBtn.addEventListener("click", () => {
state.scale = Math.min(4, state.scale + 0.1); state.scale = Math.min(4, state.scale + 0.1);
state.userAdjustedView = true; state.userAdjustedView = true;

View File

@ -111,9 +111,11 @@
</div> </div>
<div class="editorActions"> <div class="editorActions">
<button id="addSymbolPinBtn">Add Pin</button> <button id="addSymbolPinBtn">Add Pin</button>
<button id="previewSymbolBtn">Preview Migration</button>
<button id="applySymbolBtn">Apply Symbol</button> <button id="applySymbolBtn">Apply Symbol</button>
</div> </div>
<div id="symbolValidation" class="hintText"></div> <div id="symbolValidation" class="hintText"></div>
<div id="symbolMigrationPreview" class="hintText"></div>
<div id="symbolPinsList" class="miniList"></div> <div id="symbolPinsList" class="miniList"></div>
</div> </div>
</details> </details>

View File

@ -284,7 +284,7 @@ textarea {
.symbolPinRow { .symbolPinRow {
display: grid; display: grid;
grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto; grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto auto auto;
align-items: center; align-items: center;
} }
@ -303,6 +303,16 @@ textarea {
font-size: 0.76rem; font-size: 0.76rem;
} }
.migrationPreview {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px;
background: #f8fafc;
margin-top: 6px;
margin-bottom: 6px;
line-height: 1.35;
}
.canvasTools { .canvasTools {
display: flex; display: flex;
gap: 8px; gap: 8px;