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 +
+ )}
); }