2D: Tab in ein Griff-/Kanten-Feld friert die Maus ein, bis der erste Wert getippt ist

Nutzer-Wunsch: "wenn ich mit Tab in die Werte springe, soll die Maus
dann nicht mehr steuern, bis man den ersten Wert eingegeben hat -- das
kann man aber mit Esc lösen." Bisher folgte der Punkt/die Kante nach
Tab weiter der Maus, solange noch kein Wert gelockt war -- ein
Zittern der Hand beim Greifen zur Tastatur konnte die Position also
noch verändern, bevor überhaupt getippt wurde.

Neue gripFrozenPointRef/edgeFrozenPointRef (useGripEditing.ts): beim
ÖFFNEN des Feld-Controllers (erstes Tab, noch kein vorheriges Feld
offen) wird die AKTUELLE Position eingefroren; onGripMove/onEdgeMove
halten den Punkt/die Kante bei aktivem Feld-Controller OHNE Lock exakt
dort fest, unabhängig von der Mausposition. Der erste getippte Wert
(submitGripEditValue/submitEdgeEditValue) löst das Einfrieren -- ab da
gilt wieder die bisherige Locked/Live-Mischung (gelockte Felder fest,
ungelockte folgen der Maus).

Neue closeGripEditField/closeEdgeEditField-Funktionen lösen das
Einfrieren explizit auf und schließen NUR den Feld-Prompt (Punkt/Kante
bleibt bewaffnet, Maus steuert wieder normal) -- ersetzen in App.tsx
die bisherigen direkten setGripEdit(null)/setEdgeEdit(null)-Aufrufe im
CommandLine-onCancel (Escape MIT Fokus in der Befehlszeile). Das
bestehende, GLOBALE Escape (ohne Fokus in einem Eingabefeld) bleibt
unverändert: es verwirft den ganzen Griff-/Kanten-Drag komplett auf
die Ursprungsposition (PlanView.tsx, voriger Commit) -- zwei bewusst
verschiedene Eskalationsstufen.

