fix(manifold): clamp temporary output slider overrides
This commit is contained in:
parent
6e71ad7d35
commit
3879d57b4c
4 changed files with 95 additions and 8 deletions
|
|
@ -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
|
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
|
+ 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
|
`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
|
double-click exits). `OutputStage.tsx` is the output columns; click or drag a bar to temporarily
|
||||||
`compact` prop for the narrow pane.
|
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`
|
- **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
|
defines `OUTPUT_MODES` = **particles** (default) / midi / osc / cv / synth / editor, each mapping to a
|
||||||
|
|
|
||||||
|
|
@ -609,7 +609,32 @@ export function ConsoleApp() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const setParam = (i: number, patch: Partial<MFParam>) =>
|
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
|
// Ref-mirror of everything the two global-listener effects below close over
|
||||||
// that is NOT already React-stable (verdict/navigation handlers are plain
|
// that is NOT already React-stable (verdict/navigation handlers are plain
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* columns. Drag/click a bar sets value; ⌥/alt-click cycles state; hover opens
|
* columns. Drag/click a bar sets value; ⌥/alt-click cycles state; hover opens
|
||||||
* the OutputEditor. Ported from `OutputStage.jsx`.
|
* 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 { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import type { MFParam, ParamStatus } from './model';
|
import type { MFParam, ParamStatus } from './model';
|
||||||
import { GROUP_COLOR } from './model';
|
import { GROUP_COLOR } from './model';
|
||||||
|
|
@ -20,10 +20,12 @@ export interface OutputStageProps {
|
||||||
|
|
||||||
export function OutputStage({ params, values, onChange, compact = false }: OutputStageProps) {
|
export function OutputStage({ params, values, onChange, compact = false }: OutputStageProps) {
|
||||||
const [open, setOpen] = useState<number | null>(null);
|
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 }>({
|
const timers = useRef<{ open: ReturnType<typeof setTimeout> | null; close: ReturnType<typeof setTimeout> | null }>({
|
||||||
open: null,
|
open: null,
|
||||||
close: 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 }>({
|
const drag = useRef<{ i: number; moved: boolean; startY: number; el: HTMLDivElement | null; alt: boolean }>({
|
||||||
i: -1,
|
i: -1,
|
||||||
moved: false,
|
moved: false,
|
||||||
|
|
@ -46,10 +48,48 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
|
||||||
if (timers.current.close) clearTimeout(timers.current.close);
|
if (timers.current.close) clearTimeout(timers.current.close);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (clampTimer.current) clearTimeout(clampTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const valFromEvent = (el: HTMLDivElement, clientY: number) => {
|
const valFromEvent = (el: HTMLDivElement, clientY: number) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
return Math.max(0, Math.min(1, 1 - (clientY - r.top) / r.height));
|
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) => {
|
const down = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
|
||||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
drag.current = {
|
drag.current = {
|
||||||
|
|
@ -64,13 +104,15 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
|
||||||
const d = drag.current;
|
const d = drag.current;
|
||||||
if (d.i !== i) return;
|
if (d.i !== i) return;
|
||||||
if (Math.abs(e.clientY - d.startY) > 3) d.moved = true;
|
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 up = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
|
||||||
const d = drag.current;
|
const d = drag.current;
|
||||||
if (d.i !== i) return;
|
if (d.i !== i) return;
|
||||||
if (d.alt && !d.moved) onChange(i, { status: OUT_NEXT[params[i].status] || 'live' });
|
if (d.alt && !d.moved) {
|
||||||
else if (!d.moved && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) });
|
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 };
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ export interface MFParam {
|
||||||
group: string;
|
group: string;
|
||||||
status: ParamStatus;
|
status: ParamStatus;
|
||||||
val: number;
|
val: number;
|
||||||
|
/** Temporary value from a direct main-view slider gesture; released on the next engine tick. */
|
||||||
|
manualOverride?: boolean;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
curve: number;
|
curve: number;
|
||||||
|
|
@ -364,7 +366,9 @@ export { applyCurve };
|
||||||
export function shapeValues(params: MFParam[], engineOut: Float32Array | null): number[] {
|
export function shapeValues(params: MFParam[], engineOut: Float32Array | null): number[] {
|
||||||
return params.map((p, i) => {
|
return params.map((p, i) => {
|
||||||
if (p.status === 'off') return 0;
|
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 raw = engineOut && i < engineOut.length ? engineOut[i] : 0.5;
|
||||||
const v = p.min + applyCurve(raw, p.curve) * (p.max - p.min);
|
const v = p.min + applyCurve(raw, p.curve) * (p.max - p.min);
|
||||||
return Math.max(0, Math.min(1, v));
|
return Math.max(0, Math.min(1, v));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue