Add cell copy/paste, multi-row edits and a context menu to the circuit grid

Ctrl+C/Ctrl+V copy and paste grid cells without opening an editor, using
the document copy/paste events so it also works over plain HTTP in the
LAN. Protection and cable summaries copy the complete circuit record;
tab-separated blocks from Excel spread over visible rows and columns.
With a Ctrl/Shift multi-selection, typing, pasting and the protection
dialog apply to every selected row that can take the value, including
device power and GZF; other cells are skipped and reported.

All of these writes use the new circuit-list.bulk-update command. It
carries complete circuit.update, circuit-device-row.update and
circuit-protection.update commands, applies them with the single-command
rules in one transaction and stores the reversed inverses, so each edit
is one undo step. The single-command apply logic is extracted for reuse.

A right-click menu (also context-menu key / Shift+F10) offers copy,
paste, edit, protection, cable sizing, insert circuit below, add device
and delete. The protection summary is now selected by click and opened
by double click, Enter or F2. Existing numbers open in the inline editor
with a decimal comma, so confirming an unchanged decimal no longer fails.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 07:38:28 +01:00
parent 07bc52a134
commit 17a3945813
18 changed files with 2548 additions and 391 deletions

View file

@ -7,8 +7,11 @@ import {
ArrowDown01,
ArrowLeft,
ArrowUp,
BetweenHorizontalStart,
Calculator,
Check,
ClipboardCopy,
ClipboardPaste,
Columns3,
FilterX,
FolderPlus,
@ -22,6 +25,7 @@ import {
Pencil,
Plus,
Redo2,
Shield,
ShieldPlus,
TableProperties,
Trash2,
@ -148,9 +152,22 @@ import {
updateCircuitDeviceRowById,
updateCircuitProtectionCommand,
updateCircuitGroupCommand,
updateCircuitListCellsCommand,
updateDistributionBoardComponentCommand,
undoProjectCommand,
} from "../utils/api";
import {
buildCellClipboard,
formatCellEditText,
gridDataRowTypes,
isCircuitRecordCell,
isCircuitRow,
planCellWrites,
resolveClipboardCells,
resolvePasteWrites,
type CellClipboard,
type CellWrite,
} from "../utils/circuit-grid-clipboard";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
@ -173,6 +190,7 @@ import { DistributionBoardComponentModal } from "./distribution-board-component-
import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu";
import { CableSizingModal } from "./cable-sizing-modal";
import {
circuitGroupCategoryLabels,
@ -321,6 +339,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// editingCell is mounted input session state (draft + mode + focus token).
// It should only exist for currently editable grid cells.
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [contextMenu, setContextMenu] = useState<
{ x: number; y: number; rowKey: string; cellKey: CellKey } | null
>(null);
// Last cells copied in this editor. Pasting the same text again reuses the
// structured values (complete protection/cable records).
const cellClipboardRef = useRef<CellClipboard | null>(null);
const [hasCellClipboard, setHasCellClipboard] = useState(false);
const [gridNotice, setGridNotice] = useState<string | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Synchronous re-entry guard: isSaving is React state and only reflects
@ -904,7 +930,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() =>
visibleRows.flatMap((row) =>
row.cells
.filter((cell) => cell.editable)
.filter((cell) => cell.editable || isCircuitRecordCell(row, cell.cellKey))
.map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey }))
),
[visibleRows]
@ -917,7 +943,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const map = new Map<string, CellKey[]>();
for (const row of visibleRows) {
const keys = row.cells
.filter((cell) => cell.editable && visibleKeySet.has(cell.cellKey))
.filter(
(cell) =>
(cell.editable || isCircuitRecordCell(row, cell.cellKey)) &&
visibleKeySet.has(cell.cellKey)
)
.map((cell) => cell.cellKey as CellKey);
if (keys.length) {
map.set(row.rowKey, keys);
@ -1520,6 +1550,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
// Circuit rows of a multi-selection that contains the edited circuit.
function getProtectionMultiSelectionRows(circuitId: string) {
const circuitRowKey = visibleRows.find(
(row) => row.circuit?.id === circuitId && isCircuitRow(row)
)?.rowKey;
const rows = circuitRowKey ? getMultiSelectionRows(circuitRowKey) : null;
const circuitRows = rows?.filter((row) => isCircuitRow(row)) ?? [];
return circuitRows.length > 1 ? circuitRows : null;
}
async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto
) {
@ -1527,6 +1567,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!circuit) {
return;
}
const multiSelectionRows = getProtectionMultiSelectionRows(circuit.id);
if (multiSelectionRows) {
const { circuitId: _circuitId, ...protectionValue } =
toCircuitProtectionSnapshot(circuit.id, protection);
setProtectionEditorCircuit(null);
await applyCellWrites(
multiSelectionRows.map((row) => ({
row,
cellKey: "protectionSummary",
value: { kind: "protection", text: "", protection: protectionValue },
})),
"Stromkreisschutz für mehrere Stromkreise bearbeiten"
);
return;
}
await runCommand({
label: "Stromkreisschutz bearbeiten",
redo: async () => {
@ -1858,15 +1913,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Opens edit session for selected cell.
// Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar.
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
const row = findRow(cell.rowKey);
const visibleCell = findCell(cell.rowKey, cell.cellKey);
// The protection summary is edited in its dialog (Enter/F2/double click).
if (
row?.circuit &&
cell.cellKey === "protectionSummary" &&
isCircuitRecordCell(row, cell.cellKey) &&
mode !== "replaceWithTypedChar"
) {
setProtectionEditorCircuit(row.circuit);
return;
}
if (!visibleCell || !visibleCell.editable) {
return;
}
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
// Existing numbers use the decimal comma the edit parser expects.
const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? ""
: String(visibleCell.editValue ?? visibleCell.value ?? "");
: formatCellEditText(visibleCell.editValue ?? visibleCell.value);
focusTokenRef.current += 1;
setEditingCell({
...cell,
@ -2048,6 +2115,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return buildSelectionIntent(selected);
};
// With several selected rows the value goes to the same column of every
// selected row that can take it, as one command and one undo step.
const multiSelectionRows = getMultiSelectionRows(row.rowKey);
if (multiSelectionRows) {
setEditingCell(null);
await applyCellWrites(
multiSelectionRows.map((targetRow) => ({
row: targetRow,
cellKey: session.cellKey,
value: { kind: "text", text: draft },
})),
"Mehrere Zeilen bearbeiten",
{ allowEquipmentIdentifier: false },
nextSelectionIntent
);
return;
}
// Placeholder rows are not persisted entities; editing them materializes a real circuit first.
if (row.rowType === "placeholder") {
const command: HistoryCommand = {
@ -2179,6 +2264,373 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
focusGridWithoutScroll();
}
// Selected data rows in grid order while a multi-selection contains the
// given row; null for a single-row selection.
function getMultiSelectionRows(rowKey: string): VisibleGridRow[] | null {
if (selectedRowKeys.length < 2 || !selectedRowKeys.includes(rowKey)) {
return null;
}
const selected = new Set(selectedRowKeys);
return visibleRows.filter(
(row) => selected.has(row.rowKey) && gridDataRowTypes.has(row.rowType)
);
}
// Plans cell writes and stores them as one circuit-list.bulk-update.
async function applyCellWrites(
writes: CellWrite[],
label: string,
options: { allowEquipmentIdentifier?: boolean } = {},
selectionIntent: () => SelectionIntent | null = () => null,
extraSkippedCellCount = 0
) {
let plan: ReturnType<typeof planCellWrites>;
try {
plan = planCellWrites(writes, options);
} catch (err) {
setError(normalizeUiError(err));
return;
}
const skipped = plan.skippedCellCount + extraSkippedCellCount;
const skippedText =
skipped > 0
? `${skipped} Zelle(n) übersprungen (nicht bearbeitbar oder nicht passend).`
: "";
if (plan.updates.length === 0) {
setGridNotice(
skippedText || "Keine Änderung – die Werte sind bereits gesetzt."
);
return;
}
setGridNotice(
`${plan.changedCellCount} Zelle(n) geändert.${skippedText ? ` ${skippedText}` : ""}`
);
await runCommand({
label,
redo: async () => {
const result = await updateCircuitListCellsCommand(
projectId,
getExpectedProjectRevision(),
plan.updates,
label
);
applyProjectCommandResult(result);
return selectionIntent();
},
});
}
// Cells to copy: the selected cell, or its column for every selected row.
function buildSelectionClipboard(): CellClipboard | null {
if (!selectedCell) {
return null;
}
const row = findRow(selectedCell.rowKey);
if (!row) {
return null;
}
return buildCellClipboard(
getMultiSelectionRows(row.rowKey) ?? [row],
[selectedCell.cellKey]
);
}
function copySelectionToClipboard(clipboardData: DataTransfer | null) {
const clipboard = buildSelectionClipboard();
if (!clipboard) {
return false;
}
cellClipboardRef.current = clipboard;
setHasCellClipboard(true);
clipboardData?.setData("text/plain", clipboard.text);
const count = clipboard.cells.length;
setGridNotice(
count > 1 ? `${count} Zellen kopiert.` : "Zelle kopiert."
);
return true;
}
async function pasteClipboardText(text: string) {
if (!selectedCell || isSaving || historyBusy) {
return;
}
const cells = resolveClipboardCells(text, cellClipboardRef.current);
if (cells.length === 0) {
return;
}
const { writes, overflowCellCount } = resolvePasteWrites({
cells,
anchor: selectedCell,
selectedRowKeys,
rows: visibleRows,
columnKeys: visibleColumns.map((column) => column.key),
});
await applyCellWrites(
writes,
"Zellen einfügen",
{},
() => buildSelectionIntent(selectedCell),
overflowCellCount
);
}
// Menu paste has no clipboard event (browsers block reading it there), so
// it reuses the cells last copied in this editor.
function pasteFromCellClipboard() {
const clipboard = cellClipboardRef.current;
if (clipboard) {
void pasteClipboardText(clipboard.text);
}
}
// execCommand("copy") also works over plain HTTP and runs the grid's copy
// listener, which writes the system clipboard.
function copyFromMenu() {
focusGridWithoutScroll();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
if (!copied) {
copySelectionToClipboard(null);
}
}
function openContextMenu(row: VisibleGridRow, cellKey: CellKey, x: number, y: number) {
if (editingCell) {
setEditingCell(null);
}
if (selectedRowKeys.length > 1 && selectedRowKeys.includes(row.rowKey)) {
setSelectedCell({ rowKey: row.rowKey, cellKey });
} else {
handleRowSelectionClick(row, cellKey);
}
setContextMenu({ x, y, rowKey: row.rowKey, cellKey });
}
// Keyboard access (context-menu key or Shift+F10) opens the menu at the cell.
function openContextMenuForSelectedCell() {
if (!selectedCell) {
return;
}
const row = findRow(selectedCell.rowKey);
const element = containerRef.current?.querySelector(
`td[data-row-key="${CSS.escape(selectedCell.rowKey)}"][data-cell-key="${CSS.escape(selectedCell.cellKey)}"]`
);
if (!row || !element) {
return;
}
const rect = element.getBoundingClientRect();
openContextMenu(row, selectedCell.cellKey, rect.left + 8, rect.bottom);
}
function closeContextMenu(restoreFocus: boolean) {
setContextMenu(null);
if (restoreFocus) {
focusGridWithoutScroll();
}
}
// Multi-selection hint and the result of the last copy/paste/bulk edit.
function renderGridStatus() {
const selectionRows = selectedCell
? getMultiSelectionRows(selectedCell.rowKey)
: null;
if (!selectionRows && !gridNotice) {
return null;
}
return (
<div className="grid-status-line" role="status">
{selectionRows ? (
<span className="grid-status-selection">
{selectionRows.length} Zeilen ausgewählt – Eingaben, Einfügen und
Schutzgerät gelten für alle passenden Zeilen.
</span>
) : null}
{gridNotice ? <span>{gridNotice}</span> : null}
{gridNotice ? (
<button type="button" onClick={() => setGridNotice(null)}>
Ausblenden
</button>
) : null}
</div>
);
}
function buildContextMenuEntries(): GridContextMenuEntry[] {
const row = contextMenu ? findRow(contextMenu.rowKey) : null;
if (!contextMenu || !row) {
return [];
}
const busy = isSaving || historyBusy;
const { circuit, device } = row;
const cell = row.cells.find((entry) => entry.cellKey === contextMenu.cellKey);
const multiSelectionRows = getMultiSelectionRows(row.rowKey);
const protectionRows = circuit
? getProtectionMultiSelectionRows(circuit.id)
: null;
const feedsDownstreamBoard = (circuit?.downstreamBoardLoads?.length ?? 0) > 0;
const entries: GridContextMenuEntry[] = [
{
kind: "action",
label: multiSelectionRows
? `${multiSelectionRows.length} Zellen kopieren`
: "Kopieren",
icon: ClipboardCopy,
shortcut: "Strg+C",
onSelect: copyFromMenu,
},
{
kind: "action",
label: multiSelectionRows
? `In ${multiSelectionRows.length} Zeilen einfügen`
: "Einfügen",
icon: ClipboardPaste,
shortcut: "Strg+V",
disabled: !hasCellClipboard || busy,
title: hasCellClipboard
? undefined
: "Zuerst Zellen dieser Stromkreisliste kopieren. Werte aus Excel mit Strg+V einfügen.",
onSelect: pasteFromCellClipboard,
},
{ kind: "separator" },
];
if (cell?.editable) {
entries.push({
kind: "action",
label: multiSelectionRows
? `Bearbeiten (${multiSelectionRows.length} Zeilen)`
: "Bearbeiten",
icon: Pencil,
shortcut: "Enter",
disabled: busy,
onSelect: () =>
startEdit({ rowKey: row.rowKey, cellKey: contextMenu.cellKey }, "selectExisting"),
});
}
if (circuit && isCircuitRow(row)) {
entries.push(
{
kind: "action",
label: protectionRows
? `Schutzgerät für ${protectionRows.length} Stromkreise bearbeiten`
: "Schutzgerät bearbeiten",
icon: Shield,
disabled: busy,
onSelect: () => setProtectionEditorCircuit(circuit),
},
{
kind: "action",
label: "Leitungsberechnung",
icon: Calculator,
disabled: busy,
onSelect: () => setCableSizingEditorCircuit(circuit),
}
);
}
if (circuit) {
entries.push(
{ kind: "separator" },
{
kind: "action",
label: "Stromkreis unterhalb einfügen",
icon: BetweenHorizontalStart,
disabled: busy,
onSelect: () => void handleAddReserveCircuit(row.sectionId, circuit.id),
},
{
kind: "action",
label: "Manuelles Gerät hinzufügen",
icon: ListPlus,
disabled: busy,
onSelect: () =>
void handleAddManualDevice(circuit, row.sectionId, device?.id),
}
);
}
if (device || (circuit && isCircuitRow(row))) {
entries.push({ kind: "separator" });
}
if (device) {
entries.push({
kind: "action",
label: `Gerät „${device.displayName || device.name}“ löschen`,
icon: ListX,
variant: "danger",
disabled: busy,
onSelect: () => void handleDeleteDevice(device.id),
});
}
if (circuit && isCircuitRow(row)) {
entries.push({
kind: "action",
label: `Stromkreis ${circuit.equipmentIdentifier} löschen`,
icon: Trash2,
variant: "danger",
disabled: busy || feedsDownstreamBoard,
title: feedsDownstreamBoard
? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern"
: undefined,
onSelect: () => void handleDeleteCircuit(circuit.id),
});
}
return entries;
}
// Latest handlers for the document-level clipboard listeners below.
const gridClipboardHandlersRef = useRef<{
copy: (event: ClipboardEvent) => void;
paste: (event: ClipboardEvent) => void;
} | null>(null);
gridClipboardHandlersRef.current = {
copy: (event) => {
if (!isGridClipboardTarget()) {
return;
}
if (copySelectionToClipboard(event.clipboardData)) {
event.preventDefault();
}
},
paste: (event) => {
if (!isGridClipboardTarget()) {
return;
}
event.preventDefault();
void pasteClipboardText(event.clipboardData?.getData("text/plain") ?? "");
},
};
// Copy/paste act on grid cells only while the grid itself has focus, no
// inline editor or dialog is open and no page text is selected.
function isGridClipboardTarget() {
const active = document.activeElement;
return Boolean(
selectedCell &&
!editingCell &&
!gzfPickerCell &&
!protectionEditorCircuit &&
active &&
containerRef.current?.contains(active) &&
!isGridEditorControlTarget(active) &&
!window.getSelection()?.toString()
);
}
useEffect(() => {
const handleCopy = (event: ClipboardEvent) =>
gridClipboardHandlersRef.current?.copy(event);
const handlePaste = (event: ClipboardEvent) =>
gridClipboardHandlersRef.current?.paste(event);
document.addEventListener("copy", handleCopy);
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("copy", handleCopy);
document.removeEventListener("paste", handlePaste);
};
}, []);
async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) {
const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) {
@ -3191,6 +3643,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (eventTarget.closest("button, input, select, textarea")) {
return;
}
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
openContextMenuForSelectedCell();
return;
}
if (event.key === "Escape") {
if (selectedRowKeys.length > 0) {
event.preventDefault();
@ -3579,6 +4036,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div>
) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
{renderGridStatus()}
{contextMenu ? (
<GridContextMenu
ariaLabel="Zellenaktionen"
entries={buildContextMenuEntries()}
onClose={closeContextMenu}
x={contextMenu.x}
y={contextMenu.y}
/>
) : null}
<div className="tree-editor-layout">
{isRevitDrawerOpen ? (
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
@ -3772,6 +4239,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={0}
onFocus={handleContainerFocus}
onKeyDown={handleContainerKeyDown}
onContextMenu={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
openContextMenuForSelectedCell();
}
}}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setSelectedRowKeys([]);
@ -4665,14 +5138,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.circuit &&
(duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict)
);
const isProtectionTrigger = Boolean(
const isProtectionTrigger =
column.key === "protectionSummary" &&
row.circuit &&
row.rowType !== "deviceRow"
);
isCircuitRecordCell(row, column.key);
const isSelectableCell =
cell.editable || isCircuitRecordCell(row, column.key);
return (
<td
key={column.key}
data-cell-key={column.key}
data-row-key={row.rowKey}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
@ -4694,7 +5169,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
cell.lockedReason
? cell.lockedReason
: isProtectionTrigger
? "Schutzgerät bearbeiten"
? "Doppelklick oder Enter: Schutzgerät bearbeiten"
: column.key === "equipmentIdentifier" &&
row.circuit &&
(row.rowType === "circuitCompact" ||
@ -4770,11 +5245,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isGridEditorControlTarget(event.target)) {
return;
}
if (isProtectionTrigger) {
setProtectionEditorCircuit(row.circuit!);
return;
}
if (cell.editable) {
if (isSelectableCell) {
handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
@ -4786,10 +5257,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isGridEditorControlTarget(event.target)) {
return;
}
if (cell.editable) {
if (isSelectableCell) {
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
}
}}
onContextMenu={(event) => {
if (isGridEditorControlTarget(event.target) || !isSelectableCell) {
return;
}
event.preventDefault();
openContextMenu(row, column.key, event.clientX, event.clientY);
}}
>
{isEditing && column.key === "phaseType" ? (
<select
@ -5054,7 +5532,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div>
</div>
<p className="todo-hint">
Strg+Plus fügt unterhalb des ausgewählten Stromkreises oder Geräts ein. Mit „Rückgängig“ lässt sich das Einfügen zurücknehmen.
Strg+Plus fügt unterhalb des ausgewählten Stromkreises oder Geräts ein. Rechtsklick öffnet die Zellenaktionen, Strg+C/Strg+V kopiert und fügt Zellen ein; mit Strg/Umschalt ausgewählte Zeilen werden gemeinsam bearbeitet.
</p>
</div>
);