2D: Punkt-Griff ziehen ohne gehaltene Maustaste, Cursor-HUD dabei

Nutzer-Wunsch: sobald ein Punkt-Griff angewählt ist, soll man die
Maustaste loslassen können und trotzdem weiter per Cursor UND per
getipptem Wert positionieren können -- wie beim Zeichnen (Klick setzt
Punkt, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter
bestätigt), nicht wie bisher als reine Halten-und-Ziehen-Geste.

PlanView.tsx: der Griff wird beim Klick "bewaffnet" statt per
Pointer-Capture gehalten zu werden; Loslassen der Maus beendet die
Bearbeitung nicht mehr, der Punkt folgt weiter per normalem Hover-
Pointermove. Abgeschlossen wird per erneutem Linksklick (irgendwo,
verhindert dass derselbe Klick eine andere Aktion auslöst) oder Enter,
verworfen per Escape (beide nur ausserhalb von Eingabefeldern, damit
die Befehlszeile ihr eigenes Enter behält). Neuer onGripCancel-Handler
setzt dafür exakt auf die Ausgangsposition zurück.

useGripEditing.ts: die Anker-Auflösung (Nachbar-Ecke bzw. Kreis-/Bogen-
Zentrum) ist jetzt ein gemeinsamer resolveGripAnchor()-Helper, genutzt
vom Tab-Feld-Controller UND einem neuen Cursor-HUD (Länge/Winkel neben
der Maus, dieselbe Darstellung wie beim Zeichnen) -- vorher zeigte das
Cursor-HUD beim Griff-Ziehen gar keine Werte an, nur die Befehlszeile
unten.

App.tsx: das Cursor-HUD-Feld-Objekt zeigt bei aktivem Griff-Tab-Feld
jetzt dieselben Felder wie die Befehlszeile (vorher nur bei laufendem
Zeichenbefehl).

