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: - device row drag:
- drop to existing circuit -> move row(s) into that circuit - drop to existing circuit -> move row(s) into that circuit
- drop to placeholder -> create new target circuit and move row(s) - 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 - crossing a section boundary requires explicit confirmation
- phase type, category, linked project device and local row values remain unchanged - phase type, category, linked project device and local row values remain unchanged
- target creation, row assignments and reserve-state updates use one SQLite transaction - 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 - New groups are created manually in one of the three supported categories and
receive the highest existing category group number plus one. 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 reorder buttons move only relative to category peers and never change a
group number or BMK. group number or BMK.
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action. - `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
+127 -24
View File
@@ -10,6 +10,7 @@ import {
resolveProjectVoltage, resolveProjectVoltage,
} from "../../domain/services/project-voltage.service"; } from "../../domain/services/project-voltage.service";
import { import {
getAdjacentInsertionSortOrder,
getInsertionSortOrder, getInsertionSortOrder,
isGridInsertionRowType, isGridInsertionRowType,
resolveGridInsertionIntent, resolveGridInsertionIntent,
@@ -175,7 +176,13 @@ type ProjectDeviceDropIntent =
type DeviceRowMoveDropIntent = type DeviceRowMoveDropIntent =
| { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean } | { 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 = type CircuitReorderDropIntent =
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { 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. // Applies same-section circuit reorder as explicit id ordering.
// No implicit renumbering is performed here. // No implicit renumbering is performed here.
function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
@@ -2512,13 +2547,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(intent.sectionId); const next = await getNextCircuitIdentifier(intent.sectionId);
const sortOrder = const sortOrder =
targetSection.circuits.length > 0 intent.targetCircuitId && intent.placement
? Math.max( ? getAdjacentInsertionSortOrder(
...targetSection.circuits.map( targetSection.circuits,
(circuit) => circuit.sortOrder intent.targetCircuitId,
) intent.placement
) + 10 )
: 10; : getInsertionSortOrder(targetSection.circuits);
const targetCircuit = createCircuitSnapshot({ const targetCircuit = createCircuitSnapshot({
sectionId: intent.sectionId, sectionId: intent.sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
@@ -3841,6 +3876,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceMoveIntent.requiresConfirmation deviceMoveIntent.requiresConfirmation
? "drop-target-confirm" ? "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 && row.circuit &&
deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent?.kind === "move-to-circuit" &&
@@ -3985,15 +4042,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const sourceCircuitIds = activeDraggedDeviceRowIds const sourceCircuitIds = activeDraggedDeviceRowIds
.map((id) => findDeviceRowCircuitId(id)) .map((id) => findDeviceRowCircuitId(id))
.filter((id): id is string => Boolean(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.preventDefault();
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move";
setDeviceMoveIntent({ setDeviceMoveIntent(intent);
kind: "move-to-circuit", } else {
circuitId: row.circuit.id, setDeviceMoveIntent(null);
sectionId: row.sectionId,
requiresConfirmation,
});
} }
} }
} }
@@ -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) { if (current.kind === "move-to-new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
return null; 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) { if (current.kind === "move-to-circuit" && current.circuitId === row.circuit?.id) {
return null; return null;
} }
@@ -4096,12 +4169,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
if (row.circuit && row.rowType !== "deviceRow") { if (row.circuit && row.rowType !== "deviceRow") {
void handleDeviceRowDropWithIntent(event, { void handleDeviceRowDropWithIntent(
kind: "move-to-circuit", event,
circuitId: row.circuit.id, resolveDeviceRowCircuitDropIntent(
sectionId: row.sectionId, event,
requiresConfirmation, row.circuit.id,
}); row.sectionId,
requiresConfirmation
)
);
} }
} }
}} }}
@@ -4140,9 +4216,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.device?.id && row.device?.id &&
((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) || ((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) ||
(draggingDeviceRowIds.length === 0 && draggingDeviceRowId === 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={ draggable={
!isEditing && !isEditing &&
((Boolean(row.device) && ((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)" : ""}`} {`${draggingDeviceCount || 1} Gerätezeile(n) in diesen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`}
</span> </span>
) : null} ) : 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" && {circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" && row.rowType === "placeholder" &&
circuitReorderIntent.sectionId === row.sectionId ? ( circuitReorderIntent.sectionId === row.sectionId ? (
@@ -87,3 +87,26 @@ export function getInsertionSortOrder(
const next = entries[index + 1]?.sortOrder; const next = entries[index + 1]?.sortOrder;
return next === undefined ? current + 10 : current + (next - current) / 2; 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( export function getCircuitSectionLabel(
section: { key: string; displayName: string } section: {
key: string;
displayName: string;
category?: string;
}
): string { ): string {
if (section.category) {
return section.displayName;
}
return circuitSectionLabels[section.key] ?? section.displayName; return circuitSectionLabels[section.key] ?? section.displayName;
} }
+22
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
getAdjacentInsertionSortOrder,
getInsertionSortOrder, getInsertionSortOrder,
isGridInsertionRowType, isGridInsertionRowType,
resolveGridInsertionIntent, resolveGridInsertionIntent,
@@ -89,5 +90,26 @@ describe("circuit grid insertion", () => {
assert.equal(getInsertionSortOrder(entries, "c"), 40); assert.equal(getInsertionSortOrder(entries, "c"), 40);
assert.equal(getInsertionSortOrder(entries), 40); assert.equal(getInsertionSortOrder(entries), 40);
assert.equal(getInsertionSortOrder([], "missing"), 10); 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", () => { it("shows stable circuit sections with German labels", () => {
assert.equal(
getCircuitSectionLabel({
key: "lighting",
displayName: "Lighting",
category: "lighting",
}),
"Lighting"
);
assert.equal( assert.equal(
getCircuitSectionLabel({ getCircuitSectionLabel({
key: "lighting", key: "lighting",