2D: Hover-Teilpunkte auf Linien (konfigurierbare Verzögerung + Anzahl)
Neues Feature (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand (kein Werkzeug/Drag) länger als eine einstellbare Zeit (Default 300ms) auf einem 2D-Linien-Segment, erscheinen dessen gleiche Teilpunkte kurz mit Akzentfarbe -- bei 2 Teilen (Default) nur der Mittelpunkt, bei 3/4/… entsprechend mehr. Reine Sichtbarkeits-Hilfe, kein neues Snap-Ziel. Verzögerung (ms) und Teile-Anzahl sind unten im Footer einstellbar, im bestehenden Fang-Popover (StatusBar.tsx SnapControl) als zwei weitere Zahlenfelder -- neue Felder hoverDivideDelayMs/hoverDivideCount auf SnapSettings (tools/types.ts), NICHT an snap.enabled gekoppelt (eigenes, vom Fang unabhängiges visuelles Feature). PlanView.tsx: neue nearestLineSegment()-Suche (dieselbe Bildschirm-Nähe- Logik wie pickDrawing, liefert aber die zwei Endpunkte des konkret gehoverten Segments einer Polylinie, nicht nur die drawingId). Ein Timer verankert/verwirft die Anzeige bei Segment-Wechsel; die beiden neuen Props (hoverDivideDelayMs/hoverDivideCount) werden wie snapColor durch viewportContent.tsx bis zu PlanView durchgereicht. tsc/vitest 922/922 grün. Manuell in der App zu prüfen (Pointer-Hover ist nicht automatisiert testbar, wie die übrigen Griff-Interaktions- Fixes dieser Session).
This commit is contained in:
@@ -2739,6 +2739,8 @@ export default function App() {
|
||||
hairline={lineMode === "display"}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={snap.hoverDivideDelayMs}
|
||||
hoverDivideCount={snap.hoverDivideCount}
|
||||
renderMode={renderMode}
|
||||
view3d={view3d}
|
||||
fov={fov}
|
||||
|
||||
@@ -241,6 +241,8 @@ export const de = {
|
||||
"snap.angleStep": "Winkelraster",
|
||||
"snap.commonAngles": "Gängige Winkel",
|
||||
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
|
||||
"snap.hoverDivideDelayMs": "Hover-Verzögerung (ms)",
|
||||
"snap.hoverDivideCount": "Teilpunkte (Anzahl)",
|
||||
|
||||
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
||||
"transform.bar": "Transformation",
|
||||
|
||||
@@ -239,6 +239,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"snap.angleStep": "Angle step",
|
||||
"snap.commonAngles": "Common angles",
|
||||
"snap.commonAngleTolerance": "Angle tolerance (°)",
|
||||
"snap.hoverDivideDelayMs": "Hover delay (ms)",
|
||||
"snap.hoverDivideCount": "Division points (count)",
|
||||
|
||||
// Transform (move/mirror/rotate) + mode bar.
|
||||
"transform.bar": "Transform",
|
||||
|
||||
+118
-2
@@ -410,6 +410,19 @@ export interface PlanViewProps {
|
||||
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
|
||||
*/
|
||||
snapColor?: string;
|
||||
/**
|
||||
* Ruht der Cursor (kein Werkzeug/Drag aktiv) länger als diese Zeit (ms) auf
|
||||
* einer 2D-Linie, erscheinen ihre Teilpunkte (s. `hoverDivideCount`) kurz
|
||||
* mit Akzentfarbe — reine Sichtbarkeits-Hilfe, kein Snap-Ziel. Einstellbar
|
||||
* im Footer (`SnapSettings.hoverDivideDelayMs`); Default 300 ms.
|
||||
*/
|
||||
hoverDivideDelayMs?: number;
|
||||
/**
|
||||
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
|
||||
* (2 = nur der Mittelpunkt). Einstellbar im Footer
|
||||
* (`SnapSettings.hoverDivideCount`); Default 2.
|
||||
*/
|
||||
hoverDivideCount?: number;
|
||||
}
|
||||
|
||||
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
|
||||
@@ -493,6 +506,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
gripHandlers,
|
||||
marqueeColor = DEFAULT_MARQUEE_COLOR,
|
||||
snapColor = DEFAULT_SNAP_COLOR,
|
||||
hoverDivideDelayMs = 300,
|
||||
hoverDivideCount = 2,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -813,6 +828,50 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
crossing: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Hover-Teilpunkte (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand
|
||||
// (kein Werkzeug/Drag/Marquee — s. Aufruf in onPointerMove) länger als
|
||||
// `hoverDivideDelayMs` auf einem 2D-Linien-Segment, werden dessen gleiche
|
||||
// Teilpunkte (Anzahl `hoverDivideCount`, 2 = nur Mitte) kurz mit
|
||||
// Akzentfarbe eingeblendet. Reine Sichtbarkeits-Hilfe, kein Snap-Ziel.
|
||||
const [hoverDivideSeg, setHoverDivideSeg] = useState<{ a: Vec2; b: Vec2 } | null>(null);
|
||||
// Zuletzt erkanntes Segment (Serialisiert als Schlüssel) — erkennt einen
|
||||
// Wechsel, ohne bei jedem Move-Event neu zu timern.
|
||||
const hoverDivideKeyRef = useRef<string | null>(null);
|
||||
const hoverDivideTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Nächstliegendes Linien-Segment unterm Cursor ermitteln und die
|
||||
// Hover-Teilpunkte-Anzeige entsprechend (neu) timern/verwerfen.
|
||||
const updateHoverDivide = (clientX: number, clientY: number) => {
|
||||
const seg = nearestLineSegment(clientX, clientY);
|
||||
const key = seg ? `${seg.a.x},${seg.a.y}|${seg.b.x},${seg.b.y}` : null;
|
||||
if (key === hoverDivideKeyRef.current) return; // dasselbe Segment (oder weiter keins)
|
||||
hoverDivideKeyRef.current = key;
|
||||
if (hoverDivideTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverDivideTimerRef.current);
|
||||
hoverDivideTimerRef.current = null;
|
||||
}
|
||||
setHoverDivideSeg(null); // Segment gewechselt → alte Anzeige sofort weg
|
||||
if (!seg) return;
|
||||
hoverDivideTimerRef.current = window.setTimeout(() => {
|
||||
hoverDivideTimerRef.current = null;
|
||||
setHoverDivideSeg(seg);
|
||||
}, hoverDivideDelayMs);
|
||||
};
|
||||
const clearHoverDivide = () => {
|
||||
if (hoverDivideTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverDivideTimerRef.current);
|
||||
hoverDivideTimerRef.current = null;
|
||||
}
|
||||
hoverDivideKeyRef.current = null;
|
||||
setHoverDivideSeg(null);
|
||||
};
|
||||
// Weg, sobald ein Werkzeug aktiv wird (sonst könnte eine veraltete Anzeige
|
||||
// ohne weiteres Move-Event über einem neuen Werkzeug-Entwurf stehen bleiben).
|
||||
useEffect(() => {
|
||||
if (toolActive) clearHoverDivide();
|
||||
}, [toolActive]);
|
||||
useEffect(() => () => clearHoverDivide(), []);
|
||||
|
||||
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
|
||||
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
|
||||
// Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung
|
||||
@@ -1067,6 +1126,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
|
||||
};
|
||||
|
||||
// Nächstgelegenes EINZELNES Geraden-Segment (nicht das ganze Element) für
|
||||
// die Hover-Teilpunkte-Anzeige (s. hoverDivideSeg unten) — dieselbe Nähe-
|
||||
// Logik wie pickDrawing, liefert aber die zwei Endpunkte des getroffenen
|
||||
// Segments selbst: eine Polylinie besteht aus mehreren "line"-Primitiven
|
||||
// (eins je Segment, gleiche drawingId), unterteilt werden soll nur das
|
||||
// konkret gehoverte Segment, nicht das ganze Element.
|
||||
const nearestLineSegment = (clientX: number, clientY: number): { a: Vec2; b: Vec2 } | null => {
|
||||
let best: { a: Vec2; b: Vec2 } | null = null;
|
||||
let bestDist = LINE_PICK_PX;
|
||||
for (const p of plan.primitives) {
|
||||
if (p.kind !== "line" || !p.drawingId) continue;
|
||||
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
|
||||
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
|
||||
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = { a: p.a, b: p.b };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
|
||||
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
|
||||
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
|
||||
@@ -1615,7 +1696,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
}
|
||||
|
||||
const d = drag.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
if (!d || d.pointerId !== e.pointerId) {
|
||||
// Echter Ruhezustand (kein Drag/Werkzeug/Marquee/Pan) — Hover-
|
||||
// Teilpunkte-Erkennung.
|
||||
updateHoverDivide(e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const { scale } = measure(d.startView);
|
||||
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
|
||||
// verschiebt sich der Ausschnitt entgegengesetzt.
|
||||
@@ -2224,7 +2310,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }}
|
||||
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); clearHoverDivide(); }}
|
||||
onWheel={onWheel}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenuHandler}
|
||||
@@ -2577,6 +2663,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
hudFields={hudFields ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* Hover-Teilpunkte (s. hoverDivideSeg oben): kleine Punkte in Akzentfarbe
|
||||
an den `hoverDivideCount`-gleichen Teilpunkten des gehoverten Segments
|
||||
(2 = nur Mitte). Nur im Ruhezustand (kein Werkzeug aktiv). */}
|
||||
{hoverDivideSeg && !toolActive && (() => {
|
||||
const vbPerPx = 1 / (meetScale(view, svgRef.current) ?? 1);
|
||||
const r = 3.5 * vbPerPx;
|
||||
const n = Math.max(2, Math.round(hoverDivideCount));
|
||||
const { a, b } = hoverDivideSeg;
|
||||
const pts = Array.from({ length: n - 1 }, (_, i) => {
|
||||
const t = (i + 1) / n;
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
});
|
||||
return (
|
||||
<g className="plan-hover-divide" pointerEvents="none">
|
||||
{pts.map((pt, i) => {
|
||||
const s = toScreen(pt);
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={s.x}
|
||||
cy={s.y}
|
||||
r={r}
|
||||
style={{ fill: snapColor, stroke: snapColor }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
|
||||
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
|
||||
|
||||
@@ -71,6 +71,18 @@ export interface SnapSettings {
|
||||
commonAngles: boolean;
|
||||
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */
|
||||
commonAngleTolerance: number;
|
||||
/**
|
||||
* Verweilt der Cursor länger als diese Zeit (ms) ruhig auf einer Linie
|
||||
* (2D-Element, kein aktiver Zug/Werkzeug), werden ihre Teilpunkte (s.
|
||||
* `hoverDivideCount`) kurz mit Akzentfarbe eingeblendet — reine
|
||||
* Sichtbarkeits-Hilfe, kein zusätzliches Snap-Ziel.
|
||||
*/
|
||||
hoverDivideDelayMs: number;
|
||||
/**
|
||||
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
|
||||
* (2 = nur der Mittelpunkt, 3 = zwei Drittelpunkte, usw.).
|
||||
*/
|
||||
hoverDivideCount: number;
|
||||
}
|
||||
|
||||
/** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
|
||||
@@ -88,6 +100,8 @@ export const DEFAULT_SNAP: SnapSettings = {
|
||||
tolerancePx: 12,
|
||||
commonAngles: true,
|
||||
commonAngleTolerance: 2.5,
|
||||
hoverDivideDelayMs: 300,
|
||||
hoverDivideCount: 2,
|
||||
};
|
||||
|
||||
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
|
||||
|
||||
@@ -245,6 +245,35 @@ function SnapControl({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{/* Hover-Teilpunkte (rein visuell, unabhängig vom Fang selbst — daher
|
||||
NICHT an snap.enabled gekoppelt): Ruht der Cursor auf einer Linie,
|
||||
erscheinen nach der Verzögerung ihre Teilpunkte mit Akzentfarbe. */}
|
||||
<label className="sb-snap-row sb-snap-row-num">
|
||||
<span>{t("snap.hoverDivideDelayMs")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step={50}
|
||||
min={0}
|
||||
max={3000}
|
||||
value={snap.hoverDivideDelayMs}
|
||||
onChange={(e) =>
|
||||
onSnapChange({ ...snap, hoverDivideDelayMs: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="sb-snap-row sb-snap-row-num">
|
||||
<span>{t("snap.hoverDivideCount")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step={1}
|
||||
min={2}
|
||||
max={12}
|
||||
value={snap.hoverDivideCount}
|
||||
onChange={(e) =>
|
||||
onSnapChange({ ...snap, hoverDivideCount: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -62,6 +62,8 @@ export function Content({
|
||||
hairline,
|
||||
marqueeColor,
|
||||
snapColor,
|
||||
hoverDivideDelayMs,
|
||||
hoverDivideCount,
|
||||
renderMode,
|
||||
view3d,
|
||||
fov,
|
||||
@@ -141,6 +143,8 @@ export function Content({
|
||||
marqueeColor: string;
|
||||
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
|
||||
snapColor: string;
|
||||
hoverDivideDelayMs?: number;
|
||||
hoverDivideCount?: number;
|
||||
renderMode: RenderMode;
|
||||
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
|
||||
view3d: View3d;
|
||||
@@ -291,6 +295,8 @@ export function Content({
|
||||
hairline={hairline}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||
hoverDivideCount={hoverDivideCount}
|
||||
mono={mono}
|
||||
planViewRef={planViewRef}
|
||||
onCursor={onCursor}
|
||||
@@ -353,6 +359,8 @@ export function Content({
|
||||
hairline={hairline}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||
hoverDivideCount={hoverDivideCount}
|
||||
mono={mono}
|
||||
planViewRef={planViewRef}
|
||||
onCursor={onCursor}
|
||||
@@ -458,6 +466,8 @@ export function Content({
|
||||
hairline={hairline}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||
hoverDivideCount={hoverDivideCount}
|
||||
mono={mono}
|
||||
planViewRef={planViewRef}
|
||||
onCursor={onCursor}
|
||||
@@ -511,6 +521,8 @@ function LevelPlanView({
|
||||
hairline,
|
||||
marqueeColor,
|
||||
snapColor,
|
||||
hoverDivideDelayMs,
|
||||
hoverDivideCount,
|
||||
mono,
|
||||
planViewRef,
|
||||
onCursor,
|
||||
@@ -557,6 +569,8 @@ function LevelPlanView({
|
||||
hairline: boolean;
|
||||
marqueeColor: string;
|
||||
snapColor: string;
|
||||
hoverDivideDelayMs?: number;
|
||||
hoverDivideCount?: number;
|
||||
mono: boolean;
|
||||
planViewRef: React.RefObject<PlanViewHandle>;
|
||||
onCursor: (model: Vec2 | null) => void;
|
||||
@@ -678,6 +692,8 @@ function LevelPlanView({
|
||||
hairline={hairline}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||
hoverDivideCount={hoverDivideCount}
|
||||
selectedDrawingId={selectedDrawingId}
|
||||
selectedDrawingIds={selectedDrawingIds}
|
||||
gripHandlers={gripHandlers}
|
||||
@@ -703,6 +719,8 @@ function SectionPlanView({
|
||||
hairline,
|
||||
marqueeColor,
|
||||
snapColor,
|
||||
hoverDivideDelayMs,
|
||||
hoverDivideCount,
|
||||
mono,
|
||||
planViewRef,
|
||||
onCursor,
|
||||
@@ -744,6 +762,8 @@ function SectionPlanView({
|
||||
hairline: boolean;
|
||||
marqueeColor: string;
|
||||
snapColor: string;
|
||||
hoverDivideDelayMs?: number;
|
||||
hoverDivideCount?: number;
|
||||
mono: boolean;
|
||||
planViewRef: React.RefObject<PlanViewHandle>;
|
||||
onCursor: (model: Vec2 | null) => void;
|
||||
@@ -890,6 +910,8 @@ function SectionPlanView({
|
||||
hairline={hairline}
|
||||
marqueeColor={marqueeColor}
|
||||
snapColor={snapColor}
|
||||
hoverDivideDelayMs={hoverDivideDelayMs}
|
||||
hoverDivideCount={hoverDivideCount}
|
||||
selectedDrawingId={selectedDrawingId}
|
||||
selectedDrawingIds={selectedDrawingIds}
|
||||
gripHandlers={gripHandlers}
|
||||
|
||||
Reference in New Issue
Block a user