From 031c7f97ff0914aba708b30a3d51cc9c2f17ab71 Mon Sep 17 00:00:00 2001
From: monkey-w1n5t0n
Date: Mon, 29 Jun 2026 00:14:49 +0200
Subject: [PATCH 1/3] fix(manifold): keep input knob inside the circular area
for mouse + gamepad
The on-screen circular input disc let both the mouse and the gamepad drive
the knob outside the visible circle.
- Gamepad: each stick axis was clamped to [0,1] independently, so a full
diagonal push reached the square corner. Clamp the stick *vector* to the
unit disc (radially symmetric) before mapping to [0,1]; covers both sticks.
- Mouse: the circular variant clamped in normalised [0,1]^2 but the canvas
drew the knob across the full non-square panel against the inscribed circle,
so the disc rendered as an ellipse that spilled past the rim. Map both the
pointer and every drawn position (knob/pins/markers/trail/flash) through the
inscribed-circle geometry, and disc-clamp the auto-drift.
---
manifold/src/console/Manifold.tsx | 63 +++++++++++++++++++--------
manifold/src/inputs/gamepad-source.ts | 44 ++++++++++++++-----
2 files changed, 76 insertions(+), 31 deletions(-)
diff --git a/manifold/src/console/Manifold.tsx b/manifold/src/console/Manifold.tsx
index 56e240b..528222c 100644
--- a/manifold/src/console/Manifold.tsx
+++ b/manifold/src/console/Manifold.tsx
@@ -72,18 +72,25 @@ export function Manifold({
const el = wrapRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
- let x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
- let y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
if (stateRef.current.variant === 'circular') {
- // Clamp to the unit disc centred at (0.5, 0.5).
- const dx = x - 0.5;
- const dy = y - 0.5;
- const d = Math.hypot(dx, dy);
- if (d > 0.5) {
- x = 0.5 + (dx / d) * 0.5;
- y = 0.5 + (dy / d) * 0.5;
+ // Map the pointer relative to the *inscribed circle* the canvas draws (see
+ // `drawRadius` below): a unit-disc vector around the centre, so the knob
+ // tracks the cursor inside the disc and snaps to the rim outside it. This
+ // keeps the reachable area a true circle on a non-square surface (where a
+ // [0,1]² clamp would render as an ellipse spilling past the drawn rim).
+ const radius = Math.min(r.width, r.height) / 2 - 2;
+ if (radius <= 0) return [0.5, 0.5];
+ let vx = (e.clientX - r.left - r.width / 2) / radius;
+ let vy = (r.height / 2 - (e.clientY - r.top)) / radius; // screen y is down; flip so up = +
+ const mag = Math.hypot(vx, vy);
+ if (mag > 1) {
+ vx /= mag;
+ vy /= mag;
}
+ return [0.5 + 0.5 * vx, 0.5 + 0.5 * vy];
}
+ const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
+ const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
return [x, y];
};
@@ -170,6 +177,13 @@ export function Manifold({
const cx = W / 2;
const cy = H / 2;
const radius = Math.min(W, H) / 2 - 2;
+ // Map a normalised [0,1] coord to a screen pixel. The rectangular variant
+ // spreads [0,1]² across the full surface; the circular variant maps the
+ // central unit disc onto the inscribed circle so the [0,1]² clamp lines up
+ // exactly with the drawn rim (and stays a true circle when W ≠ H). x and y
+ // map independently (the circular transform is separable).
+ const sx = circular ? (nx: number) => cx + (nx - 0.5) * 2 * radius : (nx: number) => nx * W;
+ const sy = circular ? (ny: number) => cy - (ny - 0.5) * 2 * radius : (ny: number) => (1 - ny) * H;
if (fl && !draggingRef.current && !fz) {
let [x, y] = p;
@@ -180,6 +194,17 @@ export function Manifold({
if (y < 0.05 || y > 0.95) d.vy *= -1;
x = Math.max(0.05, Math.min(0.95, x));
y = Math.max(0.05, Math.min(0.95, y));
+ if (circular) {
+ // Keep the auto-drift inside the disc too, so it never wanders past
+ // the drawn rim into the corners.
+ const dx = x - 0.5;
+ const dy = y - 0.5;
+ const dd = Math.hypot(dx, dy);
+ if (dd > 0.5) {
+ x = 0.5 + (dx / dd) * 0.5;
+ y = 0.5 + (dy / dd) * 0.5;
+ }
+ }
onMove(x, y);
}
@@ -228,12 +253,12 @@ export function Manifold({
}
}
- const px = p[0] * W;
- const py = (1 - p[1]) * H;
+ const px = sx(p[0]);
+ const py = sy(p[1]);
for (const pin of pn) {
- const ppx = pin.x * W;
- const ppy = (1 - pin.y) * H;
+ const ppx = sx(pin.x);
+ const ppy = sy(pin.y);
ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)';
ctx.beginPath();
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
@@ -248,8 +273,8 @@ export function Manifold({
// Feedback markers: positive = filled accent dot, negative = open red
// ring. Plotted at the input location each verdict was given (session).
for (const m of mk) {
- const mx = m.x * W;
- const my = (1 - m.y) * H;
+ const mx = sx(m.x);
+ const my = sy(m.y);
if (m.polarity === 'positive') {
ctx.fillStyle = 'rgba(255,106,0,0.9)';
ctx.beginPath();
@@ -280,8 +305,8 @@ export function Manifold({
const alpha = (1 - age / LIFE) * 0.5;
ctx.strokeStyle = `rgba(0,204,255,${alpha})`;
ctx.beginPath();
- ctx.moveTo(a.x * W, (1 - a.y) * H);
- ctx.lineTo(b.x * W, (1 - b.y) * H);
+ ctx.moveTo(sx(a.x), sy(a.y));
+ ctx.lineTo(sx(b.x), sy(b.y));
ctx.stroke();
}
@@ -320,8 +345,8 @@ export function Manifold({
placedRef.current = null;
} else {
const a = 1 - age / 900;
- const mx = placed.x * W;
- const my = (1 - placed.y) * H;
+ const mx = sx(placed.x);
+ const my = sy(placed.y);
ctx.strokeStyle = `rgba(0,204,255,${a})`;
ctx.lineWidth = 2;
ctx.beginPath();
diff --git a/manifold/src/inputs/gamepad-source.ts b/manifold/src/inputs/gamepad-source.ts
index 65bca26..12c5d3d 100644
--- a/manifold/src/inputs/gamepad-source.ts
+++ b/manifold/src/inputs/gamepad-source.ts
@@ -117,12 +117,17 @@ export class GamepadSource extends BaseSource {
for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent
return n;
}
- // Left stick = axes 0,1; right stick = axes 2,3 (standard mapping).
- out[offset] = remap(pad.axes[0] ?? 0);
- out[offset + 1] = remap(-(pad.axes[1] ?? 0)); // flip: up = 1
+ // Left stick = axes 0,1; right stick = axes 2,3 (standard mapping). Each
+ // stick is clamped to the unit disc (not per-axis), so a full diagonal push
+ // lands ON the circular boundary rather than the square corner — matching
+ // the on-screen circular input area and the engine's own circular clamp.
+ const [lx, ly] = clampStick(pad.axes[0] ?? 0, -(pad.axes[1] ?? 0)); // flip: up = 1
+ out[offset] = lx;
+ out[offset + 1] = ly;
if (n === 4) {
- out[offset + 2] = remap(pad.axes[2] ?? 0);
- out[offset + 3] = remap(-(pad.axes[3] ?? 0));
+ const [rx, ry] = clampStick(pad.axes[2] ?? 0, -(pad.axes[3] ?? 0));
+ out[offset + 2] = rx;
+ out[offset + 3] = ry;
}
return n;
}
@@ -186,11 +191,26 @@ export class GamepadSource extends BaseSource {
}
}
-/** Map a [-1,1] stick axis (with radial deadzone) to [0,1]. */
-function remap(v: number): number {
- let x = v;
- if (x > -DEADZONE && x < DEADZONE) x = 0;
- else x = x > 0 ? (x - DEADZONE) / (1 - DEADZONE) : (x + DEADZONE) / (1 - DEADZONE);
- const out = (x + 1) / 2;
- return out < 0 ? 0 : out > 1 ? 1 : out;
+/** Apply the per-axis deadzone, returning a signed value in [-1,1]. */
+function deadzoneAxis(v: number): number {
+ if (v > -DEADZONE && v < DEADZONE) return 0;
+ const x = v > 0 ? (v - DEADZONE) / (1 - DEADZONE) : (v + DEADZONE) / (1 - DEADZONE);
+ return x < -1 ? -1 : x > 1 ? 1 : x;
+}
+
+/**
+ * Map a raw stick (rawX, rawY with y already flipped so up = +) to two [0,1]
+ * axes, clamping the stick *vector* to the unit disc first. This keeps full
+ * deflection on the circular boundary in every direction (radially symmetric),
+ * instead of letting a diagonal reach the square corner.
+ */
+function clampStick(rawX: number, rawY: number): [number, number] {
+ let x = deadzoneAxis(rawX);
+ let y = deadzoneAxis(rawY);
+ const mag = Math.hypot(x, y);
+ if (mag > 1) {
+ x /= mag;
+ y /= mag;
+ }
+ return [0.5 + 0.5 * x, 0.5 + 0.5 * y];
}
From d2b0427ec6f9b32cc03fe9edc452cfa40ded1f4f Mon Sep 17 00:00:00 2001
From: monkey-w1n5t0n
Date: Mon, 29 Jun 2026 00:36:32 +0200
Subject: [PATCH 2/3] feat(manifold): default to geometric push + add Clear
examples button
Geometric-dislike ('Push away', Mode 1) is now the default feedback mode
instead of explore-and-place, which works poorly. Add a Clear button to
the Learning drawer that forgets all recorded examples and wipes the
on-map visuals (feedback markers + placed-anchor pins) via the existing
ctx.onClear, now also resetting pins.
---
manifold/ONBOARDING.md | 7 ++++---
manifold/src/console/ConsoleApp.tsx | 9 +++++++--
manifold/src/console/Drawers.tsx | 13 +++++++++++++
3 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md
index c698181..d432563 100644
--- a/manifold/ONBOARDING.md
+++ b/manifold/ONBOARDING.md
@@ -196,9 +196,10 @@ a setting → `--r-*` tokens.
### Feedback — `src/feedback/`
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
- Two modes: **explore-and-place** (default, Mode 2, positive-only — drives the C++ core's
- snapshot/scratchpad/undo lifecycle; caller accumulates anchors and trains on finalise with
- warm-start) and **geometric-dislike** (Mode 1, selectable). Solo/arm via per-output mask.
+ Two modes: **geometric-dislike** (default, Mode 1, "Push away" — down carves the current sound
+ away from what you like, directed repulsion) and **explore-and-place** (Mode 2, positive-only,
+ selectable — drives the C++ core's snapshot/scratchpad/undo lifecycle; caller accumulates anchors
+ and trains on finalise with warm-start). Solo/arm via per-output mask.
- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
the stream — not bit-identical to `nisps::Rng`.
- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx
index aca34b7..6aa41b0 100644
--- a/manifold/src/console/ConsoleApp.tsx
+++ b/manifold/src/console/ConsoleApp.tsx
@@ -112,8 +112,9 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
const [sandwich, setSandwich] = useState(false);
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
- // feedback mode = "Explore and place"; default solo = "Mask gradients".
- const [feedbackMode, setFeedbackModeState] = useState('explore-and-place');
+ // feedback mode = "Push away" (geometric); default solo = "Mask gradients".
+ // (Explore-and-place is selectable but the geometric push is the better default.)
+ const [feedbackMode, setFeedbackModeState] = useState('geometric-dislike');
const [soloMode, setSoloMode] = useState('mask-gradients');
const [exploring, setExploring] = useState(false);
const [learningPaused, setLearningPaused] = useState(false);
@@ -654,10 +655,14 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
onAddExample: addExample,
onTrain: train,
onClear: () => {
+ // Drop the recorded training examples AND every on-map visual that
+ // represents them (feedback markers + placed-anchor pins). The cursor
+ // trail is ephemeral and self-decays, so it needs no explicit reset.
engine?.clearExamples();
setExamples(0);
setLoss([]);
setMarkers([]);
+ setPins([]);
},
snapshots,
onJump: (id) => {
diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx
index d0993ae..9fcfcf1 100644
--- a/manifold/src/console/Drawers.tsx
+++ b/manifold/src/console/Drawers.tsx
@@ -168,6 +168,19 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
)}
+ Recorded examples
+
+
+ {ctx.datasetCount} example{ctx.datasetCount === 1 ? '' : 's'}
+
+
+
+ forget every example & wipe the on-map marks
+
+
+
{depth === 'expanded' && (
<>
Solo / arm scope
From c7056e20e8c09011fa1d47b68426fdf4fb730da9 Mon Sep 17 00:00:00 2001
From: monkey-w1n5t0n
Date: Mon, 29 Jun 2026 00:45:45 +0200
Subject: [PATCH 3/3] feat(manifold): follow-mouse knob (dbl-click mark) + 1/2
verdict keys
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Double-click the input mark to enter follow-mouse mode: a window-level
pointermove listener maps the whole viewport onto the surface's [0,1]²
space so the knob tracks the cursor across the entire UI. Esc or a second
double-click exits; a badge + hidden cursor signal the active state. Local
pan/long-press and auto-drift are suppressed while following.
- Keys 1 = thumbs-down/explore (perturb), 2 = thumbs-up (commit), on the
window keydown handler so they fire even in follow-mouse mode. Drawers
learn/inputs lose their 1/2 shortcuts (3-5 keep route/settings/help).
- Sync help keymap (Drawers) + ONBOARDING stages table.
---
manifold/ONBOARDING.md | 2 +-
manifold/src/console/ConsoleApp.tsx | 13 ++-
manifold/src/console/Drawers.tsx | 9 +-
manifold/src/console/Manifold.tsx | 125 +++++++++++++++++++++++++++-
4 files changed, 138 insertions(+), 11 deletions(-)
diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md
index d432563..ac5b379 100644
--- a/manifold/ONBOARDING.md
+++ b/manifold/ONBOARDING.md
@@ -94,7 +94,7 @@ decision. Buffers are reused frame-to-frame; never assume a fresh array.
### The "convertible" Stages (one renders at a time, chosen by `focus` + `outputMode`)
| Stage | File | Renders when | What it is |
|---|---|---|---|
-| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. |
+| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. **Double-click the input mark → follow-mouse mode** (self-contained state; a window `pointermove` listener maps the whole viewport onto this surface's space so the knob tracks the cursor across the entire UI; Esc / second double-click exits). |
| OutputStage | `OutputStage.tsx` | `focus==='out'` | Full-bleed output columns; drag bars set value; `InputMini` docked in a corner. |
| SplitStage | `SplitStage.tsx` | `focus==='split'` | Manifold left, OutputStage right, equal width. |
| CompositeStage | `CompositeStage.tsx` | `focus==='composite'` (**app default / hero**) | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. |
diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx
index 6aa41b0..8ca2b0b 100644
--- a/manifold/src/console/ConsoleApp.tsx
+++ b/manifold/src/console/ConsoleApp.tsx
@@ -543,14 +543,21 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
+ // Verdict accelerators: 1 = thumbs-down / explore, 2 = thumbs-up. These
+ // work everywhere, including while the manifold is in follow-mouse mode
+ // (the knob tracks the cursor; the keys still land the verdict).
const map: Record = {
- '1': 'learn',
- '2': 'inputs',
'3': 'route',
'4': 'settings',
'5': 'help',
};
- if (map[e.key]) {
+ if (e.key === '1') {
+ e.preventDefault();
+ perturb();
+ } else if (e.key === '2') {
+ e.preventDefault();
+ commit();
+ } else if (map[e.key]) {
setActive((a) => (a === map[e.key] ? null : map[e.key]));
setDepth('condensed');
} else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx
index 9fcfcf1..c93ca47 100644
--- a/manifold/src/console/Drawers.tsx
+++ b/manifold/src/console/Drawers.tsx
@@ -719,12 +719,15 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
// ===========================================================================
const KEYS: [string, string][] = [
- ['1–5', 'open drawers'],
- ['\\', 'expand drawer'],
+ ['1', 'down − / explore'],
+ ['2', 'commit +'],
['space / ↑', 'commit +'],
- ['↓', 'perturb / down −'],
+ ['↓', 'down − / explore'],
+ ['3–5', 'open drawers'],
+ ['\\', 'expand drawer'],
['z', 'undo'],
['[ ] =', 'split (composite)'],
+ ['dbl-click mark', 'follow mouse (Esc exits)'],
];
function HelpDrawer() {
return (
diff --git a/manifold/src/console/Manifold.tsx b/manifold/src/console/Manifold.tsx
index 528222c..00f487a 100644
--- a/manifold/src/console/Manifold.tsx
+++ b/manifold/src/console/Manifold.tsx
@@ -6,8 +6,8 @@
*
* Ported faithfully from the window-global `Manifold.jsx`.
*/
-import { useEffect, useRef } from 'react';
-import type { PointerEvent as ReactPointerEvent } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react';
import type { FeedbackMarker, Pin } from './types';
export interface ManifoldProps {
@@ -55,6 +55,17 @@ export function Manifold({
// Transient "just placed" marker location (manifold space), for a brief flash.
const placedRef = useRef<{ x: number; y: number; t: number } | null>(null);
+ // FOLLOW-MOUSE mode: double-click the input mark to glue the knob to the
+ // cursor, then it tracks the mouse anywhere on screen (the whole viewport maps
+ // onto this surface's [0,1]² space). Escape or a second double-click exits.
+ const [followMouse, setFollowMouse] = useState(false);
+ const followMouseRef = useRef(false);
+ followMouseRef.current = followMouse;
+ // onMove can change identity each render; keep a ref so the global listener
+ // never has to be re-installed per frame while following.
+ const onMoveRef = useRef(onMove);
+ onMoveRef.current = onMove;
+
stateRef.current = { pos, noiseCap, pins, markers, variant, frozen, follow, picking };
// push trail point whenever pos changes
@@ -99,8 +110,65 @@ export function Manifold({
if (p) onMove(p[0], p[1]);
};
+ /**
+ * FOLLOW-MOUSE mapping: turn an absolute viewport coordinate (client px) into
+ * a normalised [0,1]² manifold position. The *whole window* maps onto this
+ * surface's space so the knob genuinely tracks the cursor across the entire
+ * UI — left edge → x=0, right edge → x=1 — regardless of where (or how small)
+ * this surface sits. The circular variant clamps to the inscribed unit disc.
+ */
+ const posFromClient = (clientX: number, clientY: number): [number, number] => {
+ const W = window.innerWidth || 1;
+ const H = window.innerHeight || 1;
+ const nx = clientX / W;
+ const ny = 1 - clientY / H; // screen y is down; flip so up = +
+ if (stateRef.current.variant === 'circular') {
+ let vx = (nx - 0.5) * 2;
+ let vy = (ny - 0.5) * 2;
+ const mag = Math.hypot(vx, vy);
+ if (mag > 1) {
+ vx /= mag;
+ vy /= mag;
+ }
+ return [0.5 + 0.5 * vx, 0.5 + 0.5 * vy];
+ }
+ return [Math.max(0, Math.min(1, nx)), Math.max(0, Math.min(1, ny))];
+ };
+
+ /** Screen-pixel (client) position of the drawn input mark, for the hit-test. */
+ const knobClient = (r: DOMRect, p: [number, number]): [number, number] => {
+ if (stateRef.current.variant === 'circular') {
+ const radius = Math.min(r.width, r.height) / 2 - 2;
+ const cx = r.left + r.width / 2;
+ const cy = r.top + r.height / 2;
+ return [cx + (p[0] - 0.5) * 2 * radius, cy - (p[1] - 0.5) * 2 * radius];
+ }
+ return [r.left + p[0] * r.width, r.top + (1 - p[1]) * r.height];
+ };
+
+ /**
+ * Double-click toggles follow-mouse mode. Entering requires the click to land
+ * on the input mark (within ~36px of the knob); a second double-click — the
+ * mark is now under the cursor — exits.
+ */
+ const onDoubleClick = (e: ReactMouseEvent) => {
+ if (stateRef.current.frozen || stateRef.current.picking) return;
+ if (followMouseRef.current) {
+ setFollowMouse(false);
+ return;
+ }
+ const el = wrapRef.current;
+ if (!el) return;
+ const r = el.getBoundingClientRect();
+ const [kx, ky] = knobClient(r, stateRef.current.pos);
+ if (Math.hypot(e.clientX - kx, e.clientY - ky) <= 36) setFollowMouse(true);
+ };
+
const down = (e: ReactPointerEvent) => {
if (stateRef.current.frozen) return;
+ // While following the mouse, the global listener owns the knob — don't start
+ // a pan/long-press drag (double-click still fires to exit).
+ if (followMouseRef.current) return;
// PICK-LOCATION: when placing, this pointer-down picks the anchor location
// (rl-feedback §2.2 §3) and does NOT start a pan/drive drag.
if (stateRef.current.picking) {
@@ -119,6 +187,7 @@ export function Manifold({
}, 600);
};
const move = (e: ReactPointerEvent) => {
+ if (followMouseRef.current) return;
if (draggingRef.current) {
setFromEvent(e);
if (lpTimer.current) clearTimeout(lpTimer.current);
@@ -185,7 +254,7 @@ export function Manifold({
const sx = circular ? (nx: number) => cx + (nx - 0.5) * 2 * radius : (nx: number) => nx * W;
const sy = circular ? (ny: number) => cy - (ny - 0.5) * 2 * radius : (ny: number) => (1 - ny) * H;
- if (fl && !draggingRef.current && !fz) {
+ if (fl && !draggingRef.current && !fz && !followMouseRef.current) {
let [x, y] = p;
const d = driftRef.current;
x += d.vx;
@@ -391,6 +460,29 @@ export function Manifold({
};
}, []);
+ // FOLLOW-MOUSE: while active, a window-level listener drives the knob from the
+ // raw cursor position (anywhere on screen), and Escape exits. Kept on `window`
+ // so it keeps tracking even when the cursor leaves this surface.
+ useEffect(() => {
+ if (!followMouse) return;
+ const onWinMove = (e: PointerEvent) => {
+ if (stateRef.current.frozen) return;
+ const p = posFromClient(e.clientX, e.clientY);
+ onMoveRef.current(p[0], p[1]);
+ };
+ const onWinKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setFollowMouse(false);
+ };
+ window.addEventListener('pointermove', onWinMove);
+ window.addEventListener('keydown', onWinKey);
+ return () => {
+ window.removeEventListener('pointermove', onWinMove);
+ window.removeEventListener('keydown', onWinKey);
+ };
+ // posFromClient/onMoveRef read live refs, so [followMouse] is the only dep.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [followMouse]);
+
return (
+ {followMouse && (
+
+ ● following mouse — Esc / double-click to exit
+
+ )}
);
}