Restore circuit drag placement
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user