Make Ctrl+Plus context aware

This commit is contained in:
2026-07-22 20:15:33 +02:00
parent fd62bf2e15
commit a6b83285eb
5 changed files with 243 additions and 16 deletions
+3
View File
@@ -37,6 +37,9 @@ Selection, keyboard movement, and editability checks run against this normalized
- `Tab` / `Shift+Tab`: move between editable cells (commits active edit)
- arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
## `-frei-` Placeholder Behavior
+2 -2
View File
@@ -11,8 +11,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
+74 -14
View File
@@ -5,6 +5,10 @@ import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
} from "../../domain/services/project-device-placement.service";
import {
getInsertionSortOrder,
resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion";
import {
createCircuit,
createCircuitDeviceRow,
@@ -1616,7 +1620,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
requestAnimationFrame(() => containerRef.current?.focus());
}
async function handleAddReserveCircuit(sectionId: string) {
async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) {
const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) {
return;
@@ -1626,8 +1630,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Add circuit",
redo: async () => {
const next = await getNextCircuitIdentifier(sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const created = (await createCircuit(projectId, circuitListId, {
sectionId,
equipmentIdentifier: next.nextIdentifier,
@@ -1659,7 +1662,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
async function handleAddManualDevice(
circuit: CircuitTreeCircuitDto,
sectionId: string,
afterDeviceRowId?: string
) {
const originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId);
let createdRowId: string | null = null;
await runCommand({
label: "Add manual device",
@@ -1667,18 +1676,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const created = (await createCircuitDeviceRow(circuit.id, {
name: "Manual device",
displayName: "Manual device",
phaseType: "single_phase",
phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase",
quantity: 1,
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId),
})) as { id: string };
createdRowId = created.id;
setActiveSectionId(sectionId);
return {
rowKey: `device:${created.id}`,
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`,
cellKey: "displayName",
rowType: "deviceRow",
rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow",
sectionId,
circuitId: circuit.id,
deviceId: created.id,
@@ -1688,17 +1698,68 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (createdRowId) {
await deleteCircuitDeviceRowById(createdRowId);
}
const rowKey =
originalDeviceCount === 0
? `reserveCircuit:${circuit.id}`
: originalDeviceCount === 1
? `circuitCompact:${circuit.id}`
: afterDeviceRowId
? `device:${afterDeviceRowId}`
: `circuitSummary:${circuit.id}`;
return {
rowKey: `reserveCircuit:${circuit.id}`,
rowKey,
cellKey: "displayName",
rowType: "reserveCircuit",
rowType:
originalDeviceCount === 0
? "reserveCircuit"
: originalDeviceCount === 1
? "circuitCompact"
: afterDeviceRowId
? "deviceRow"
: "circuitSummary",
sectionId,
circuitId: circuit.id,
deviceId: afterDeviceRowId,
};
},
});
}
async function handleInsertBelowSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const selection =
row && cell && row.rowType !== "section"
? {
rowType: row.rowType,
cellKind: cell.kind,
sectionId: row.sectionId,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null;
const intent = resolveGridInsertionIntent(
selection,
activeSectionId ?? data?.sections[0]?.id
);
if (!intent) {
setError("No section is available for insertion.");
return;
}
if (intent.kind === "circuit") {
await handleAddReserveCircuit(intent.sectionId, intent.afterCircuitId);
return;
}
const circuit = data?.sections
.flatMap((sectionEntry) => sectionEntry.circuits)
.find((entry) => entry.id === intent.circuitId);
if (!circuit) {
setError("Invalid circuit id.");
return;
}
await handleAddManualDevice(circuit, intent.sectionId, intent.afterDeviceRowId);
}
function resolvePhaseType(device: ProjectDeviceDto): string {
return device.phaseType;
}
@@ -2472,10 +2533,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
if (isCtrlPlus) {
event.preventDefault();
const sectionId = activeSectionId ?? data?.sections[0]?.id;
if (sectionId) {
void handleAddReserveCircuit(sectionId);
}
void handleInsertBelowSelection();
return;
}
if (event.key === "Tab" && selectedCell) {
@@ -3486,7 +3544,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</table>
</div>
</div>
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
<p className="todo-hint">
Ctrl+Plus inserts below the selected circuit or device context. Use Undo to revert the insertion.
</p>
</div>
);
}
@@ -0,0 +1,79 @@
export type GridInsertionRowType =
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "reserveCircuit"
| "placeholder";
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
export interface GridInsertionSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
sectionId: string;
circuitId?: string;
deviceId?: string;
}
export type GridInsertionIntent =
| { kind: "circuit"; sectionId: string; afterCircuitId?: string }
| { kind: "device"; sectionId: string; circuitId: string; afterDeviceRowId?: string };
export function resolveGridInsertionIntent(
selection: GridInsertionSelection | null,
fallbackSectionId?: string
): GridInsertionIntent | null {
if (!selection) {
return fallbackSectionId ? { kind: "circuit", sectionId: fallbackSectionId } : null;
}
if (selection.rowType === "placeholder") {
return { kind: "circuit", sectionId: selection.sectionId };
}
const targetsDevice =
selection.rowType === "deviceRow" ||
((selection.rowType === "circuitCompact" || selection.rowType === "reserveCircuit") &&
selection.cellKind === "deviceField");
if (targetsDevice && selection.circuitId) {
return {
kind: "device",
sectionId: selection.sectionId,
circuitId: selection.circuitId,
afterDeviceRowId: selection.deviceId,
};
}
if (selection.circuitId) {
return {
kind: "circuit",
sectionId: selection.sectionId,
afterCircuitId: selection.circuitId,
};
}
return { kind: "circuit", sectionId: selection.sectionId };
}
export function getInsertionSortOrder(
orderedEntries: Array<{ id: string; sortOrder: number }>,
afterId?: string
): number {
if (orderedEntries.length === 0) {
return 10;
}
if (!afterId) {
return Math.max(...orderedEntries.map((entry) => entry.sortOrder)) + 10;
}
const entries = [...orderedEntries].sort((left, right) => left.sortOrder - right.sortOrder);
const index = entries.findIndex((entry) => entry.id === afterId);
if (index < 0) {
return entries[entries.length - 1].sortOrder + 10;
}
const current = entries[index].sortOrder;
const next = entries[index + 1]?.sortOrder;
return next === undefined ? current + 10 : current + (next - current) / 2;
}
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getInsertionSortOrder,
resolveGridInsertionIntent,
} from "../src/frontend/utils/circuit-grid-insertion.js";
describe("circuit grid insertion", () => {
it("inserts a circuit after circuit-level selections", () => {
assert.deepEqual(
resolveGridInsertionIntent(
{
rowType: "circuitCompact",
cellKind: "circuitField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-1",
},
"fallback"
),
{ kind: "circuit", sectionId: "section-1", afterCircuitId: "circuit-1" }
);
});
it("inserts a device after device-level selections in compact and expanded circuits", () => {
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "circuitCompact",
cellKind: "deviceField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-1",
}),
{
kind: "device",
sectionId: "section-1",
circuitId: "circuit-1",
afterDeviceRowId: "row-1",
}
);
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "deviceRow",
cellKind: "deviceField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-2",
}),
{
kind: "device",
sectionId: "section-1",
circuitId: "circuit-1",
afterDeviceRowId: "row-2",
}
);
});
it("uses the active section for placeholders and missing selections", () => {
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "placeholder",
cellKind: "deviceField",
sectionId: "section-1",
}),
{ kind: "circuit", sectionId: "section-1" }
);
assert.deepEqual(resolveGridInsertionIntent(null, "section-2"), {
kind: "circuit",
sectionId: "section-2",
});
});
it("creates stable sort values between neighbours or at the end", () => {
const entries = [
{ id: "a", sortOrder: 10 },
{ id: "b", sortOrder: 20 },
{ id: "c", sortOrder: 30 },
];
assert.equal(getInsertionSortOrder(entries, "a"), 15);
assert.equal(getInsertionSortOrder(entries, "c"), 40);
assert.equal(getInsertionSortOrder(entries), 40);
assert.equal(getInsertionSortOrder([], "missing"), 10);
});
});