From 2d97fafce940f8fbbfbf8c54bd42c9b20ddad8b1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 08:33:41 +0200 Subject: [PATCH] Restore circuit drag placement --- docs/circuit-list-editor-interactions.md | 6 + .../components/circuit-tree-editor.tsx | 151 +++++++++++++++--- src/frontend/utils/circuit-grid-insertion.ts | 23 +++ src/frontend/utils/circuit-grid-model.ts | 9 +- tests/circuit-grid-insertion.test.ts | 22 +++ tests/circuit-grid-model.test.ts | 8 + 6 files changed, 194 insertions(+), 25 deletions(-) diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 3867143..c8c02aa 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -83,6 +83,10 @@ Intent is separated by drag source type: - device row drag: - drop to existing circuit -> move row(s) into that circuit - drop to placeholder -> create new target circuit and move row(s) + - drop on the upper or lower edge of a circuit row -> create a new target + circuit directly before or after that circuit and move the row(s) + - the center of a circuit row remains the explicit target for adding the + row(s) to that existing circuit - crossing a section boundary requires explicit confirmation - phase type, category, linked project device and local row values remain unchanged - target creation, row assignments and reserve-state updates use one SQLite transaction @@ -107,6 +111,8 @@ Cross-section device moves show confirmation-required feedback before drop. The - New groups are created manually in one of the three supported categories and receive the highest existing category group number plus one. +- Group headings always show the stored editable display name; category labels + do not overwrite names of the default first groups. - Group reorder buttons move only relative to category peers and never change a group number or BMK. - `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action. diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index b2bed2b..f339c62 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -10,6 +10,7 @@ import { resolveProjectVoltage, } from "../../domain/services/project-voltage.service"; import { + getAdjacentInsertionSortOrder, getInsertionSortOrder, isGridInsertionRowType, resolveGridInsertionIntent, @@ -175,7 +176,13 @@ type ProjectDeviceDropIntent = type DeviceRowMoveDropIntent = | { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean } - | { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean }; + | { + kind: "move-to-new-circuit"; + sectionId: string; + requiresConfirmation: boolean; + targetCircuitId?: string; + placement?: "before" | "after"; + }; type CircuitReorderDropIntent = | { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } @@ -2329,6 +2336,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); } + function resolveDeviceRowCircuitDropIntent( + event: DragEvent, + circuitId: string, + sectionId: string, + requiresConfirmation: boolean + ): DeviceRowMoveDropIntent { + const rect = ( + event.currentTarget as HTMLTableRowElement + ).getBoundingClientRect(); + const relativeY = + rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + if (relativeY <= 0.25 || relativeY >= 0.75) { + return { + kind: "move-to-new-circuit", + sectionId, + requiresConfirmation, + targetCircuitId: circuitId, + placement: relativeY <= 0.25 ? "before" : "after", + }; + } + return { + kind: "move-to-circuit", + circuitId, + sectionId, + requiresConfirmation, + }; + } + // Applies same-section circuit reorder as explicit id ordering. // No implicit renumbering is performed here. function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { @@ -2512,13 +2547,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str redo: async () => { const next = await getNextCircuitIdentifier(intent.sectionId); const sortOrder = - targetSection.circuits.length > 0 - ? Math.max( - ...targetSection.circuits.map( - (circuit) => circuit.sortOrder - ) - ) + 10 - : 10; + intent.targetCircuitId && intent.placement + ? getAdjacentInsertionSortOrder( + targetSection.circuits, + intent.targetCircuitId, + intent.placement + ) + : getInsertionSortOrder(targetSection.circuits); const targetCircuit = createCircuitSnapshot({ sectionId: intent.sectionId, equipmentIdentifier: next.nextIdentifier, @@ -3841,6 +3876,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceMoveIntent.requiresConfirmation ? "drop-target-confirm" : "" + } ${ + row.circuit && + deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.targetCircuitId === row.circuit.id + ? deviceMoveIntent.requiresConfirmation + ? "drop-target-confirm" + : "drop-target-active" + : "" + } ${ + row.circuit && + deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.targetCircuitId === row.circuit.id && + deviceMoveIntent.placement === "before" + ? "circuit-insert-before" + : "" + } ${ + row.circuit && + deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.targetCircuitId === row.circuit.id && + deviceMoveIntent.placement === "after" + ? "circuit-insert-after" + : "" } ${ row.circuit && deviceMoveIntent?.kind === "move-to-circuit" && @@ -3985,15 +4042,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const sourceCircuitIds = activeDraggedDeviceRowIds .map((id) => findDeviceRowCircuitId(id)) .filter((id): id is string => Boolean(id)); - if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) { + const intent = + resolveDeviceRowCircuitDropIntent( + event, + row.circuit.id, + row.sectionId, + requiresConfirmation + ); + if ( + intent.kind === "move-to-new-circuit" || + sourceCircuitIds.some( + (sourceCircuitId) => + sourceCircuitId !== row.circuit!.id + ) + ) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; - setDeviceMoveIntent({ - kind: "move-to-circuit", - circuitId: row.circuit.id, - sectionId: row.sectionId, - requiresConfirmation, - }); + setDeviceMoveIntent(intent); + } else { + setDeviceMoveIntent(null); } } } @@ -4018,6 +4085,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (current.kind === "move-to-new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { return null; } + if ( + current.kind === "move-to-new-circuit" && + current.targetCircuitId === row.circuit?.id + ) { + return null; + } if (current.kind === "move-to-circuit" && current.circuitId === row.circuit?.id) { return null; } @@ -4096,12 +4169,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (row.circuit && row.rowType !== "deviceRow") { - void handleDeviceRowDropWithIntent(event, { - kind: "move-to-circuit", - circuitId: row.circuit.id, - sectionId: row.sectionId, - requiresConfirmation, - }); + void handleDeviceRowDropWithIntent( + event, + resolveDeviceRowCircuitDropIntent( + event, + row.circuit.id, + row.sectionId, + requiresConfirmation + ) + ); } } }} @@ -4140,9 +4216,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str row.device?.id && ((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) || (draggingDeviceRowIds.length === 0 && draggingDeviceRowId === row.device.id)) - ? "device-dragging" - : "" - }`} + ? "device-dragging" + : "" + }`} + title={ + column.key === "equipmentIdentifier" && + row.circuit && + (row.rowType === "circuitCompact" || + row.rowType === "circuitSummary" || + row.rowType === "reserveCircuit") + ? "Stromkreis verschieben: am BMK ziehen und zwischen Stromkreisen ablegen" + : column.key === "displayName" && + row.device + ? "Gerät verschieben: in der Zeilenmitte zuordnen oder am oberen/unteren Rand einen neuen Stromkreis bilden" + : undefined + } draggable={ !isEditing && ((Boolean(row.device) && @@ -4384,6 +4472,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {`${draggingDeviceCount || 1} Gerätezeile(n) in diesen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`} ) : null} + {deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.targetCircuitId === + row.circuit?.id ? ( + + {`${draggingDeviceCount || 1} Gerätezeile(n) in einen neuen Stromkreis ${ + deviceMoveIntent.placement === "before" + ? "davor" + : "danach" + } verschieben${ + deviceMoveIntent.requiresConfirmation + ? " (Bestätigung erforderlich)" + : "" + }`} + + ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( diff --git a/src/frontend/utils/circuit-grid-insertion.ts b/src/frontend/utils/circuit-grid-insertion.ts index b207193..cffbaaa 100644 --- a/src/frontend/utils/circuit-grid-insertion.ts +++ b/src/frontend/utils/circuit-grid-insertion.ts @@ -87,3 +87,26 @@ export function getInsertionSortOrder( const next = entries[index + 1]?.sortOrder; return next === undefined ? current + 10 : current + (next - current) / 2; } + +export function getAdjacentInsertionSortOrder( + orderedEntries: Array<{ id: string; sortOrder: number }>, + targetId: string, + placement: "before" | "after" +): number { + const entries = [...orderedEntries].sort( + (left, right) => + left.sortOrder - right.sortOrder || + left.id.localeCompare(right.id) + ); + const index = entries.findIndex((entry) => entry.id === targetId); + if (index < 0) { + throw new Error("Der Zielstromkreis wurde nicht gefunden."); + } + if (placement === "after") { + return getInsertionSortOrder(entries, targetId); + } + const previous = entries[index - 1]; + return previous + ? getInsertionSortOrder(entries, previous.id) + : entries[index].sortOrder - 10; +} diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index a70a6bd..49263b4 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -144,8 +144,15 @@ const circuitSectionLabels: Record = { }; export function getCircuitSectionLabel( - section: { key: string; displayName: string } + section: { + key: string; + displayName: string; + category?: string; + } ): string { + if (section.category) { + return section.displayName; + } return circuitSectionLabels[section.key] ?? section.displayName; } diff --git a/tests/circuit-grid-insertion.test.ts b/tests/circuit-grid-insertion.test.ts index c2260f8..2f58334 100644 --- a/tests/circuit-grid-insertion.test.ts +++ b/tests/circuit-grid-insertion.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + getAdjacentInsertionSortOrder, getInsertionSortOrder, isGridInsertionRowType, resolveGridInsertionIntent, @@ -89,5 +90,26 @@ describe("circuit grid insertion", () => { assert.equal(getInsertionSortOrder(entries, "c"), 40); assert.equal(getInsertionSortOrder(entries), 40); assert.equal(getInsertionSortOrder([], "missing"), 10); + assert.equal( + getAdjacentInsertionSortOrder(entries, "a", "before"), + 0 + ); + assert.equal( + getAdjacentInsertionSortOrder(entries, "b", "before"), + 15 + ); + assert.equal( + getAdjacentInsertionSortOrder(entries, "b", "after"), + 25 + ); + assert.throws( + () => + getAdjacentInsertionSortOrder( + entries, + "missing", + "before" + ), + /nicht gefunden/ + ); }); }); diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts index 915e6dc..5e045d9 100644 --- a/tests/circuit-grid-model.test.ts +++ b/tests/circuit-grid-model.test.ts @@ -101,6 +101,14 @@ describe("circuit grid model", () => { }); it("shows stable circuit sections with German labels", () => { + assert.equal( + getCircuitSectionLabel({ + key: "lighting", + displayName: "Lighting", + category: "lighting", + }), + "Lighting" + ); assert.equal( getCircuitSectionLabel({ key: "lighting",