fix(manifold): clamp temporary output slider overrides

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:16:01 +02:00
parent 6e71ad7d35
commit 3879d57b4c
4 changed files with 95 additions and 8 deletions

View file

@ -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

View file

@ -609,7 +609,32 @@ export function ConsoleApp() {
};
const setParam = (i: number, patch: Partial<MFParam>) =>
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

View file

@ -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<number | null>(null);
const [clampMarker, setClampMarker] = useState<{ id: string; value: number } | null>(null);
const timers = useRef<{ open: ReturnType<typeof setTimeout> | null; close: ReturnType<typeof setTimeout> | null }>({
open: null,
close: null,
});
const clampTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLDivElement>, 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<HTMLDivElement>, 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 && (
<div
style={{
position: 'absolute',
left: 1,
right: 1,
bottom: `${clampMarker.value * 100}%`,
height: 0,
borderTop: '1px dashed #ff4466',
boxShadow: '0 0 5px rgba(255,68,102,0.8)',
zIndex: 3,
pointerEvents: 'none',
}}
/>
)}
<div
style={{
position: 'absolute',

View file

@ -105,6 +105,8 @@ export interface MFParam {
group: string;
status: ParamStatus;
val: number;
/** Temporary value from a direct main-view slider gesture; released on the next engine tick. */
manualOverride?: boolean;
min: number;
max: number;
curve: number;
@ -364,7 +366,9 @@ export { applyCurve };
export function shapeValues(params: MFParam[], engineOut: Float32Array | null): number[] {
return params.map((p, i) => {
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));