From 3879d57b4cc2dae00a077fe4517028088bf430c6 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sat, 25 Jul 2026 15:16:01 +0200 Subject: [PATCH] fix(manifold): clamp temporary output slider overrides --- manifold/ONBOARDING.md | 5 ++- manifold/src/console/ConsoleApp.tsx | 27 +++++++++++- manifold/src/console/OutputStage.tsx | 65 ++++++++++++++++++++++++++-- manifold/src/console/model.ts | 6 ++- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index 29e0107..347362b 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -115,8 +115,9 @@ Manifold ships a single "composite" altitude. Selection is now a plain three-way CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins + feedback markers; pointer → `onMove`; **double-click the input mark → follow-mouse mode**, a window `pointermove` listener mapping the whole viewport onto this surface's space, Esc or a second -double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set value, and it takes a -`compact` prop for the narrow pane. +double-click exits). `OutputStage.tsx` is the output columns; click or drag a bar to temporarily +audition a clamped value (the next engine tick restores the live MLP), and it takes a `compact` prop +for the narrow pane. - **Output modes** (the TOP dock selector, NOT the same axis as `focus`): `src/console/output-mode.ts` defines `OUTPUT_MODES` = **particles** (default) / midi / osc / cv / synth / editor, each mapping to a diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index cbbc480..901d2d2 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -609,7 +609,32 @@ export function ConsoleApp() { }; const setParam = (i: number, patch: Partial) => - setParams((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p))); + setParams((ps) => + ps.map((p, j) => { + if (j !== i) return p; + const next = { ...p, ...patch }; + // Any explicit edit other than the main-view gesture cancels the + // transient override, so normal dock/editor controls remain authoritative. + if (!Object.prototype.hasOwnProperty.call(patch, 'manualOverride')) { + next.manualOverride = undefined; + } + return next; + }), + ); + + // A direct slider gesture is only an audition value. The next engine tick + // (normally caused by moving the input) returns that param to the live MLP. + useEffect(() => { + setParams((ps) => { + let changed = false; + const next = ps.map((p) => { + if (!p.manualOverride) return p; + changed = true; + return { ...p, status: 'live' as const, manualOverride: undefined }; + }); + return changed ? next : ps; + }); + }, [version]); // Ref-mirror of everything the two global-listener effects below close over // that is NOT already React-stable (verdict/navigation handlers are plain diff --git a/manifold/src/console/OutputStage.tsx b/manifold/src/console/OutputStage.tsx index 46d7aea..f571ecb 100644 --- a/manifold/src/console/OutputStage.tsx +++ b/manifold/src/console/OutputStage.tsx @@ -3,7 +3,7 @@ * columns. Drag/click a bar sets value; ⌥/alt-click cycles state; hover opens * the OutputEditor. Ported from `OutputStage.jsx`. */ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import type { MFParam, ParamStatus } from './model'; import { GROUP_COLOR } from './model'; @@ -20,10 +20,12 @@ export interface OutputStageProps { export function OutputStage({ params, values, onChange, compact = false }: OutputStageProps) { const [open, setOpen] = useState(null); + const [clampMarker, setClampMarker] = useState<{ id: string; value: number } | null>(null); const timers = useRef<{ open: ReturnType | null; close: ReturnType | null }>({ open: null, close: null, }); + const clampTimer = useRef | null>(null); const drag = useRef<{ i: number; moved: boolean; startY: number; el: HTMLDivElement | null; alt: boolean }>({ i: -1, moved: false, @@ -46,10 +48,48 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu if (timers.current.close) clearTimeout(timers.current.close); }; + useEffect(() => { + return () => { + if (clampTimer.current) clearTimeout(clampTimer.current); + }; + }, []); + const valFromEvent = (el: HTMLDivElement, clientY: number) => { const r = el.getBoundingClientRect(); return Math.max(0, Math.min(1, 1 - (clientY - r.top) / r.height)); }; + const showClampMarker = (id: string, value: number) => { + if (clampTimer.current) clearTimeout(clampTimer.current); + setClampMarker({ id, value }); + clampTimer.current = setTimeout(() => { + setClampMarker(null); + clampTimer.current = null; + }, 700); + }; + const clearClampMarker = () => { + if (clampTimer.current) clearTimeout(clampTimer.current); + clampTimer.current = null; + setClampMarker(null); + }; + const setSliderValue = (i: number, raw: number) => { + const param = params[i]; + if (!param) return; + const min = Math.max(0, Math.min(1, Math.min(param.min, param.max))); + const max = Math.max(0, Math.min(1, Math.max(param.min, param.max))); + const value = Math.max(min, Math.min(max, raw)); + const wasClamped = value !== raw; + if (wasClamped) showClampMarker(param.id, value); + else clearClampMarker(); + + const span = max - min; + const fixedValue = span > 0 ? (value - min) / span : 0; + const temporary = param.manualOverride || param.status === 'live'; + onChange(i, { + status: 'fixed', + val: fixedValue, + manualOverride: temporary ? true : undefined, + }); + }; const down = (e: ReactPointerEvent, i: number) => { e.currentTarget.setPointerCapture?.(e.pointerId); drag.current = { @@ -64,13 +104,15 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu const d = drag.current; if (d.i !== i) return; if (Math.abs(e.clientY - d.startY) > 3) d.moved = true; - if (d.moved && !d.alt && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) }); + if (d.moved && !d.alt && d.el) setSliderValue(i, valFromEvent(d.el, e.clientY)); }; const up = (e: ReactPointerEvent, i: number) => { const d = drag.current; if (d.i !== i) return; - if (d.alt && !d.moved) onChange(i, { status: OUT_NEXT[params[i].status] || 'live' }); - else if (!d.moved && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) }); + if (d.alt && !d.moved) { + clearClampMarker(); + onChange(i, { status: OUT_NEXT[params[i].status] || 'live', manualOverride: undefined }); + } else if (!d.moved && d.el) setSliderValue(i, valFromEvent(d.el, e.clientY)); drag.current = { i: -1, moved: false, startY: 0, el: null, alt: false }; }; @@ -202,6 +244,21 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu }} /> )} + {clampMarker?.id === p.id && ( +
+ )}
{ if (p.status === 'off') return 0; - if (p.status === 'fixed') return p.val ?? 0.5; + if (p.status === 'fixed') { + return p.min + Math.max(0, Math.min(1, p.val ?? 0.5)) * (p.max - p.min); + } const raw = engineOut && i < engineOut.length ? engineOut[i] : 0.5; const v = p.min + applyCurve(raw, p.curve) * (p.max - p.min); return Math.max(0, Math.min(1, v));