2D: nach dem ersten getippten Wert bleibt das andere Feld ebenfalls fix

Nutzer-Feedback zum vorigen Commit: das Einfrieren beim Tab-Öffnen
gefiel, aber sobald man einen Wert (z. B. Länge) eintippte und Enter
drückte, sprang der ANDERE Wert (Winkel) sofort wieder auf die Maus um
-- gewünscht war stattdessen: Enter fixiert BEIDE Werte (den getippten
UND den beim Öffnen eingefrorenen), und erst ein explizites Tab in das
andere Feld gibt die Maus für GENAU dieses Feld wieder frei.

Vereinfachung statt Zusatzmechanismus: `locks` wird beim ÖFFNEN (erstes
Tab) nicht mehr leer gelassen, sondern SOFORT mit beiden aktuellen
Werten (Länge+Winkel bzw. bei geführten Kanten nur der eine Abstands-
Wert) befüllt -- die ohnehin vorhandene Locked/Live-Mischung greift
dadurch von Anfang an vollständig, ein separater "eingefroren"-Zustand
(gripFrozenPointRef/edgeFrozenPointRef aus dem vorigen Commit) wird
dadurch überflüssig und wieder entfernt. Zykeln (weiteres Tab bei schon
offenem Feld) wechselt jetzt zum anderen Feld UND löst NUR dessen Lock
(delete locks[nextActive]) -- das verlassene Feld bleibt exakt auf
seinem letzten Wert (getippt oder eingefroren) fixiert. Getippte Werte
(submitGripEditValue/submitEdgeEditValue) sowie Escape-Verhalten
(global = ganzen Drag verwerfen, in der Befehlszeile = nur Feld
schließen) bleiben unverändert.

