Drag and drop working

This commit is contained in:
2026-05-04 23:27:13 +02:00
parent 897e506b74
commit 75435475fc
8 changed files with 448 additions and 16 deletions
+276 -14
View File
@@ -10,6 +10,7 @@ import {
getNextCircuitIdentifier,
listProjectDevices,
moveCircuitDeviceRowById,
reorderSectionCircuits,
renumberCircuitSection,
updateCircuitById,
updateCircuitDeviceRowById,
@@ -83,6 +84,11 @@ type DeviceRowMoveDropIntent =
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
| { kind: "move-to-new-circuit"; sectionId: string };
type CircuitReorderDropIntent =
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
| { kind: "section-end"; sectionId: string; valid: boolean };
const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
{ key: "equipmentIdentifier", label: "Equipment identifier" },
{ key: "displayName", label: "Display name" },
@@ -274,6 +280,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -947,6 +955,41 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return null;
}
function findCircuitSectionId(circuitId: string) {
for (const section of data?.sections ?? []) {
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
return section.id;
}
}
return null;
}
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: string) {
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const ids = section.circuits.map((circuit) => circuit.id);
const fromIndex = ids.indexOf(sourceCircuitId);
if (fromIndex < 0) {
throw new Error("Invalid source circuit.");
}
const nextIds = [...ids];
nextIds.splice(fromIndex, 1);
if (intent.kind === "section-end") {
nextIds.push(sourceCircuitId);
} else {
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
if (targetIndex < 0) {
throw new Error("Invalid target circuit.");
}
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
nextIds.splice(insertIndex, 0, sourceCircuitId);
}
await reorderSectionCircuits(section.id, nextIds);
}
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
event.preventDefault();
event.stopPropagation();
@@ -1017,6 +1060,39 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
event.preventDefault();
event.stopPropagation();
const sourceCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
setCircuitReorderIntent(null);
setDraggingCircuitId(null);
if (!sourceCircuitId) {
setError("Missing dragged circuit.");
return;
}
if (!intent.valid) {
setError("Cross-section circuit move is not allowed in this phase.");
return;
}
try {
setError(null);
setIsSaving(true);
await applyCircuitReorder(intent, sourceCircuitId);
pendingSelectionAfterReload.current = {
rowKey: `circuitSummary:${sourceCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: sourceCircuitId,
};
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleDeleteDevice(rowId: string) {
if (!confirm("Delete this device row?")) {
return;
@@ -1174,7 +1250,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setSelectedProjectDeviceId(device.id);
setDraggingProjectDeviceId(device.id);
setDraggingDeviceRowId(null);
setDraggingCircuitId(null);
setDeviceMoveIntent(null);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("application/x-project-device-id", device.id);
}}
@@ -1261,6 +1339,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
}`}
onDragOver={(event) => {
if (draggingCircuitId) {
event.preventDefault();
event.dataTransfer.dropEffect = "none";
setCircuitReorderIntent({
kind: "section-end",
sectionId: section.id,
valid: false,
});
return;
}
if (!draggingProjectDeviceId) {
return;
}
@@ -1272,8 +1360,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
setDropIntent(null);
}
if (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === section.id) {
setCircuitReorderIntent(null);
}
}}
onDrop={(event) => {
if (draggingProjectDeviceId) {
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
return;
}
if (draggingCircuitId) {
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: section.id,
valid: false,
});
}
}}
onDrop={(event) => void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id })}
>
<td colSpan={columns.length + 1} className="section-drop-cell">
<div className="section-content">
@@ -1311,12 +1414,81 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} ${
row.rowType === "placeholder" &&
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) ||
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId))
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) ||
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
? "drop-target-active"
: ""
} ${
row.rowType === "placeholder" &&
circuitReorderIntent?.kind === "section-end" &&
circuitReorderIntent.sectionId === row.sectionId &&
!circuitReorderIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit &&
circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "drop-target-active"
: ""
} ${
row.circuit &&
circuitReorderIntent?.kind === "before-circuit" &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "circuit-insert-before"
: ""
} ${
row.circuit &&
circuitReorderIntent?.kind === "after-circuit" &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "circuit-insert-after"
: ""
} ${
row.circuit &&
circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
!circuitReorderIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit?.id && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingCircuitId) {
if (row.rowType === "placeholder") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
const valid = sourceSectionId === row.sectionId;
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "move" : "none";
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
if (row.circuit.id === draggingCircuitId) {
setCircuitReorderIntent(null);
return;
}
const valid = sourceSectionId === row.sectionId;
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
const isAfter = event.clientY > rect.top + rect.height / 2;
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "move" : "none";
setCircuitReorderIntent({
kind: isAfter ? "after-circuit" : "before-circuit",
sectionId: row.sectionId,
targetCircuitId: row.circuit.id,
valid,
});
}
return;
}
if (draggingProjectDeviceId) {
if (row.rowType === "placeholder") {
event.preventDefault();
@@ -1373,8 +1545,49 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return current;
});
setCircuitReorderIntent((current) => {
if (!current) {
return null;
}
if (current.kind === "section-end" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
return null;
}
if (
(current.kind === "before-circuit" || current.kind === "after-circuit") &&
current.targetCircuitId === row.circuit?.id
) {
return null;
}
return current;
});
}}
onDrop={(event) => {
if (draggingCircuitId) {
if (row.rowType === "placeholder") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: row.sectionId,
valid: sourceSectionId === row.sectionId,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
if (row.circuit.id === draggingCircuitId) {
return;
}
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
const isAfter = event.clientY > rect.top + rect.height / 2;
void handleCircuitReorderDrop(event, {
kind: isAfter ? "after-circuit" : "before-circuit",
sectionId: row.sectionId,
targetCircuitId: row.circuit.id,
valid: sourceSectionId === row.sectionId,
});
}
return;
}
if (draggingProjectDeviceId) {
if (row.rowType === "placeholder") {
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId });
@@ -1417,33 +1630,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
: ""
} ${
column.key === "equipmentIdentifier" &&
Boolean(row.circuit) &&
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit")
? "circuit-drag-handle"
: ""
} ${
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
}`}
draggable={
!isEditing &&
Boolean(row.device) &&
column.key === "displayName" &&
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
((Boolean(row.device) &&
column.key === "displayName" &&
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")) ||
(Boolean(row.circuit) &&
column.key === "equipmentIdentifier" &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit")))
}
onDragStart={(event) => {
if (
!row.device ||
column.key !== "displayName" ||
(row.rowType !== "deviceRow" && row.rowType !== "circuitCompact")
row.circuit &&
column.key === "equipmentIdentifier" &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit")
) {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowId(null);
setDeviceMoveIntent(null);
setDraggingCircuitId(row.circuit.id);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
return;
}
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowId(row.device.id);
setDeviceMoveIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
if (
row.device &&
column.key === "displayName" &&
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
) {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingCircuitId(null);
setCircuitReorderIntent(null);
setDraggingDeviceRowId(row.device.id);
setDeviceMoveIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
}
}}
onDragEnd={() => {
setDraggingDeviceRowId(null);
setDeviceMoveIntent(null);
setDraggingCircuitId(null);
setCircuitReorderIntent(null);
}}
onClick={() => {
if (cell.editable) {
@@ -1539,6 +1783,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">move device to this circuit</span>
) : null}
{circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" &&
circuitReorderIntent.sectionId === row.sectionId ? (
<span className="drop-hint">
{circuitReorderIntent.valid ? "move circuit to section end" : "cross-section move not allowed"}
</span>
) : null}
{circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit?.id ? (
<span className="drop-hint">
{circuitReorderIntent.valid
? circuitReorderIntent.kind === "before-circuit"
? "move circuit before this circuit"
: "move circuit after this circuit"
: "cross-section move not allowed"}
</span>
) : null}
</td>
</tr>
);