tsc/vitest 922/922 grün.
This commit is contained in:
2026-08-22 00:16:29 +02:00
parent 0c69162ba6
commit c43573859f
2 changed files with 90 additions and 34 deletions
+4 -4
View File
@@ -1290,15 +1290,15 @@ export default function App() {
edgeGrips,
gripHandlers,
gripEdit,
setGripEdit,
gripEditFields,
cycleGripEditField,
submitGripEditValue,
closeGripEditField,
edgeEdit,
setEdgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
closeEdgeEditField,
gripDragInfoRef,
edgeDragInfoRef,
} = useGripEditing({
@@ -2886,9 +2886,9 @@ export default function App() {
}
onCancel={() =>
gripEdit
? setGripEdit(null)
? closeGripEditField()
: edgeEdit
? setEdgeEdit(null)
? closeEdgeEditField()
: activeTransform
? cancelTransform()
: engine.cancel()
+86 -30
View File
@@ -540,26 +540,36 @@ 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).
// Eingefrorene Zielposition, solange das Feld offen, aber noch KEIN Wert
// getippt ist (s. Kommentar an onGripMove). null = nicht eingefroren.
const gripFrozenPointRef = useRef<Vec2 | null>(null);
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Beim
// ÖFFNEN (noch kein gripEdit) friert die AKTUELLE Position ein — Nutzer-
// Wunsch: „wenn ich mit Tab in die Werte springe, soll die Maus nicht mehr
// steuern, bis man den ersten Wert eingegeben hat" (verhindert, dass ein
// Zittern der Hand beim Greifen zur Tastatur den Punkt noch verschiebt).
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
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
: { anchor, active: "length", locks: {} },
);
setGripEdit((prev) => {
if (prev) return { ...prev, active: prev.active === "length" ? "angle" : "length" };
gripFrozenPointRef.current = grips[info.index];
return { anchor, active: "length", locks: {} };
});
};
cycleGripEditFieldRef.current = cycleGripEditField;
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
// den Zielpunkt sofort mit dem gelockten Wert neu setzen.
// den Zielpunkt sofort mit dem gelockten Wert neu setzen. Der erste getippte
// 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 ge = gripEditRef.current;
const info = gripDragInfoRef.current;
if (!ge || !info) return;
gripFrozenPointRef.current = null;
const locks = { ...ge.locks, [ge.active]: value };
const next = ge.active === "length" ? "angle" : "length";
setGripEdit({ ...ge, locks, active: next });
@@ -568,6 +578,14 @@ export function useGripEditing(opts: {
applyGrip(info.index, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s.
// App.tsx) — löst NUR das Einfrieren/den Feld-Prompt, beendet NICHT den
// ganzen Griff-Drag (der bleibt bewaffnet, Maus steuert wieder normal).
const closeGripEditField = () => {
setGripEdit(null);
gripFrozenPointRef.current = null;
};
// Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten-
// Griff (Dreieck-Anfasser, „Seite parallel verschieben"). Ein Feld genügt für
// geführte Kanten (rect/geschlossene Polylinie/Wand — Bewegung ist auf die
@@ -640,19 +658,23 @@ export function useGripEditing(opts: {
};
const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
// Tab im Kanten-Drag: Feld-Controller öffnen; bei freien Kanten zykelt es
// zusätzlich Länge ↔ Winkel (geführte Kanten haben nur das eine Feld).
// Eingefrorene Zielposition, solange das Feld offen, aber noch KEIN Wert
// getippt ist — dasselbe Prinzip wie gripFrozenPointRef oben.
const edgeFrozenPointRef = useRef<Vec2 | null>(null);
// Tab im Kanten-Drag: Feld-Controller öffnen (friert dabei die aktuelle
// Position ein, s. o.); bei freien Kanten zykelt es zusätzlich Länge ↔
// Winkel (geführte Kanten haben nur das eine Feld).
const cycleEdgeEditField = () => {
const info = edgeDragInfoRef.current;
if (!info) return;
const { edge } = info;
setEdgeEdit((prev) =>
prev
? edge.free
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
: prev
: { anchor: edge.mid, active: "length", locks: {} },
);
const { edge, raw } = info;
setEdgeEdit((prev) => {
if (prev) {
return edge.free ? { ...prev, active: prev.active === "length" ? "angle" : "length" } : prev;
}
edgeFrozenPointRef.current = edgeEditPoint(edge, edge.mid, {}, raw);
return { anchor: edge.mid, active: "length", locks: {} };
});
};
cycleEdgeEditFieldRef.current = cycleEdgeEditField;
@@ -675,6 +697,7 @@ export function useGripEditing(opts: {
const ee = edgeEditRef.current;
const info = edgeDragInfoRef.current;
if (!ee || !info) return;
edgeFrozenPointRef.current = null;
const { edge, raw } = info;
const locks = { ...ee.locks, [ee.active]: value };
const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active;
@@ -683,6 +706,13 @@ export function useGripEditing(opts: {
applyEdgeToward(edge, ee.anchor, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — löst
// nur das Einfrieren/den Feld-Prompt, beendet NICHT den Kanten-Drag.
const closeEdgeEditField = () => {
setEdgeEdit(null);
edgeFrozenPointRef.current = null;
};
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
// auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
const applyGrip = (index: number, pt: Vec2) => {
@@ -777,8 +807,10 @@ export function useGripEditing(opts: {
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen
gripFrozenPointRef.current = null; // Einfrieren verwerfen
setGripEdit(null); // (c) Feld-Controller schließen
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
edgeFrozenPointRef.current = null; // Einfrieren verwerfen
setEdgeEdit(null); // Kanten-Feld-Controller schließen
};
@@ -788,15 +820,26 @@ export function useGripEditing(opts: {
// AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt
// verankern (s. Kommentar an gripOriginRef).
if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index];
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar
// aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor).
// (c) Vertex-Drag mit aktivem Feld-Controller: solange EIN Wert gelockt
// ist, gilt die polare Locked/Live-Mischung wie bisher. Ist das Feld
// offen, aber NOCH KEIN Wert getippt, hält `gripFrozenPointRef` den
// Punkt fest (Maus wirkungslos) — s. Kommentar an cycleGripEditField.
const ge = gripEditRef.current;
gripDragInfoRef.current = { index, raw };
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, hud: hudFor(index, pt) });
return;
if (ge) {
const hasLock = ge.locks.length != null || ge.locks.angle != null;
if (hasLock) {
const pt = gripEditPoint(ge.anchor, ge.locks, raw);
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
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
// das singuläre selectedDrawingId/selectedWallId) — von den eigenen
@@ -918,12 +961,23 @@ export function useGripEditing(opts: {
const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
const ee = edgeEditRef.current;
// Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert
// (Maus wirkt für das gelockte Feld nicht mehr — Lock dominiert).
if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
// (Maus wirkt für das gelockte Feld nicht mehr — Lock dominiert). Feld
// offen, aber noch kein Wert getippt: `edgeFrozenPointRef` hält den
// Punkt fest (Maus wirkungslos) — s. Kommentar an cycleEdgeEditField.
if (ee) {
const hasLock = ee.locks.length != null || ee.locks.angle != null;
if (hasLock) {
const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
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) {
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
@@ -1002,11 +1056,13 @@ export function useGripEditing(opts: {
gripEditFields,
cycleGripEditField,
submitGripEditValue,
closeGripEditField,
edgeEdit,
setEdgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
closeEdgeEditField,
// Für useToolNumberShortcuts: solange ein Punkt- ODER Kanten-Griff
// bewaffnet ist, dürfen Zifferntasten NICHT als Werkzeug-Wechsel
// interpretiert werden (auch nicht, wenn das Befehlsfeld fokussiert und