Harden symbol editor with migration preview and pin reorder
This commit is contained in:
parent
c6578c05fe
commit
fcad4b284b
307
frontend/app.js
307
frontend/app.js
@ -40,7 +40,8 @@ const state = {
|
||||
historyPast: [],
|
||||
historyFuture: [],
|
||||
historyLimit: 80,
|
||||
historyRestoring: false
|
||||
historyRestoring: false,
|
||||
symbolMigrationAckHash: null
|
||||
};
|
||||
|
||||
const el = {
|
||||
@ -78,8 +79,10 @@ const el = {
|
||||
symbolWidthInput: document.getElementById("symbolWidthInput"),
|
||||
symbolHeightInput: document.getElementById("symbolHeightInput"),
|
||||
addSymbolPinBtn: document.getElementById("addSymbolPinBtn"),
|
||||
previewSymbolBtn: document.getElementById("previewSymbolBtn"),
|
||||
applySymbolBtn: document.getElementById("applySymbolBtn"),
|
||||
symbolValidation: document.getElementById("symbolValidation"),
|
||||
symbolMigrationPreview: document.getElementById("symbolMigrationPreview"),
|
||||
symbolPinsList: document.getElementById("symbolPinsList"),
|
||||
pinMeta: document.getElementById("pinMeta"),
|
||||
pinNameInput: document.getElementById("pinNameInput"),
|
||||
@ -871,10 +874,194 @@ function symbolPinRowHtml(pin) {
|
||||
<select class="pinCol pinSide">${sideOptions}</select>
|
||||
<input class="pinCol pinOffset" type="number" min="0" step="1" value="${Number(pin.offset ?? 0)}" />
|
||||
<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>
|
||||
</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) {
|
||||
const inst = instanceByRef(ref);
|
||||
const sym = symbolForRef(ref);
|
||||
@ -888,6 +1075,7 @@ function renderSymbolEditorForRef(ref) {
|
||||
el.symbolHeightInput.value = String(Number(sym.body?.height ?? 80));
|
||||
el.symbolValidation.textContent = "";
|
||||
el.symbolValidation.classList.remove("symbolValidationError");
|
||||
invalidateSymbolMigrationPreview("");
|
||||
el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join("");
|
||||
el.symbolEditor.classList.remove("hidden");
|
||||
}
|
||||
@ -2318,9 +2506,26 @@ function setupEvents() {
|
||||
type: "passive"
|
||||
};
|
||||
el.symbolPinsList.insertAdjacentHTML("beforeend", symbolPinRowHtml(row));
|
||||
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
|
||||
});
|
||||
|
||||
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]");
|
||||
if (!btn) {
|
||||
return;
|
||||
@ -2328,6 +2533,7 @@ function setupEvents() {
|
||||
const row = btn.closest(".symbolPinRow");
|
||||
if (row) {
|
||||
row.remove();
|
||||
invalidateSymbolMigrationPreview("Pin rows changed. Preview migration again before apply.");
|
||||
}
|
||||
});
|
||||
|
||||
@ -2340,76 +2546,50 @@ function setupEvents() {
|
||||
el.symbolValidation.textContent = "";
|
||||
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", () => {
|
||||
if (!state.selectedRef) {
|
||||
return;
|
||||
}
|
||||
const inst = instanceByRef(state.selectedRef);
|
||||
const sym = symbolForRef(state.selectedRef);
|
||||
if (!inst || !sym) {
|
||||
return;
|
||||
}
|
||||
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.";
|
||||
const draft = collectSymbolDraft(state.selectedRef);
|
||||
if (!draft.ok) {
|
||||
el.jsonFeedback.textContent = draft.message;
|
||||
el.symbolValidation.textContent = draft.message;
|
||||
el.symbolValidation.classList.add("symbolValidationError");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedPins = [];
|
||||
const rowErrors = [];
|
||||
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");
|
||||
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];
|
||||
const { inst, sym, parsedPins, nextCategory, nextWidth, nextHeight } = draft;
|
||||
const plan = buildSymbolMigrationPlan(inst.symbol, sym.pins ?? [], parsedPins);
|
||||
if (plan.hasDestructive && state.symbolMigrationAckHash !== plan.hash) {
|
||||
renderSymbolMigrationPlan(plan);
|
||||
el.jsonFeedback.textContent = "Destructive symbol edit detected. Click 'Preview Migration' before applying.";
|
||||
el.symbolValidation.textContent = "Preview migration is required for pin removals or dropped net mappings.";
|
||||
el.symbolValidation.classList.add("symbolValidationError");
|
||||
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");
|
||||
const beforePins = new Set((sym.pins ?? []).map((p) => p.name));
|
||||
for (const entry of parsedPins) {
|
||||
@ -2420,8 +2600,8 @@ function setupEvents() {
|
||||
sym.category = nextCategory;
|
||||
sym.body = {
|
||||
...(sym.body ?? {}),
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight)
|
||||
width: nextWidth,
|
||||
height: nextHeight
|
||||
};
|
||||
sym.pins = parsedPins.map((p) => p.pin);
|
||||
const allowedPins = new Set(sym.pins.map((p) => p.name));
|
||||
@ -2450,6 +2630,7 @@ function setupEvents() {
|
||||
}
|
||||
el.symbolValidation.textContent = "";
|
||||
el.symbolValidation.classList.remove("symbolValidationError");
|
||||
invalidateSymbolMigrationPreview("");
|
||||
const removedPinCount = [...beforePins].filter((p) => !allowedPins.has(p)).length;
|
||||
el.jsonFeedback.textContent = removedPinCount
|
||||
? `Updated symbol ${inst.symbol}. Removed ${removedPinCount} pin mappings from nets/UI metadata.`
|
||||
@ -2457,6 +2638,12 @@ function setupEvents() {
|
||||
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", () => {
|
||||
state.scale = Math.min(4, state.scale + 0.1);
|
||||
state.userAdjustedView = true;
|
||||
|
||||
@ -111,9 +111,11 @@
|
||||
</div>
|
||||
<div class="editorActions">
|
||||
<button id="addSymbolPinBtn">Add Pin</button>
|
||||
<button id="previewSymbolBtn">Preview Migration</button>
|
||||
<button id="applySymbolBtn">Apply Symbol</button>
|
||||
</div>
|
||||
<div id="symbolValidation" class="hintText"></div>
|
||||
<div id="symbolMigrationPreview" class="hintText"></div>
|
||||
<div id="symbolPinsList" class="miniList"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@ -284,7 +284,7 @@ textarea {
|
||||
|
||||
.symbolPinRow {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -303,6 +303,16 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user