diff --git a/src/App.tsx b/src/App.tsx index d1a2d3b..5d7c9d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -849,18 +849,6 @@ export default function App() { // Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert // erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten). const [cmdTyped, setCmdTyped] = useState(""); - const hudFields = - commandActive && engineView.fields.length > 0 - ? { - fields: engineView.fields.map((f) => ({ - id: f.id, - value: f.value, - locked: f.locked, - active: f.active, - })), - typed: cmdTyped, - } - : null; // ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ─────────────────────── // Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie) @@ -1339,6 +1327,27 @@ export default function App() { moveElementByOf, }); + // Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein + // Punkt-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der + // Befehlszeile — Länge/Winkel-Tab-Zyklus), sonst die Befehls-Engine-Felder + // beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen GAR KEINE + // Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-Wunsch: „du + // kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim Erstellen". + const hudFields = + gripEdit + ? { fields: gripEditFields(), typed: cmdTyped } + : commandActive && engineView.fields.length > 0 + ? { + fields: engineView.fields.map((f) => ({ + id: f.id, + value: f.value, + locked: f.locked, + active: f.active, + })), + typed: cmdTyped, + } + : null; + // Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe // state/useKeyboardShortcuts.ts. useDrawingClipboardShortcut( diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index e400d2a..bdddd8e 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -424,6 +424,12 @@ export interface GripHandlers { */ onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void; onGripEnd(): void; + /** + * Bewaffneten Punkt-Griff verwerfen (Escape): setzt den Punkt exakt auf + * `origin` zurück (ohne Snap/Ortho-Neuauswertung) und räumt danach wie + * {@link onGripEnd} auf. + */ + onGripCancel(index: number, origin: Vec2): void; } export const PlanView = forwardRef( @@ -769,7 +775,14 @@ export const PlanView = forwardRef( // Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je // Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove. - const gripDrag = useRef<{ pointerId: number; index: number } | null>(null); + // Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung + // NICHT mehr zu beenden: der Griff bleibt „bewaffnet" (kein Pointer-Capture), + // folgt dem Cursor beim Schweben weiter — wie beim Zeichnen (Klick → Punkt + // setzen, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter bestätigt, + // Escape verwirft zur ursprünglichen Position `origin` zurück). + const gripDrag = useRef<{ pointerId: number; index: number; origin: Vec2 } | null>( + null, + ); // Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser). // Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove. const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null); @@ -789,6 +802,28 @@ export const PlanView = forwardRef( // Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick). const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); + // Tastatur-Abschluss eines bewaffneten Punkt-Griffs (s. gripDrag oben): Escape + // verwirft (zurück auf `origin`), Enter bestätigt die aktuelle Position — beide + // NUR wenn kein Eingabefeld fokussiert ist (die Befehlszeile behandelt ihr + // eigenes Enter für getippte Länge/Winkel-Werte selbst, s. App.tsx). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const gd = gripDrag.current; + if (!gd || !gripHandlers) return; + if (e.key !== "Escape" && e.key !== "Enter") return; + const el = e.target as HTMLElement | null; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { + return; + } + e.preventDefault(); + gripDrag.current = null; + if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin); + else gripHandlers.onGripEnd(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [gripHandlers]); + /** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */ const stampHandleAt = (clientX: number, clientY: number): string | null => { if (!roomStamp) return null; @@ -1199,6 +1234,16 @@ export const PlanView = forwardRef( // Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan), // LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom. const onPointerDown = (e: React.PointerEvent) => { + // Ein Punkt-Griff ist bereits „bewaffnet" (Maus wurde losgelassen, der + // Punkt folgt seither dem Cursor) — JEDER weitere Linksklick bestätigt nur + // die aktuelle Position, statt irgendeine andere Aktion (Doppelklick, + // Schere, neue Auswahl, …) an derselben Stelle auszulösen. + if (e.button === 0 && gripDrag.current && gripHandlers) { + e.preventDefault(); + gripDrag.current = null; + gripHandlers.onGripEnd(); + return; + } // Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche → // Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView // Pointer-Capture nutzt und der Browser dann kein natives `dblclick` @@ -1278,8 +1323,10 @@ export const PlanView = forwardRef( const gi = gripAt(e.clientX, e.clientY); if (gi !== null) { e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); - gripDrag.current = { pointerId: e.pointerId, index: gi }; + // KEIN Pointer-Capture: der Griff bleibt nach dem Loslassen bewaffnet + // und folgt via normalem Hover-onPointerMove weiter dem Cursor (s. + // Kommentar an der gripDrag-Deklaration). + gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] }; return; } // Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. @@ -1571,14 +1618,11 @@ export const PlanView = forwardRef( return; } - // Editier-Griff loslassen: Ziehen abschließen. - const gd = gripDrag.current; - if (gd && gd.pointerId === e.pointerId) { - if (e.currentTarget.hasPointerCapture(e.pointerId)) { - e.currentTarget.releasePointerCapture(e.pointerId); - } - gripDrag.current = null; - gripHandlers!.onGripEnd(); + // Editier-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein + // Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem + // Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder + // Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten). + if (gripDrag.current && gripDrag.current.pointerId === e.pointerId) { return; } diff --git a/src/state/useGripEditing.ts b/src/state/useGripEditing.ts index 7937a95..d2276c2 100644 --- a/src/state/useGripEditing.ts +++ b/src/state/useGripEditing.ts @@ -435,32 +435,34 @@ export function useGripEditing(opts: { ]; }; - // Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps). - const cycleGripEditFieldRef = useRef<() => void>(() => {}); - // Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Der - // Anker ist normalerweise die BENACHBARTE Ecke (Polylinien-Semantik: Länge/ - // Winkel relativ zum Nachbarpunkt) — bei Kreis/Bogen ist das falsch (deren - // Griffe liegen auf dem Ring, "Länge" muss der RADIUS sein, also relativ - // zum ZENTRUM). Ohne diese Sonderfälle blieb der Kreis-Radius-Griff sogar - // ganz ohne Anker (nur 1 Griff, kein Nachbar) → Tab tat nichts. - const cycleGripEditField = () => { - const info = gripDragInfoRef.current; - if (!info) return; + // Fixer Bezug (Anker) für Länge/Winkel eines gezogenen Punkt-Griffs. Normal + // die BENACHBARTE Ecke DESSELBEN Elements (Polylinien-Semantik) — bei Kreis/ + // Bogen ist das falsch (deren Griffe liegen auf dem Ring, "Länge" muss der + // RADIUS sein, also relativ zum ZENTRUM). Ohne diesen Sonderfall blieb der + // Kreis-Radius-Griff sogar ganz ohne Anker (nur 1 Griff, kein Nachbar). + // Gemeinsam genutzt vom Tab-Feld-Controller UND dem Shift-Ortho-Bezug UND + // dem Cursor-HUD (Länge/Winkel-Anzeige neben der Maus). + const resolveGripAnchor = (index: number): Vec2 | null => { // Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln // selektierten `selectedDrawing`, sondern einem anderen Element der // Auswahl — das eigentliche Element über `gripOwners` auflösen. - const owner = gripOwners[info.index]; + const owner = gripOwners[index]; const ownerDrawing = owner?.drawingId ? project.drawings2d.find((d) => d.id === owner.drawingId) : selectedDrawing; const g = ownerDrawing?.geom; - const anchor = - g && (g.shape === "circle" || g.shape === "arc") - ? g.center - : (() => { - const refIndex = neighborGripIndex(info.index); - return refIndex != null ? grips[refIndex] : null; - })(); + if (g && (g.shape === "circle" || g.shape === "arc")) return g.center; + const refIndex = neighborGripIndex(index); + return refIndex != null ? grips[refIndex] : null; + }; + + // Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps). + const cycleGripEditFieldRef = useRef<() => void>(() => {}); + // Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). + const cycleGripEditField = () => { + const info = gripDragInfoRef.current; + if (!info) return; + const anchor = resolveGripAnchor(info.index); if (!anchor) return; // ohne Anker keine polare Eingabe möglich setGripEdit((prev) => prev @@ -532,6 +534,29 @@ export function useGripEditing(opts: { else moveEdgeOf(edge.owner?.drawingId ?? selectedDrawingId, edge.owner?.wallId ?? selectedWallId, edge.aIndex, edge.bIndex, delta); }; + // Maß-/Winkel-HUD am Cursor für einen gezogenen Punkt-Griff — dieselbe + // Darstellung wie beim Zeichnen (DraftHud, VW-Stil). Ohne Anker (Einzelgriff- + // Elemente wie Öffnung/Treppe) gibt es keine sinnvolle Länge/Winkel-Anzeige. + const hudFor = (index: number, pt: Vec2): NonNullable | undefined => { + const anchor = resolveGripAnchor(index); + if (!anchor) return undefined; + const dx = pt.x - anchor.x; + const dy = pt.y - anchor.y; + const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360; + return { at: pt, length: Math.hypot(dx, dy), angleDeg }; + }; + + // Aufräumen nach Abschluss/Verwerfen eines Griff-Drags (geteilt von onGripEnd + // und onGripCancel). + const endGripEditing = () => { + setDraft(null); + edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen + edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen + moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen + gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen + setGripEdit(null); // (c) Feld-Controller schließen + }; + const gripHandlers = { onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => { // (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar @@ -541,7 +566,7 @@ export function useGripEditing(opts: { if (ge && (ge.locks.length != null || ge.locks.angle != null)) { const pt = gripEditPoint(ge.anchor, ge.locks, raw); applyGrip(index, pt); - setDraft({ preview: [], vertices: [], snap: null }); + setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) }); return; } // Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend @@ -568,7 +593,7 @@ export function useGripEditing(opts: { if (mods.shift && anchor) { const pt = applyAngleConstraint(anchor, raw, 90); applyGrip(index, pt); - setDraft({ preview: [], vertices: [], snap: null }); + setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) }); return; } // Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der @@ -588,7 +613,7 @@ export function useGripEditing(opts: { }); const pt = snapResult?.point ?? raw; applyGrip(index, pt); - setDraft({ preview: [], vertices: [], snap: snapResult }); + setDraft({ preview: [], vertices: [], snap: snapResult, hud: hudFor(index, pt) }); }, onMoveBody: ( delta: Vec2, @@ -706,12 +731,14 @@ export function useGripEditing(opts: { setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null }); }, onGripEnd: () => { - setDraft(null); - edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen - edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen - moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen - gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen - setGripEdit(null); // (c) Feld-Controller schließen + endGripEditing(); + }, + // Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin` + // zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und + // danach wie onGripEnd aufräumen. + onGripCancel: (index: number, origin: Vec2) => { + applyGrip(index, origin); + endGripEditing(); }, };