Restore circuit drag placement

This commit is contained in:
2026-07-31 08:33:41 +02:00
parent 7c8d4a7ddf
commit 2d97fafce9
6 changed files with 194 additions and 25 deletions
+6
View File
@@ -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.
+127 -24
View File
@@ -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<HTMLElement>,
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)" : ""}`}
</span>
) : null}
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.targetCircuitId ===
row.circuit?.id ? (
<span className="drop-hint">
{`${draggingDeviceCount || 1} Gerätezeile(n) in einen neuen Stromkreis ${
deviceMoveIntent.placement === "before"
? "davor"
: "danach"
} verschieben${
deviceMoveIntent.requiresConfirmation
? " (Bestätigung erforderlich)"
: ""
}`}
</span>
) : null}
{circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" &&
circuitReorderIntent.sectionId === row.sectionId ? (
@@ -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;
}
+8 -1
View File
@@ -144,8 +144,15 @@ const circuitSectionLabels: Record<string, string> = {
};
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;
}
+22
View File
@@ -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/
);
});
});
+8
View File
@@ -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",