Make Ctrl+Plus context aware
This commit is contained in:
@@ -37,6 +37,9 @@ Selection, keyboard movement, and editability checks run against this normalized
|
|||||||
- `Tab` / `Shift+Tab`: move between editable cells (commits active edit)
|
- `Tab` / `Shift+Tab`: move between editable cells (commits active edit)
|
||||||
- arrow keys: move selected cell in grid when not editing
|
- 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
|
- 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
|
## `-frei-` Placeholder Behavior
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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",
|
"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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
inferProjectDeviceSectionKey,
|
inferProjectDeviceSectionKey,
|
||||||
isProjectDevicePlacementValid,
|
isProjectDevicePlacementValid,
|
||||||
} from "../../domain/services/project-device-placement.service";
|
} from "../../domain/services/project-device-placement.service";
|
||||||
|
import {
|
||||||
|
getInsertionSortOrder,
|
||||||
|
resolveGridInsertionIntent,
|
||||||
|
} from "../utils/circuit-grid-insertion";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
createCircuitDeviceRow,
|
createCircuitDeviceRow,
|
||||||
@@ -1616,7 +1620,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
requestAnimationFrame(() => containerRef.current?.focus());
|
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);
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
return;
|
return;
|
||||||
@@ -1626,8 +1630,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
label: "Add circuit",
|
label: "Add circuit",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const next = await getNextCircuitIdentifier(sectionId);
|
const next = await getNextCircuitIdentifier(sectionId);
|
||||||
const sortOrder =
|
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
|
||||||
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
||||||
const created = (await createCircuit(projectId, circuitListId, {
|
const created = (await createCircuit(projectId, circuitListId, {
|
||||||
sectionId,
|
sectionId,
|
||||||
equipmentIdentifier: next.nextIdentifier,
|
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;
|
let createdRowId: string | null = null;
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Add manual device",
|
label: "Add manual device",
|
||||||
@@ -1667,18 +1676,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const created = (await createCircuitDeviceRow(circuit.id, {
|
const created = (await createCircuitDeviceRow(circuit.id, {
|
||||||
name: "Manual device",
|
name: "Manual device",
|
||||||
displayName: "Manual device",
|
displayName: "Manual device",
|
||||||
phaseType: "single_phase",
|
phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
powerPerUnit: 0,
|
powerPerUnit: 0,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
cosPhi: 1,
|
cosPhi: 1,
|
||||||
|
sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId),
|
||||||
})) as { id: string };
|
})) as { id: string };
|
||||||
createdRowId = created.id;
|
createdRowId = created.id;
|
||||||
setActiveSectionId(sectionId);
|
setActiveSectionId(sectionId);
|
||||||
return {
|
return {
|
||||||
rowKey: `device:${created.id}`,
|
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`,
|
||||||
cellKey: "displayName",
|
cellKey: "displayName",
|
||||||
rowType: "deviceRow",
|
rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow",
|
||||||
sectionId,
|
sectionId,
|
||||||
circuitId: circuit.id,
|
circuitId: circuit.id,
|
||||||
deviceId: created.id,
|
deviceId: created.id,
|
||||||
@@ -1688,17 +1698,68 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
if (createdRowId) {
|
if (createdRowId) {
|
||||||
await deleteCircuitDeviceRowById(createdRowId);
|
await deleteCircuitDeviceRowById(createdRowId);
|
||||||
}
|
}
|
||||||
|
const rowKey =
|
||||||
|
originalDeviceCount === 0
|
||||||
|
? `reserveCircuit:${circuit.id}`
|
||||||
|
: originalDeviceCount === 1
|
||||||
|
? `circuitCompact:${circuit.id}`
|
||||||
|
: afterDeviceRowId
|
||||||
|
? `device:${afterDeviceRowId}`
|
||||||
|
: `circuitSummary:${circuit.id}`;
|
||||||
return {
|
return {
|
||||||
rowKey: `reserveCircuit:${circuit.id}`,
|
rowKey,
|
||||||
cellKey: "displayName",
|
cellKey: "displayName",
|
||||||
rowType: "reserveCircuit",
|
rowType:
|
||||||
|
originalDeviceCount === 0
|
||||||
|
? "reserveCircuit"
|
||||||
|
: originalDeviceCount === 1
|
||||||
|
? "circuitCompact"
|
||||||
|
: afterDeviceRowId
|
||||||
|
? "deviceRow"
|
||||||
|
: "circuitSummary",
|
||||||
sectionId,
|
sectionId,
|
||||||
circuitId: circuit.id,
|
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 {
|
function resolvePhaseType(device: ProjectDeviceDto): string {
|
||||||
return device.phaseType;
|
return device.phaseType;
|
||||||
}
|
}
|
||||||
@@ -2472,10 +2533,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
|
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
|
||||||
if (isCtrlPlus) {
|
if (isCtrlPlus) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const sectionId = activeSectionId ?? data?.sections[0]?.id;
|
void handleInsertBelowSelection();
|
||||||
if (sectionId) {
|
|
||||||
void handleAddReserveCircuit(sectionId);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Tab" && selectedCell) {
|
if (event.key === "Tab" && selectedCell) {
|
||||||
@@ -3486,7 +3544,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user