Kanten-Griff und Körper-Verschieben (ganzes Element) bleiben bewusst
unverändert (Halten-und-Ziehen) -- nur explizit angefragt war der
Einzelpunkt-Fall.
This commit is contained in:
2026-08-21 21:50:00 +02:00
parent 13619e9271
commit a6c984e517
3 changed files with 131 additions and 51 deletions
+21 -12
View File
@@ -849,18 +849,6 @@ export default function App() {
// Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert // Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert
// erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten). // erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten).
const [cmdTyped, setCmdTyped] = useState(""); 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) ─────────────────────── // ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ───────────────────────
// Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie) // Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie)
@@ -1339,6 +1327,27 @@ export default function App() {
moveElementByOf, 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 // Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
// state/useKeyboardShortcuts.ts. // state/useKeyboardShortcuts.ts.
useDrawingClipboardShortcut( useDrawingClipboardShortcut(
+55 -11
View File
@@ -424,6 +424,12 @@ export interface GripHandlers {
*/ */
onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void; onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
onGripEnd(): 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<PlanViewHandle, PlanViewProps>( export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
@@ -769,7 +775,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je // Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove. // 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). // Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser).
// Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove. // Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove.
const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null); const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
@@ -789,6 +802,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick). // Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); 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. */ /** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */
const stampHandleAt = (clientX: number, clientY: number): string | null => { const stampHandleAt = (clientX: number, clientY: number): string | null => {
if (!roomStamp) return null; if (!roomStamp) return null;
@@ -1199,6 +1234,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan), // Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom. // LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => { const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
// 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 → // Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView // Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick` // Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
@@ -1278,8 +1323,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const gi = gripAt(e.clientX, e.clientY); const gi = gripAt(e.clientX, e.clientY);
if (gi !== null) { if (gi !== null) {
e.preventDefault(); e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId); // KEIN Pointer-Capture: der Griff bleibt nach dem Loslassen bewaffnet
gripDrag.current = { pointerId: e.pointerId, index: gi }; // 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; return;
} }
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. // Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen.
@@ -1571,14 +1618,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return; return;
} }
// Editier-Griff loslassen: Ziehen abschließen. // Editier-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
const gd = gripDrag.current; // Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
if (gd && gd.pointerId === e.pointerId) { // Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
if (e.currentTarget.hasPointerCapture(e.pointerId)) { // Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
e.currentTarget.releasePointerCapture(e.pointerId); if (gripDrag.current && gripDrag.current.pointerId === e.pointerId) {
}
gripDrag.current = null;
gripHandlers!.onGripEnd();
return; return;
} }
+55 -28
View File
@@ -435,32 +435,34 @@ export function useGripEditing(opts: {
]; ];
}; };
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps). // Fixer Bezug (Anker) für Länge/Winkel eines gezogenen Punkt-Griffs. Normal
const cycleGripEditFieldRef = useRef<() => void>(() => {}); // die BENACHBARTE Ecke DESSELBEN Elements (Polylinien-Semantik) — bei Kreis/
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Der // Bogen ist das falsch (deren Griffe liegen auf dem Ring, "Länge" muss der
// Anker ist normalerweise die BENACHBARTE Ecke (Polylinien-Semantik: Länge/ // RADIUS sein, also relativ zum ZENTRUM). Ohne diesen Sonderfall blieb der
// Winkel relativ zum Nachbarpunkt) — bei Kreis/Bogen ist das falsch (deren // Kreis-Radius-Griff sogar ganz ohne Anker (nur 1 Griff, kein Nachbar).
// Griffe liegen auf dem Ring, "Länge" muss der RADIUS sein, also relativ // Gemeinsam genutzt vom Tab-Feld-Controller UND dem Shift-Ortho-Bezug UND
// zum ZENTRUM). Ohne diese Sonderfälle blieb der Kreis-Radius-Griff sogar // dem Cursor-HUD (Länge/Winkel-Anzeige neben der Maus).
// ganz ohne Anker (nur 1 Griff, kein Nachbar) → Tab tat nichts. const resolveGripAnchor = (index: number): Vec2 | null => {
const cycleGripEditField = () => {
const info = gripDragInfoRef.current;
if (!info) return;
// Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln // Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln
// selektierten `selectedDrawing`, sondern einem anderen Element der // selektierten `selectedDrawing`, sondern einem anderen Element der
// Auswahl — das eigentliche Element über `gripOwners` auflösen. // Auswahl — das eigentliche Element über `gripOwners` auflösen.
const owner = gripOwners[info.index]; const owner = gripOwners[index];
const ownerDrawing = owner?.drawingId const ownerDrawing = owner?.drawingId
? project.drawings2d.find((d) => d.id === owner.drawingId) ? project.drawings2d.find((d) => d.id === owner.drawingId)
: selectedDrawing; : selectedDrawing;
const g = ownerDrawing?.geom; const g = ownerDrawing?.geom;
const anchor = if (g && (g.shape === "circle" || g.shape === "arc")) return g.center;
g && (g.shape === "circle" || g.shape === "arc") const refIndex = neighborGripIndex(index);
? g.center return refIndex != null ? grips[refIndex] : null;
: (() => { };
const refIndex = neighborGripIndex(info.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 if (!anchor) return; // ohne Anker keine polare Eingabe möglich
setGripEdit((prev) => setGripEdit((prev) =>
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); 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<ToolDraft["hud"]> | 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 = { const gripHandlers = {
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => { onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar // (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)) { if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
const pt = gripEditPoint(ge.anchor, ge.locks, raw); const pt = gripEditPoint(ge.anchor, ge.locks, raw);
applyGrip(index, pt); applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null }); setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return; return;
} }
// Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend // Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend
@@ -568,7 +593,7 @@ export function useGripEditing(opts: {
if (mods.shift && anchor) { if (mods.shift && anchor) {
const pt = applyAngleConstraint(anchor, raw, 90); const pt = applyAngleConstraint(anchor, raw, 90);
applyGrip(index, pt); applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null }); setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return; return;
} }
// Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der // 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; const pt = snapResult?.point ?? raw;
applyGrip(index, pt); applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: snapResult }); setDraft({ preview: [], vertices: [], snap: snapResult, hud: hudFor(index, pt) });
}, },
onMoveBody: ( onMoveBody: (
delta: Vec2, delta: Vec2,
@@ -706,12 +731,14 @@ export function useGripEditing(opts: {
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null }); setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
}, },
onGripEnd: () => { onGripEnd: () => {
setDraft(null); endGripEditing();
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen },
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen // Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin`
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen // zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen // danach wie onGripEnd aufräumen.
setGripEdit(null); // (c) Feld-Controller schließen onGripCancel: (index: number, origin: Vec2) => {
applyGrip(index, origin);
endGripEditing();
}, },
}; };