tsc/vitest 922/922 grün.
This commit is contained in:
2026-08-22 00:24:51 +02:00
parent c43573859f
commit c5c60d3df5
+59 -63
View File
@@ -540,36 +540,45 @@ export function useGripEditing(opts: {
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps). // Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps).
const cycleGripEditFieldRef = useRef<() => void>(() => {}); const cycleGripEditFieldRef = useRef<() => void>(() => {});
// Eingefrorene Zielposition, solange das Feld offen, aber noch KEIN Wert // Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle).
// getippt ist (s. Kommentar an onGripMove). null = nicht eingefroren. // • ÖFFNEN (noch kein gripEdit): friert BEIDE Werte an der aktuellen
const gripFrozenPointRef = useRef<Vec2 | null>(null); // Position ein (`locks` sofort vollständig befüllt statt leer) — die
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Beim // Maus hat ab jetzt keinen Einfluss mehr, bis man explizit wieder in ein
// ÖFFNEN (noch kein gripEdit) friert die AKTUELLE Position ein — Nutzer- // Feld tabbt (verhindert, dass ein Zittern der Hand beim Greifen zur
// Wunsch: „wenn ich mit Tab in die Werte springe, soll die Maus nicht mehr // Tastatur den Punkt noch verschiebt).
// steuern, bis man den ersten Wert eingegeben hat" (verhindert, dass ein // • ZYKELN (gripEdit schon offen): wechselt zum anderen Feld und LÖST
// Zittern der Hand beim Greifen zur Tastatur den Punkt noch verschiebt). // dessen Lock (frei für die Maus) — das Feld, das man verlässt, bleibt
// exakt auf seinem letzten Wert fixiert (getippt oder eingefroren).
// Nutzer-Wunsch: nach dem ersten getippten Wert soll das ANDERE Feld
// nicht sofort wieder der Maus folgen, sondern erst nach explizitem Tab
// dorthin — sonst „wird der Winkel nie richtig fix".
const cycleGripEditField = () => { const cycleGripEditField = () => {
const info = gripDragInfoRef.current; const info = gripDragInfoRef.current;
if (!info) return; if (!info) return;
const anchor = resolveGripAnchor(info.index); 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) => {
if (prev) return { ...prev, active: prev.active === "length" ? "angle" : "length" }; if (!prev) {
gripFrozenPointRef.current = grips[info.index]; const frozen = grips[info.index];
return { anchor, active: "length", locks: {} }; const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
}
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
}); });
}; };
cycleGripEditFieldRef.current = cycleGripEditField; cycleGripEditFieldRef.current = cycleGripEditField;
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und // Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken
// den Zielpunkt sofort mit dem gelockten Wert neu setzen. Der erste getippte // (das andere Feld bleibt unverändert fixiert, s. o.).
// Wert löst das Einfrieren (s. o.) — ab jetzt gilt wieder die normale
// Locked/Live-Mischung (gelockte Felder fest, ungelockte folgen der Maus).
const submitGripEditValue = (value: number) => { const submitGripEditValue = (value: number) => {
const ge = gripEditRef.current; const ge = gripEditRef.current;
const info = gripDragInfoRef.current; const info = gripDragInfoRef.current;
if (!ge || !info) return; if (!ge || !info) return;
gripFrozenPointRef.current = null;
const locks = { ...ge.locks, [ge.active]: value }; const locks = { ...ge.locks, [ge.active]: value };
const next = ge.active === "length" ? "angle" : "length"; const next = ge.active === "length" ? "angle" : "length";
setGripEdit({ ...ge, locks, active: next }); setGripEdit({ ...ge, locks, active: next });
@@ -579,11 +588,10 @@ export function useGripEditing(opts: {
}; };
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s. // Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s.
// App.tsx) — löst NUR das Einfrieren/den Feld-Prompt, beendet NICHT den // App.tsx) — beendet NICHT den ganzen Griff-Drag (der bleibt bewaffnet,
// ganzen Griff-Drag (der bleibt bewaffnet, Maus steuert wieder normal). // Maus steuert wieder normal wie vor dem ersten Tab).
const closeGripEditField = () => { const closeGripEditField = () => {
setGripEdit(null); setGripEdit(null);
gripFrozenPointRef.current = null;
}; };
// Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten- // Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten-
@@ -658,22 +666,33 @@ export function useGripEditing(opts: {
}; };
const cycleEdgeEditFieldRef = useRef<() => void>(() => {}); const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
// Eingefrorene Zielposition, solange das Feld offen, aber noch KEIN Wert // Tab im Kanten-Drag: Feld-Controller öffnen/zyklen — dasselbe Muster wie
// getippt ist — dasselbe Prinzip wie gripFrozenPointRef oben. // cycleGripEditField oben (Öffnen friert BEIDE Werte ein, Zykeln löst NUR
const edgeFrozenPointRef = useRef<Vec2 | null>(null); // das neu aktive Feld). Geführte Kanten haben nur ein Feld ("Länge" =
// Tab im Kanten-Drag: Feld-Controller öffnen (friert dabei die aktuelle // Abstand entlang der Normale) → Zykeln tut dort nichts, das eine Feld
// Position ein, s. o.); bei freien Kanten zykelt es zusätzlich Länge ↔ // bleibt beim Öffnen ohnehin schon eingefroren/fixiert.
// Winkel (geführte Kanten haben nur das eine Feld).
const cycleEdgeEditField = () => { const cycleEdgeEditField = () => {
const info = edgeDragInfoRef.current; const info = edgeDragInfoRef.current;
if (!info) return; if (!info) return;
const { edge, raw } = info; const { edge, raw } = info;
setEdgeEdit((prev) => { setEdgeEdit((prev) => {
if (prev) { if (!prev) {
return edge.free ? { ...prev, active: prev.active === "length" ? "angle" : "length" } : prev; const anchor = edge.mid;
const frozen = edgeEditPoint(edge, anchor, {}, raw);
const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
if (edge.free) {
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
} }
edgeFrozenPointRef.current = edgeEditPoint(edge, edge.mid, {}, raw); const offset = dx * edge.normal.x + dy * edge.normal.y;
return { anchor: edge.mid, active: "length", locks: {} }; return { anchor, active: "length", locks: { length: offset } };
}
if (!edge.free) return prev; // geführte Kante: nur ein Feld, nichts zu zykeln
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
}); });
}; };
cycleEdgeEditFieldRef.current = cycleEdgeEditField; cycleEdgeEditFieldRef.current = cycleEdgeEditField;
@@ -697,7 +716,6 @@ export function useGripEditing(opts: {
const ee = edgeEditRef.current; const ee = edgeEditRef.current;
const info = edgeDragInfoRef.current; const info = edgeDragInfoRef.current;
if (!ee || !info) return; if (!ee || !info) return;
edgeFrozenPointRef.current = null;
const { edge, raw } = info; const { edge, raw } = info;
const locks = { ...ee.locks, [ee.active]: value }; const locks = { ...ee.locks, [ee.active]: value };
const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active; const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active;
@@ -706,11 +724,10 @@ export function useGripEditing(opts: {
applyEdgeToward(edge, ee.anchor, pt); applyEdgeToward(edge, ee.anchor, pt);
}; };
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — löst // Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — beendet
// nur das Einfrieren/den Feld-Prompt, beendet NICHT den Kanten-Drag. // NICHT den Kanten-Drag (der bleibt bewaffnet, Maus steuert wieder normal).
const closeEdgeEditField = () => { const closeEdgeEditField = () => {
setEdgeEdit(null); setEdgeEdit(null);
edgeFrozenPointRef.current = null;
}; };
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst // Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
@@ -807,10 +824,8 @@ export function useGripEditing(opts: {
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen
gripFrozenPointRef.current = null; // Einfrieren verwerfen
setGripEdit(null); // (c) Feld-Controller schließen setGripEdit(null); // (c) Feld-Controller schließen
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
edgeFrozenPointRef.current = null; // Einfrieren verwerfen
setEdgeEdit(null); // Kanten-Feld-Controller schließen setEdgeEdit(null); // Kanten-Feld-Controller schließen
}; };
@@ -820,27 +835,18 @@ export function useGripEditing(opts: {
// AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt // AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt
// verankern (s. Kommentar an gripOriginRef). // verankern (s. Kommentar an gripOriginRef).
if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index]; if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index];
// (c) Vertex-Drag mit aktivem Feld-Controller: solange EIN Wert gelockt // (c) Vertex-Drag mit aktivem Feld-Controller: `locks` ist ab dem
// ist, gilt die polare Locked/Live-Mischung wie bisher. Ist das Feld // ÖFFNEN (Tab) immer für BEIDE Felder befüllt (s. cycleGripEditField),
// offen, aber NOCH KEIN Wert getippt, hält `gripFrozenPointRef` den // die Maus wirkt also erst wieder, sobald ein Feld per Tab explizit
// Punkt fest (Maus wirkungslos) — s. Kommentar an cycleGripEditField. // gelöst wurde — kein separater „eingefroren"-Zustand mehr nötig.
const ge = gripEditRef.current; const ge = gripEditRef.current;
gripDragInfoRef.current = { index, raw }; gripDragInfoRef.current = { index, raw };
if (ge) { if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
const hasLock = ge.locks.length != null || ge.locks.angle != null;
if (hasLock) {
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, hud: hudFor(index, pt) }); setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return; return;
} }
if (gripFrozenPointRef.current) {
const pt = gripFrozenPointRef.current;
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return;
}
}
// Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend // Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend
// das singuläre selectedDrawingId/selectedWallId) — von den eigenen // das singuläre selectedDrawingId/selectedWallId) — von den eigenen
// Snap-Kandidaten ausschließen (sonst fängt der Punkt an sich selbst). // Snap-Kandidaten ausschließen (sonst fängt der Punkt an sich selbst).
@@ -960,25 +966,15 @@ export function useGripEditing(opts: {
edgeDragInfoRef.current = { edge, raw }; edgeDragInfoRef.current = { edge, raw };
const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
const ee = edgeEditRef.current; const ee = edgeEditRef.current;
// Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert // Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert.
// (Maus wirkt für das gelockte Feld nicht mehr — Lock dominiert). Feld // `locks` ist ab dem Öffnen (Tab) immer befüllt (s. cycleEdgeEditField),
// offen, aber noch kein Wert getippt: `edgeFrozenPointRef` hält den // die Maus wirkt also erst wieder, sobald ein Feld per Tab gelöst wurde.
// Punkt fest (Maus wirkungslos) — s. Kommentar an cycleEdgeEditField. if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
if (ee) {
const hasLock = ee.locks.length != null || ee.locks.angle != null;
if (hasLock) {
const pt = edgeEditPoint(edge, anchor, ee.locks, raw); const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
applyEdgeToward(edge, anchor, pt); applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) }); setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return; return;
} }
if (edgeFrozenPointRef.current) {
const pt = edgeFrozenPointRef.current;
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
}
}
if (edge.free) { if (edge.free) {
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die // Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
// sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den // sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den