God-Files aufgeteilt (ResourceManager/generatePlan/toWalls3d/App.tsx-Handler), Totcode entfernt (geometry-Crate, OCCT/HLR-Spike), PDF/Bild-Import ergänzt.
ResourceManager.tsx, generatePlan.ts und toWalls3d.ts sind nach Bauteil- Domäne in src/ui/resourceManager/, src/plan/generatePlan/ und src/plan/toWalls3d/ aufgeteilt (reine Verschiebung, keine Verhaltens- änderung). App.tsx verliert weitere Handler-Gruppen an eigene Hooks (useExportHandlers, useImportHandling, useKeyboardShortcuts) sowie Hit-Testing an src/viewport/planHitTest.ts. Unbenutzte Rust-geometry-Crate (durch WASM-Joins abgelöst) sowie der alte OCCT/HLR-Schnitt-Spike (hlr.ts/occt.ts, vite occt-Aliases) entfernt. Neu: PDF-Import als Rasterbild (src/io/pdfImport.ts) + gemeinsamer Bild-Asset-Helfer (src/io/imageAsset.ts) für Plan- und Layout-Bilder. tsc -b sauber, vitest 891/891 grün.
This commit is contained in:
@@ -25,6 +25,7 @@ import type {
|
||||
} from "../model/types";
|
||||
import { commitTransform } from "../tools/transform";
|
||||
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
||||
import { uniqueId } from "../tools/types";
|
||||
import { centroid as roomCentroid } from "../geometry/roomArea";
|
||||
import { t } from "../i18n";
|
||||
import type { StoreApi } from "./store";
|
||||
@@ -1417,6 +1418,18 @@ function resizeGeom(
|
||||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||||
return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } };
|
||||
}
|
||||
case "image": {
|
||||
const box = bbox2([g.min, g.max]);
|
||||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||||
const a = f(g.min);
|
||||
const b = f(g.max);
|
||||
return {
|
||||
...g,
|
||||
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
|
||||
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
|
||||
};
|
||||
}
|
||||
default:
|
||||
return g;
|
||||
}
|
||||
@@ -1432,7 +1445,7 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
||||
const g = d.geom;
|
||||
if (g.shape === "line") return [g.a, g.b];
|
||||
if (g.shape === "polyline") return [...g.pts];
|
||||
if (g.shape === "rect") {
|
||||
if (g.shape === "rect" || g.shape === "image") {
|
||||
return [
|
||||
g.min,
|
||||
{ x: g.max.x, y: g.min.y },
|
||||
@@ -1445,6 +1458,38 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt ein Bild-Zeichenelement (eingebettete Data-URL, z. B. Foto/Logo oder
|
||||
* gerasterte PDF-Seite) achsparallel bei `[at .. at+size]` an — analog zu den
|
||||
* Zeichenwerkzeugen, aber ohne Command (Datei-Auswahl ist async, s. App.tsx
|
||||
* Bild-/PDF-Import). Direkt selektiert zurückgegeben, damit Griffe/Objekt-Info
|
||||
* sofort zum Verschieben/Skalieren bereitstehen.
|
||||
*/
|
||||
export function addImageDrawing2d(
|
||||
project: Project,
|
||||
levelId: string,
|
||||
categoryCode: string,
|
||||
src: string,
|
||||
at: Vec2,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
): { project: Project; id: string } {
|
||||
const id = uniqueId("dr2d");
|
||||
const d: Drawing2D = {
|
||||
id,
|
||||
type: "drawing2d",
|
||||
levelId,
|
||||
categoryCode,
|
||||
geom: {
|
||||
shape: "image",
|
||||
min: at,
|
||||
max: { x: at.x + widthM, y: at.y + heightM },
|
||||
src,
|
||||
},
|
||||
};
|
||||
return { project: { ...project, drawings2d: [...project.drawings2d, d] }, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand)
|
||||
* auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten.
|
||||
@@ -1469,7 +1514,7 @@ function moveGrip(
|
||||
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||||
return { ...d, geom: { ...g, pts } };
|
||||
}
|
||||
if (g.shape === "rect") {
|
||||
if (g.shape === "rect" || g.shape === "image") {
|
||||
const corners = [
|
||||
g.min,
|
||||
{ x: g.max.x, y: g.min.y },
|
||||
@@ -1518,6 +1563,7 @@ function moveElementBy(
|
||||
case "polyline":
|
||||
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||
case "rect":
|
||||
case "image":
|
||||
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||
case "circle":
|
||||
case "arc":
|
||||
@@ -1573,7 +1619,7 @@ function moveEdge(
|
||||
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
|
||||
return { ...d, geom: { ...g, pts } };
|
||||
}
|
||||
if (g.shape === "rect") {
|
||||
if (g.shape === "rect" || g.shape === "image") {
|
||||
// Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten-
|
||||
// Ecken verschieben, dann bbox neu bilden (achsparallel halten).
|
||||
const corners = [
|
||||
|
||||
@@ -353,8 +353,15 @@ export interface Selection {
|
||||
| "extrudedSolid"
|
||||
| "column"
|
||||
| "roof"
|
||||
| "contextObject";
|
||||
| "contextObject"
|
||||
| "layoutAnnotation";
|
||||
id: string;
|
||||
/**
|
||||
* Konkrete Art der Layout-Annotation (nur `kind === "layoutAnnotation"`) —
|
||||
* steuert z. B., ob die Strichstärke editierbar ist (Linie/Rechteck ja,
|
||||
* Text/Bild nein).
|
||||
*/
|
||||
annotKind?: "line" | "rect" | "text" | "image";
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
/**
|
||||
@@ -408,6 +415,13 @@ export interface Selection {
|
||||
hatchSource?: AttributeSource;
|
||||
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
|
||||
closed?: boolean;
|
||||
/**
|
||||
* Ob das Element ein Bild ist (Drawing2D `{shape:"image"}` ODER Layout-
|
||||
* Annotation `kind:"image"`) — steuert die Deckkraft-Zeile im Attribute-Panel.
|
||||
*/
|
||||
isImage?: boolean;
|
||||
/** Deckkraft (0..1) — nur wenn `isImage`. */
|
||||
opacity?: number;
|
||||
/** Achsparallele Bounding-Box in Modell-Metern. */
|
||||
bbox: SelectionBBox;
|
||||
/**
|
||||
@@ -487,6 +501,7 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox {
|
||||
for (const p of g.pts) acc(p.x, p.y);
|
||||
break;
|
||||
case "rect":
|
||||
case "image":
|
||||
acc(g.min.x, g.min.y);
|
||||
acc(g.max.x, g.max.y);
|
||||
break;
|
||||
@@ -1009,6 +1024,8 @@ function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
strokeWeightSource: d.strokeWeightSource,
|
||||
hatchSource: closed ? d.hatchSource : undefined,
|
||||
closed,
|
||||
isImage: d.geom.shape === "image",
|
||||
opacity: d.geom.shape === "image" ? d.geom.opacity ?? 1 : undefined,
|
||||
bbox: geomBBox(d.geom),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Bündelt die PDF-/DXF-Plan-Export-Handler (Papier-/Modell-Space-Export der
|
||||
// aktuellen Grundriss-Ansicht) — die Schnellexporte (CSV/IFC/OBJ/STL) liegen
|
||||
// bereits in useProjectFileActions(). Nur aus App.tsx herausgezogen, um die
|
||||
// Funktion zu entlasten — reine Verlagerung, keine Verhaltensänderung.
|
||||
//
|
||||
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { generatePlan } from "../plan/generatePlan";
|
||||
import type { CategoryDisplayResolver } from "../plan/generatePlan";
|
||||
import type { DetailLevel } from "../ui/TopBar";
|
||||
import type { DisplayResolver } from "../viewport/Viewport3D";
|
||||
import { savePlanPdf } from "../export/exportPdf";
|
||||
import { savePlanDxf } from "../export/exportDxf";
|
||||
import type { ExportPdfDecision } from "../ui/ExportPdfDialog";
|
||||
import type { ExportDxfDecision } from "../ui/ExportDxfDialog";
|
||||
import { projectToWorldCoords } from "../model/geoRebase";
|
||||
import type { Project } from "../model/types";
|
||||
|
||||
export function useExportHandlers(opts: {
|
||||
project: Project;
|
||||
activeLevelId: string;
|
||||
activeLevelName: string;
|
||||
visibleCodes: Set<string>;
|
||||
categoryDisplay: CategoryDisplayResolver & DisplayResolver;
|
||||
detail: DetailLevel;
|
||||
referenceLines: boolean;
|
||||
planColorMode: "color" | "mono";
|
||||
setExportOpen: (open: boolean) => void;
|
||||
setExportDxfOpen: (open: boolean) => void;
|
||||
}): {
|
||||
onExportPdf: (decision: ExportPdfDecision) => Promise<void>;
|
||||
onExportDxf: (decision: ExportDxfDecision) => void;
|
||||
} {
|
||||
const {
|
||||
project,
|
||||
activeLevelId,
|
||||
activeLevelName,
|
||||
visibleCodes,
|
||||
categoryDisplay,
|
||||
detail,
|
||||
referenceLines,
|
||||
planColorMode,
|
||||
setExportOpen,
|
||||
setExportDxfOpen,
|
||||
} = opts;
|
||||
|
||||
const onExportPdf = async (decision: ExportPdfDecision) => {
|
||||
const plan = generatePlan(
|
||||
project,
|
||||
activeLevelId,
|
||||
visibleCodes,
|
||||
categoryDisplay,
|
||||
detail,
|
||||
referenceLines,
|
||||
planColorMode === "mono",
|
||||
);
|
||||
await savePlanPdf(plan, {
|
||||
scaleDenominator: decision.scaleDenominator,
|
||||
paper: decision.paper,
|
||||
orientation: decision.orientation,
|
||||
title: activeLevelName,
|
||||
subtitle: project.name,
|
||||
fileName: `${project.name}-${activeLevelName}`,
|
||||
});
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
// DXF-Export: erzeugt den Plan mit EXAKT denselben Eingaben wie die Ansicht und
|
||||
// schreibt ihn als Modell-Space-DXF in echten Metern (1:1). Layer aus den
|
||||
// Kategorie-Ebenen; jede Entity auf ihrem Kategorie-Layer.
|
||||
const onExportDxf = (decision: ExportDxfDecision) => {
|
||||
// Georeferenziert exportieren: bei gesetztem Standort-Bezug (geoAnchor)
|
||||
// schreibt der Modell-DXF die ECHTEN LV95-Koordinaten (weit vom Nullpunkt),
|
||||
// damit die Datei in anderer CAD-Software lagerichtig ankommt — auch wenn
|
||||
// lokal nahe (0,0) gezeichnet wurde. Ohne Bezug bleibt alles lokal.
|
||||
// (Der Papier-/Layout-Export bleibt bewusst in lokalen Koordinaten.)
|
||||
const exportProject = projectToWorldCoords(project);
|
||||
const plan = generatePlan(
|
||||
exportProject,
|
||||
activeLevelId,
|
||||
visibleCodes,
|
||||
categoryDisplay,
|
||||
detail,
|
||||
referenceLines,
|
||||
planColorMode === "mono",
|
||||
);
|
||||
savePlanDxf(plan, exportProject, {
|
||||
title: activeLevelName,
|
||||
includeHatches: decision.includeHatches,
|
||||
fileName: `${project.name}-${activeLevelName}`,
|
||||
});
|
||||
setExportDxfOpen(false);
|
||||
};
|
||||
|
||||
return { onExportPdf, onExportDxf };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Bündelt die Import-Handler, die NICHT schon in useProjectFileActions()
|
||||
// stecken (Projektdatei-Öffnen + DXF/DWG-Ausführung liegen dort): Bild/PDF
|
||||
// einfügen (Werkzeug „image") und die App-weite Drag&Drop-Verdrahtung für
|
||||
// DXF/DWG-Dateien (ruft `openImportForFile` aus useProjectFileActions auf).
|
||||
// Nur aus App.tsx herausgezogen, um die Funktion zu entlasten — reine
|
||||
// Verlagerung, keine Verhaltensänderung.
|
||||
//
|
||||
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import { useRef } from "react";
|
||||
import { addImageDrawing2d } from "./projectSlice";
|
||||
import type { NotifyKind } from "./notifySlice";
|
||||
import { defaultImageSizeM, readImageFile } from "../io/imageAsset";
|
||||
import { rasterizePdfPages } from "../io/pdfImport";
|
||||
import { t } from "../i18n";
|
||||
import type { Project, Vec2 } from "../model/types";
|
||||
|
||||
/**
|
||||
* „Bild einfügen" (Werkzeug „image"): kein Klick-Platzieren (Datei-Auswahl ist
|
||||
* async) — jedes gewählte Bild (oder jede gerasterte PDF-Seite) landet mittig
|
||||
* am Modell-Nullpunkt, leicht kaskadiert bei mehreren, sofort selektiert
|
||||
* (Griffe/Objekt-Info stehen direkt zum Verschieben/Skalieren bereit).
|
||||
*/
|
||||
export function useImageImportHandling(opts: {
|
||||
project: Project;
|
||||
activeLevelId: string;
|
||||
activeCategoryCode: string;
|
||||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
||||
notify: (message: string, kind?: NotifyKind) => void;
|
||||
setSelectedDrawingId: (id: string | null) => void;
|
||||
setSelectedWallIds: (ids: string[]) => void;
|
||||
setSelectedCeilingIds: (ids: string[]) => void;
|
||||
setSelectedOpeningIds: (ids: string[]) => void;
|
||||
setSelectedStairIds: (ids: string[]) => void;
|
||||
setSelectedRoomIds: (ids: string[]) => void;
|
||||
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
||||
setSelectedColumnIds: (ids: string[]) => void;
|
||||
setSelectedRoofIds: (ids: string[]) => void;
|
||||
setSelectedContextObjectIds: (ids: string[]) => void;
|
||||
setSelectedSectionLineId: (id: string | null) => void;
|
||||
}): {
|
||||
insertImageDrawings: (assets: { src: string; naturalWidth: number; naturalHeight: number }[]) => void;
|
||||
onImageOrPdfFileChosen: (file: File) => void;
|
||||
} {
|
||||
const {
|
||||
project,
|
||||
activeLevelId,
|
||||
activeCategoryCode,
|
||||
setProject,
|
||||
notify,
|
||||
setSelectedDrawingId,
|
||||
setSelectedWallIds,
|
||||
setSelectedCeilingIds,
|
||||
setSelectedOpeningIds,
|
||||
setSelectedStairIds,
|
||||
setSelectedRoomIds,
|
||||
setSelectedExtrudedSolidIds,
|
||||
setSelectedColumnIds,
|
||||
setSelectedRoofIds,
|
||||
setSelectedContextObjectIds,
|
||||
setSelectedSectionLineId,
|
||||
} = opts;
|
||||
|
||||
const insertImageDrawings = (
|
||||
assets: { src: string; naturalWidth: number; naturalHeight: number }[],
|
||||
) => {
|
||||
let cur = project;
|
||||
let lastId: string | null = null;
|
||||
assets.forEach((a, i) => {
|
||||
const { widthM, heightM } = defaultImageSizeM(a.naturalWidth, a.naturalHeight);
|
||||
const cascade = i * 0.3;
|
||||
const at: Vec2 = { x: -widthM / 2 + cascade, y: -heightM / 2 - cascade };
|
||||
const r = addImageDrawing2d(cur, activeLevelId, activeCategoryCode, a.src, at, widthM, heightM);
|
||||
cur = r.project;
|
||||
lastId = r.id;
|
||||
});
|
||||
setProject(cur);
|
||||
if (lastId) {
|
||||
setSelectedDrawingId(lastId);
|
||||
setSelectedWallIds([]);
|
||||
setSelectedCeilingIds([]);
|
||||
setSelectedOpeningIds([]);
|
||||
setSelectedStairIds([]);
|
||||
setSelectedRoomIds([]);
|
||||
setSelectedExtrudedSolidIds([]);
|
||||
setSelectedColumnIds([]);
|
||||
setSelectedRoofIds([]);
|
||||
setSelectedContextObjectIds([]);
|
||||
setSelectedSectionLineId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onImageOrPdfFileChosen = (file: File) => {
|
||||
const isPdf = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
if (isPdf) {
|
||||
void rasterizePdfPages(file)
|
||||
.then((pages) => {
|
||||
if (pages.length === 0) {
|
||||
notify(t("image.pdfEmpty"), "error");
|
||||
return;
|
||||
}
|
||||
insertImageDrawings(pages);
|
||||
})
|
||||
.catch(() => notify(t("image.pdfError"), "error"));
|
||||
} else {
|
||||
void readImageFile(file)
|
||||
.then((a) => insertImageDrawings([a]))
|
||||
.catch(() => notify(t("image.readError"), "error"));
|
||||
}
|
||||
};
|
||||
|
||||
return { insertImageDrawings, onImageOrPdfFileChosen };
|
||||
}
|
||||
|
||||
/**
|
||||
* App-weites Drag & Drop einer DXF/DWG-Datei: dragover muss preventDefault
|
||||
* rufen, sonst lässt der Browser nicht fallen. dragenter/leave wird ausgezählt
|
||||
* (Kind-Elemente lösen sonst Flackern aus). Ruft `openImportForFile` aus
|
||||
* useProjectFileActions auf (dieselbe Logik wie der Datei-Dialog).
|
||||
*/
|
||||
export function useFileDropImport(
|
||||
setDropActive: (active: boolean) => void,
|
||||
openImportForFile: (file: File) => void,
|
||||
): {
|
||||
onAppDragEnter: (e: DragEvent) => void;
|
||||
onAppDragOver: (e: DragEvent) => void;
|
||||
onAppDragLeave: (e: DragEvent) => void;
|
||||
onAppDrop: (e: DragEvent) => void;
|
||||
} {
|
||||
const dragDepthRef = useRef(0);
|
||||
const hasFiles = (e: DragEvent) =>
|
||||
Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
||||
const onAppDragEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setDropActive(true);
|
||||
};
|
||||
const onAppDragOver = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
const onAppDragLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setDropActive(false);
|
||||
};
|
||||
const onAppDrop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDropActive(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) openImportForFile(f);
|
||||
};
|
||||
return { onAppDragEnter, onAppDragOver, onAppDragLeave, onAppDrop };
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// Bündelt die globalen Tastatur-Shortcut-Effekte (Undo/Redo, Tab-Feld-Zyklus,
|
||||
// Werkzeug-Zifferntasten, aktives-Werkzeug Esc/Enter/Backspace, Alles-wählen +
|
||||
// Löschen, Split/Join, Kopieren/Einfügen von 2D-Elementen, Boolean-Operationen,
|
||||
// Transformations-Shortcuts) — nur aus App.tsx herausgezogen, um die Funktion
|
||||
// zu entlasten. Reine Verlagerung der `useEffect`-Registrierungen inkl. ihrer
|
||||
// Abhängigkeits-Arrays (auch dort, wo bewusst `eslint-disable` steht) — keine
|
||||
// Verhaltensänderung. Die Aktions-Funktionen selbst (deleteSelection, runSplit,
|
||||
// applyBoolean, …) bleiben in App.tsx, da sie auch von Menüs/TopBar gebraucht
|
||||
// werden, und werden hier nur als Parameter durchgereicht.
|
||||
//
|
||||
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { getTool } from "../tools/tools";
|
||||
import type { ToolContext, ToolDraft, ToolId, ToolState } from "../tools/types";
|
||||
import type { CopyMode, TransformOp } from "../tools/transform";
|
||||
import type { CommandEngine } from "../commands/engine";
|
||||
import type { CommandLineHandle } from "../ui/CommandLine";
|
||||
import type { Drawing2D, Project, Vec2 } from "../model/types";
|
||||
import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps";
|
||||
import type { BooleanResult } from "../editors/booleanOps";
|
||||
|
||||
/** Aktive Transformation (Bewegen/Spiegeln/Drehen) — Form aus useDrawingToolState. */
|
||||
type ActiveTransform = {
|
||||
op: TransformOp;
|
||||
copyMode: CopyMode;
|
||||
count: number;
|
||||
points: Vec2[];
|
||||
} | null;
|
||||
|
||||
const isEditableTarget = (e: KeyboardEvent): boolean => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
||||
};
|
||||
|
||||
/** Globaler Undo/Redo-Shortcut: Ctrl+Z / Cmd+Z, Ctrl+Shift+Z / Cmd+Shift+Z bzw. Ctrl+Y für redo. */
|
||||
export function useUndoRedoShortcut(undo: () => void, redo: () => void): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key.toLowerCase();
|
||||
if (!mod || (key !== "z" && key !== "y")) return;
|
||||
if (isEditableTarget(e)) return;
|
||||
e.preventDefault();
|
||||
if (key === "y" || (key === "z" && e.shiftKey)) redo();
|
||||
else undo();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [undo, redo]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Globaler Tab-Handler: läuft ein Befehl MIT Feldern (§2.7), zyklt Tab das
|
||||
* aktive Feld (Präzisionseingabe), ohne den Fokus zu stehlen — so kann man bei
|
||||
* der Maus bleiben. Sonst fokussiert/öffnet Tab die Command-Line. Direktes
|
||||
* Zahlen-Tippen beim Zeichnen (VW-Verhalten): läuft ein Befehl mit Tab-Feldern
|
||||
* und tippt man eine Ziffer (oder . , -), ohne dass ein Eingabefeld fokussiert
|
||||
* ist, startet das die Werteingabe in der Befehlszeile.
|
||||
*/
|
||||
export function useCommandTabShortcut(
|
||||
engineRef: RefObject<CommandEngine | null>,
|
||||
commandLineRef: RefObject<CommandLineHandle | null>,
|
||||
gripDragInfoRef: RefObject<{ index: number; raw: Vec2 } | null>,
|
||||
gripEditRef: RefObject<{
|
||||
anchor: Vec2;
|
||||
active: "length" | "angle";
|
||||
locks: { length?: number; angle?: number };
|
||||
} | null>,
|
||||
cycleGripEditFieldRef: RefObject<() => void>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const inInput = isEditableTarget(e);
|
||||
if (
|
||||
!inInput &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
/^[0-9.,-]$/.test(e.key) &&
|
||||
engineRef.current?.isActive() &&
|
||||
engineRef.current.hasFields()
|
||||
) {
|
||||
e.preventDefault();
|
||||
commandLineRef.current?.beginTyping(e.key);
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (inInput) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const eng = engineRef.current;
|
||||
if (eng?.isActive() && eng.hasFields()) {
|
||||
eng.cycleField();
|
||||
return;
|
||||
}
|
||||
// (c) Läuft ein Vertex-Griff-Drag, öffnet/zyklt Tab das Editier-Feld
|
||||
// (Länge/Winkel relativ zur Nachbarecke). Beim ersten Tab wird zusätzlich
|
||||
// das Befehlsfeld fokussiert, damit man den Zahlenwert direkt tippen kann.
|
||||
if (gripDragInfoRef.current) {
|
||||
const wasOpen = gripEditRef.current !== null;
|
||||
cycleGripEditFieldRef.current?.();
|
||||
if (!wasOpen) commandLineRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
commandLineRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie,
|
||||
* 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum.
|
||||
* Nur auf Geschoss-Tabs, nicht mit Modifikator (Ctrl/Cmd/Alt bleiben frei). Der
|
||||
* Handler wird einmal registriert und liest die aktuelle Auswahl-Funktion/
|
||||
* Ebenen-Art über ein Ref (sonst müsste der Effekt bei jedem Render neu
|
||||
* registrieren).
|
||||
*/
|
||||
export function useToolNumberShortcuts(
|
||||
onSelectTool: (id: ToolId) => void,
|
||||
isFloor: boolean,
|
||||
engineRef: RefObject<CommandEngine | null>,
|
||||
): void {
|
||||
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
|
||||
toolShortcutRef.current = { onSelectTool, floor: isFloor };
|
||||
useEffect(() => {
|
||||
const MAP: Record<string, ToolId> = {
|
||||
"1": "text",
|
||||
"2": "line",
|
||||
"3": "circle",
|
||||
"4": "rect",
|
||||
"5": "polyline",
|
||||
"6": "wall",
|
||||
"7": "ceiling",
|
||||
"8": "window",
|
||||
"9": "door",
|
||||
"0": "room",
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
const isInput =
|
||||
!!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
||||
if (isInput) {
|
||||
const isCmdLineInput = el.classList.contains("cmdline-input");
|
||||
const empty = isCmdLineInput && (el as HTMLInputElement).value === "";
|
||||
const eng = engineRef.current;
|
||||
const cmdLineFreeToSwitch =
|
||||
empty && !eng?.hasFields() && !eng?.acceptsFreeText();
|
||||
if (!cmdLineFreeToSwitch) return;
|
||||
}
|
||||
const id = MAP[e.key];
|
||||
if (!id) return;
|
||||
if (!toolShortcutRef.current.floor) return; // Werkzeuge nur auf Geschossen
|
||||
e.preventDefault();
|
||||
toolShortcutRef.current.onSelectTool(id);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tastatur für aktive Zeichenwerkzeuge: Esc verwirft den Entwurf (bzw. schaltet
|
||||
* im Ruhezustand zurück auf Auswahl), Enter schließt mehrteilige Werkzeuge ab,
|
||||
* Backspace nimmt den letzten Punkt zurück. In echten Eingaben inaktiv. Läuft
|
||||
* ein gekoppelter Befehl (Linie/Rechteck/Polylinie über die Engine), gehören
|
||||
* Esc/Enter/Backspace der Engine bzw. dem Befehlsfeld — der Legacy-Werkzeug-
|
||||
* Pfad bleibt stumm (keine doppelte Mechanik).
|
||||
*/
|
||||
export function useActiveToolKeys(
|
||||
activeTool: ToolId,
|
||||
toolCtx: ToolContext,
|
||||
engineRef: RefObject<CommandEngine | null>,
|
||||
toolStateRef: MutableRefObject<ToolState>,
|
||||
setDraft: (draft: ToolDraft | null) => void,
|
||||
setActiveTool: (id: ToolId) => void,
|
||||
setProject: (next: Project | ((p: Project) => Project)) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (activeTool === "select") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e)) return;
|
||||
if (engineRef.current?.isActive()) return;
|
||||
const tool = getTool(activeTool);
|
||||
const drawing = toolStateRef.current.phase !== "idle";
|
||||
if (e.key === "Escape") {
|
||||
if (drawing) {
|
||||
const [st] = tool.onCancel(toolStateRef.current);
|
||||
toolStateRef.current = st;
|
||||
setDraft(null);
|
||||
} else {
|
||||
setActiveTool("select");
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Enter") {
|
||||
const [st, res] = tool.onCommitGesture(toolStateRef.current, toolCtx);
|
||||
toolStateRef.current = st;
|
||||
if (res.commit) setProject(res.commit);
|
||||
setDraft(null);
|
||||
if (res.done) toolStateRef.current = tool.init();
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Backspace" && tool.onUndoPoint) {
|
||||
const [st, res] = tool.onUndoPoint(toolStateRef.current, toolCtx);
|
||||
toolStateRef.current = st;
|
||||
setDraft(res.draft);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeTool, toolCtx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles auf der aktiven Ebene
|
||||
* wählen; Entf/Backspace = ALLE Selektierten (Wände + 2D-Elemente) löschen.
|
||||
* `hasSelectionToDelete`/`deleteSelection` bleiben in App.tsx (auch vom Menü
|
||||
* „Bearbeiten > Löschen" gebraucht) und werden hier nur aufgerufen.
|
||||
*/
|
||||
export function useSelectAllAndDeleteShortcut(opts: {
|
||||
activeTool: ToolId;
|
||||
activeTransform: ActiveTransform;
|
||||
project: Project;
|
||||
activeLevelId: string;
|
||||
selectedDrawingIds: string[];
|
||||
selectedWallIds: string[];
|
||||
selectedCeilingIds: string[];
|
||||
selectedOpeningIds: string[];
|
||||
selectedStairIds: string[];
|
||||
selectedRoomIds: string[];
|
||||
selectedExtrudedSolidIds: string[];
|
||||
selectedColumnIds: string[];
|
||||
selectedRoofIds: string[];
|
||||
selectedContextObjectIds: string[];
|
||||
selectedSectionLineId: string | null;
|
||||
setSelectedWallIds: (ids: string[]) => void;
|
||||
setSelectedDrawingIds: (ids: string[]) => void;
|
||||
setSelectedCeilingIds: (ids: string[]) => void;
|
||||
setSelectedStairIds: (ids: string[]) => void;
|
||||
setSelectedRoomIds: (ids: string[]) => void;
|
||||
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
||||
setSelectedColumnIds: (ids: string[]) => void;
|
||||
hasSelectionToDelete: () => boolean;
|
||||
deleteSelection: () => void;
|
||||
}): void {
|
||||
const {
|
||||
activeTool,
|
||||
activeTransform,
|
||||
project,
|
||||
activeLevelId,
|
||||
setSelectedWallIds,
|
||||
setSelectedDrawingIds,
|
||||
setSelectedCeilingIds,
|
||||
setSelectedStairIds,
|
||||
setSelectedRoomIds,
|
||||
setSelectedExtrudedSolidIds,
|
||||
setSelectedColumnIds,
|
||||
hasSelectionToDelete,
|
||||
deleteSelection,
|
||||
} = opts;
|
||||
useEffect(() => {
|
||||
if (activeTool !== "select") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e)) return;
|
||||
// Alles auswählen (Wände + Zeichnungen des aktiven Geschosses/der Ebene).
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) {
|
||||
const wallIds = project.walls
|
||||
.filter((w) => w.floorId === activeLevelId)
|
||||
.map((w) => w.id);
|
||||
const drawingIds = project.drawings2d
|
||||
.filter((d) => d.levelId === activeLevelId)
|
||||
.map((d) => d.id);
|
||||
const ceilingIds = (project.ceilings ?? [])
|
||||
.filter((c) => c.floorId === activeLevelId)
|
||||
.map((c) => c.id);
|
||||
const stairIds = (project.stairs ?? [])
|
||||
.filter((s) => s.floorId === activeLevelId)
|
||||
.map((s) => s.id);
|
||||
const roomIds = (project.rooms ?? [])
|
||||
.filter((r) => r.floorId === activeLevelId)
|
||||
.map((r) => r.id);
|
||||
const extrudedSolidIds = (project.extrudedSolids ?? [])
|
||||
.filter((s) => s.levelId === activeLevelId)
|
||||
.map((s) => s.id);
|
||||
const columnIds = (project.columns ?? [])
|
||||
.filter((c) => c.floorId === activeLevelId)
|
||||
.map((c) => c.id);
|
||||
if (
|
||||
wallIds.length ||
|
||||
drawingIds.length ||
|
||||
ceilingIds.length ||
|
||||
stairIds.length ||
|
||||
roomIds.length ||
|
||||
extrudedSolidIds.length ||
|
||||
columnIds.length
|
||||
) {
|
||||
setSelectedWallIds(wallIds);
|
||||
setSelectedDrawingIds(drawingIds);
|
||||
setSelectedCeilingIds(ceilingIds);
|
||||
setSelectedStairIds(stairIds);
|
||||
setSelectedRoomIds(roomIds);
|
||||
setSelectedExtrudedSolidIds(extrudedSolidIds);
|
||||
setSelectedColumnIds(columnIds);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (activeTransform) return; // während einer Transformation nicht löschen
|
||||
if (hasSelectionToDelete()) {
|
||||
deleteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [
|
||||
opts.activeTool,
|
||||
opts.activeTransform,
|
||||
opts.selectedDrawingIds,
|
||||
opts.selectedWallIds,
|
||||
opts.selectedCeilingIds,
|
||||
opts.selectedOpeningIds,
|
||||
opts.selectedStairIds,
|
||||
opts.selectedExtrudedSolidIds,
|
||||
opts.selectedColumnIds,
|
||||
opts.selectedRoofIds,
|
||||
opts.selectedContextObjectIds,
|
||||
opts.selectedSectionLineId,
|
||||
opts.project.walls,
|
||||
opts.project.drawings2d,
|
||||
opts.project.ceilings,
|
||||
opts.project.roofs,
|
||||
opts.project.stairs,
|
||||
opts.project.extrudedSolids,
|
||||
opts.project.columns,
|
||||
opts.activeLevelId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente.
|
||||
* Join läuft über DENSELBEN Engine-Befehl wie getipptes „join" (Sofort-
|
||||
* Befehl, autoRun). Split bleibt die direkte Aktion (`runSplit`, in App.tsx —
|
||||
* bleibt dort, da es auch für einen etwaigen Menü-Eintrag geteilt wird). Beide
|
||||
* rufen preventDefault (sonst Browser-Speichern/-Lesezeichen). Wände sind
|
||||
* ausgenommen (no-op). Tool-unabhängig, solange eine 2D-Auswahl steht.
|
||||
*/
|
||||
export function useSplitJoinShortcut(
|
||||
selectedDrawingIds: string[],
|
||||
engine: CommandEngine,
|
||||
runSplit: (ids: string[]) => void,
|
||||
setSelectedDrawingIds: (ids: string[]) => void,
|
||||
setSelectedWallIds: (ids: string[]) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "s" && k !== "j") return;
|
||||
if (isEditableTarget(e)) return;
|
||||
const ids = selectedDrawingIds;
|
||||
if (ids.length === 0) return; // nur 2D-Auswahl bedient; Wände → no-op
|
||||
e.preventDefault();
|
||||
|
||||
if (k === "s") {
|
||||
runSplit(ids);
|
||||
return;
|
||||
}
|
||||
// Join: exakt derselbe Befehl wie „join" tippen (Sofort-Befehl, autoRun).
|
||||
// Nach dem Verbinden zeigt die alte Auswahl auf ersetzte IDs → aufräumen.
|
||||
engine.start("join");
|
||||
setSelectedDrawingIds([]);
|
||||
setSelectedWallIds([]);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, engine]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
|
||||
* die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
|
||||
* neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
|
||||
* wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
|
||||
* Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
|
||||
*/
|
||||
export function useDrawingClipboardShortcut(
|
||||
selectedDrawingIds: string[],
|
||||
drawingClipboard: Drawing2D[],
|
||||
setDrawingClipboard: (items: Drawing2D[]) => void,
|
||||
activeLevelId: string,
|
||||
drawings2d: Drawing2D[],
|
||||
importDrawings: (drawings: Drawing2D[]) => void,
|
||||
setSelectedDrawingIds: (ids: string[]) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "c" && k !== "v") return;
|
||||
if (isEditableTarget(e)) return;
|
||||
if (k === "c") {
|
||||
const ids = new Set(selectedDrawingIds);
|
||||
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
|
||||
const picked = drawings2d.filter((d) => ids.has(d.id));
|
||||
if (picked.length === 0) return;
|
||||
e.preventDefault();
|
||||
setDrawingClipboard(picked.map((d) => structuredClone(d)));
|
||||
return;
|
||||
}
|
||||
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
|
||||
if (drawingClipboard.length === 0) return;
|
||||
e.preventDefault();
|
||||
const stamp = Date.now();
|
||||
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
|
||||
...structuredClone(d),
|
||||
id: `drawing-${stamp}-${i}`,
|
||||
levelId: activeLevelId,
|
||||
}));
|
||||
importDrawings(pasted);
|
||||
setSelectedDrawingIds(pasted.map((p) => p.id));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, drawingClipboard, activeLevelId, drawings2d]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
|
||||
* geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
|
||||
* geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.) `applyBoolean`
|
||||
* bleibt in App.tsx (auch vom Kontextmenü gebraucht).
|
||||
*/
|
||||
export function useBooleanOpsShortcut(
|
||||
selectedDrawingIds: string[],
|
||||
drawings2d: Drawing2D[],
|
||||
applyBoolean: (op: (srcs: Drawing2D[]) => BooleanResult) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "u" && k !== "d" && k !== "i") return;
|
||||
if (isEditableTarget(e)) return;
|
||||
if (selectedDrawingIds.length < 2) return;
|
||||
e.preventDefault();
|
||||
if (k === "u") applyBoolean(unionDrawings);
|
||||
else if (k === "d") applyBoolean(differenceDrawings);
|
||||
else applyBoolean(intersectionDrawings);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, drawings2d]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformations-Shortcuts (nur im Auswahl-Werkzeug):
|
||||
* m = Bewegen, s = Spiegeln, d = Drehen (startet auf der Auswahl);
|
||||
* während einer Transformation: u/i/o/p = Modus (move/copy/array/distribute),
|
||||
* Esc = abbrechen. O/P fragen die Kopienanzahl ab. „q" = Messen (Distanz/
|
||||
* Winkel) — braucht KEINE Auswahl.
|
||||
*/
|
||||
export function useTransformShortcuts(
|
||||
activeTool: ToolId,
|
||||
activeTransform: ActiveTransform,
|
||||
setActiveTransform: (
|
||||
next: ActiveTransform | ((t: ActiveTransform) => ActiveTransform),
|
||||
) => void,
|
||||
selectedWallIds: string[],
|
||||
selectedDrawingId: string | null,
|
||||
selectedExtrudedSolidId: string | null,
|
||||
selectedColumnId: string | null,
|
||||
engine: CommandEngine,
|
||||
setDraft: (draft: ToolDraft | null) => void,
|
||||
requestCopyCount: (mode: "array" | "distribute") => void,
|
||||
onRunCommand: (name: string) => void,
|
||||
commandLineRef: RefObject<CommandLineHandle | null>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (activeTool !== "select") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (activeTransform) {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "u") setActiveTransform((t) => (t ? { ...t, copyMode: "move" } : t));
|
||||
else if (k === "i") setActiveTransform((t) => (t ? { ...t, copyMode: "copy" } : t));
|
||||
else if (k === "o") {
|
||||
requestCopyCount("array");
|
||||
} else if (k === "p") {
|
||||
requestCopyCount("distribute");
|
||||
} else if (e.key === "Escape") {
|
||||
setActiveTransform(null);
|
||||
setDraft(null);
|
||||
} else return;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// „q" = Messen (Distanz/Winkel) — braucht KEINE Auswahl, daher vor hasSel.
|
||||
if (e.key.toLowerCase() === "q") {
|
||||
onRunCommand("measure");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Transformierbar sind Wände, 2D-Zeichnungen, extrudierte Körper und Stützen
|
||||
// (dieselben Arten wie transformSel). Öffnungen/Treppen laufen bewusst NICHT
|
||||
// hierüber, sondern über ihre eigenen Bewegpunkte (Grips) — sie sind wand-
|
||||
// bzw. geschossgebunden.
|
||||
const hasSel =
|
||||
selectedWallIds.length > 0 ||
|
||||
!!selectedDrawingId ||
|
||||
!!selectedExtrudedSolidId ||
|
||||
!!selectedColumnId;
|
||||
if (!hasSel) return;
|
||||
const k = e.key.toLowerCase();
|
||||
// Bewegen/Spiegeln/Drehen laufen ALLE über die interaktive Transformation
|
||||
// (`activeTransform`) mit den U/I/O/P-Kopiermodi (Bewegen/Kopie/Reihe/
|
||||
// Verteilen). Prompt + Optionen + Live-Feld speisen die Command-Line; die
|
||||
// Kürzel U/I/O/P wirken dort auch bei fokussiertem Eingabefeld (options.key).
|
||||
const op: TransformOp | null =
|
||||
k === "m" ? "move" : k === "s" ? "mirror" : k === "d" ? "rotate" : null;
|
||||
if (op) {
|
||||
setActiveTransform({ op, copyMode: "move", count: 1, points: [] });
|
||||
setDraft(null);
|
||||
requestAnimationFrame(() => commandLineRef.current?.focus());
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [
|
||||
activeTool,
|
||||
activeTransform,
|
||||
selectedWallIds,
|
||||
selectedDrawingId,
|
||||
selectedExtrudedSolidId,
|
||||
selectedColumnId,
|
||||
engine,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user