feat: prevent text selection on dsp sub pages

This commit is contained in:
2026-09-12 15:19:57 +02:00
parent fd545fb2a8
commit 2717dfe99a
15 changed files with 174 additions and 13 deletions
+2
View File
@@ -15,6 +15,8 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-select: none;
} }
.btn:disabled { .btn:disabled {
@@ -20,7 +20,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 3cqh; font-size: 3cqh;
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
text-align: left; text-align: center;
color: var(--accent-brand); color: var(--accent-brand);
+1 -1
View File
@@ -55,7 +55,7 @@ export interface LinearGraphProps {
const defaultFormat = (v: number) => { const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0'; if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v); const abs = Math.abs(v);
if (abs >= 1000 || abs < 0.01) return v.toExponential(1); if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString(); return parseFloat(v.toFixed(4)).toString();
}; };
+3
View File
@@ -3,6 +3,9 @@
flex-direction: column; flex-direction: column;
align-items: left; align-items: left;
gap: 0.3cqh; gap: 0.3cqh;
user-select: none;
-webkit-user-select: none;
} }
.meter-bar__title { .meter-bar__title {
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../features/dsp408/types'; import { InputChannel, OutputChannel } from '../../features/dsp408/types';
import { DSP408 } from '../../types'; import { DSP408 } from '../../types';
import { import {
@@ -69,6 +69,14 @@ const MARGIN = 36;
const LEFT_CHAIN_W = 560; const LEFT_CHAIN_W = 560;
const RIGHT_CHAIN_W = 620; const RIGHT_CHAIN_W = 620;
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
// user units. Much wider than the visible 1–2.5px line so it's easy to click
// or catch with a scroll wheel without needing pixel-perfect aim.
const LINE_HITBOX_WIDTH = 14;
// How long the gain tooltip stays visible after the last wheel tick, in ms.
const GAIN_TOOLTIP_HOLD_MS = 700;
function getRoutes(dsp: DSP408, output: OutputChannel) { function getRoutes(dsp: DSP408, output: OutputChannel) {
return dsp.state.current_config.output_states[output].matrix_routes; return dsp.state.current_config.output_states[output].matrix_routes;
} }
@@ -79,6 +87,18 @@ function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).gains[input]; return getRoutes(dsp, output).gains[input];
} }
function formatGainDb(value: number) {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)} dB`;
}
type GainTooltipState = {
visible: boolean;
x: number;
y: number;
label: string;
};
function SignalFlowMatrix({ function SignalFlowMatrix({
dsp, dsp,
setMatrixRouting, setMatrixRouting,
@@ -109,6 +129,20 @@ function SignalFlowMatrix({
const inputNodeX = leftStart + LEFT_CHAIN_W; const inputNodeX = leftStart + LEFT_CHAIN_W;
const outputNodeX = rightStart; const outputNodeX = rightStart;
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
visible: false,
x: 0,
y: 0,
label: '',
});
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
};
}, []);
const handleToggle = (output: OutputChannel, input: InputChannel) => { const handleToggle = (output: OutputChannel, input: InputChannel) => {
const routes = getRoutes(dsp, output); const routes = getRoutes(dsp, output);
const next = routes.connected.includes(input) const next = routes.connected.includes(input)
@@ -124,6 +158,19 @@ function SignalFlowMatrix({
const next = const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10; Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
void setMatrixGain(input, output, next); void setMatrixGain(input, output, next);
// Show a small tooltip with the new gain value right next to the cursor,
// then let it fade away shortly after wheeling stops.
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
setGainTooltip({
visible: true,
x: e.clientX,
y: e.clientY,
label: formatGainDb(next),
});
hideTooltipTimer.current = setTimeout(() => {
setGainTooltip((t) => ({ ...t, visible: false }));
}, GAIN_TOOLTIP_HOLD_MS);
}; };
const routePairs = useMemo(() => { const routePairs = useMemo(() => {
@@ -134,6 +181,18 @@ function SignalFlowMatrix({
return pairs; return pairs;
}, []); }, []);
// Draw dashed (unconnected) links first so connected (solid) links always
// paint on top of them, regardless of input/output index order. This has
// to be recomputed whenever routing changes, since isRouted reads live
// dsp state — unlike routePairs above, which is just static index pairs.
const orderedRoutePairs = useMemo(() => {
return [...routePairs].sort((a, b) => {
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
return aConnected - bConnected;
});
}, [routePairs, dsp]);
return ( return (
<div className="signal-flow"> <div className="signal-flow">
<svg <svg
@@ -141,7 +200,7 @@ function SignalFlowMatrix({
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
className="signal-flow__svg" className="signal-flow__svg"
> >
{routePairs.map(({ input, output, i, j }) => { {orderedRoutePairs.map(({ input, output, i, j }) => {
const connected = isRouted(dsp, output, input); const connected = isRouted(dsp, output, input);
const y1 = inputCenterY(i); const y1 = inputCenterY(i);
const y2 = outputCenterY(j); const y2 = outputCenterY(j);
@@ -152,6 +211,20 @@ function SignalFlowMatrix({
return ( return (
<g key={`${input}-${output}`}> <g key={`${input}-${output}`}>
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
the line is easy to grab without needing to hit the thin visible
stroke exactly. Sits directly under the visible path. */}
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={LINE_HITBOX_WIDTH}
style={{ cursor: 'pointer' }}
onClick={() => handleToggle(output, input)}
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
<path <path
d={path} d={path}
fill="none" fill="none"
@@ -159,9 +232,7 @@ function SignalFlowMatrix({
strokeWidth={connected ? 2.5 : 1} strokeWidth={connected ? 2.5 : 1}
strokeDasharray={connected ? undefined : '4 5'} strokeDasharray={connected ? undefined : '4 5'}
opacity={connected ? 0.9 : 0.35} opacity={connected ? 0.9 : 0.35}
style={{ cursor: 'pointer' }} pointerEvents="none"
onClick={() => handleToggle(output, input)}
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
/> />
</g> </g>
); );
@@ -252,8 +323,32 @@ function SignalFlowMatrix({
); );
})} })}
</svg> </svg>
{gainTooltip.visible && (
<div
className="signal-flow__gain-tooltip"
style={{
position: 'fixed',
left: gainTooltip.x + 14,
top: gainTooltip.y - 28,
pointerEvents: 'none',
zIndex: 50,
padding: '2px 8px',
borderRadius: 6,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
color: 'var(--text-primary, #fff)',
border: '1px solid var(--border-hairline, #333)',
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
whiteSpace: 'nowrap',
}}
>
{gainTooltip.label}
</div>
)}
</div> </div>
); );
} }
export default SignalFlowMatrix; export default SignalFlowMatrix;
@@ -9,6 +9,9 @@
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
} }
.gauge-track { .gauge-track {
@@ -58,8 +61,6 @@
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
touch-action: none;
user-select: none;
} }
.track { .track {
@@ -18,6 +18,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.compressor-panels { .compressor-panels {
@@ -94,7 +97,7 @@
.compressor-panel__cell--steppers { .compressor-panel__cell--steppers {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: stretch;
gap: 1cqh; gap: 1cqh;
} }
@@ -111,6 +114,11 @@
gap: 1cqh; gap: 1cqh;
} }
.compressor-panel__stepper-item .choice-stepper {
flex: 0 0 auto;
height: auto;
}
.compressor-panel__cell { .compressor-panel__cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -145,6 +153,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.compressor-graph-panel { .compressor-graph-panel {
@@ -19,6 +19,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.delay-panels { .delay-panels {
@@ -88,6 +91,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.delay-graph-panel { .delay-graph-panel {
@@ -19,6 +19,8 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.gain-panels { .gain-panels {
@@ -75,6 +77,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.gain-graph-panel { .gain-graph-panel {
@@ -168,6 +173,8 @@
gap: 0.5cqh; gap: 0.5cqh;
overflow: hidden; overflow: hidden;
user-select: none;
-webkit-user-select: none;
} }
.magnitude-selector__chip { .magnitude-selector__chip {
@@ -20,6 +20,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.gate-panels { .gate-panels {
@@ -106,6 +109,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.gate-graph-panel { .gate-graph-panel {
@@ -19,6 +19,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.geq-panels { .geq-panels {
@@ -114,6 +117,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.geq-graph-panel { .geq-graph-panel {
@@ -18,6 +18,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.limiter-panels { .limiter-panels {
@@ -118,6 +121,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.limiter-graph-panel { .limiter-graph-panel {
@@ -19,6 +19,9 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.matrix-panels { .matrix-panels {
@@ -87,10 +90,23 @@
padding-inline: 3cqh; padding-inline: 3cqh;
} }
.matrix-panel__row .choice-stepper {
align-self: center;
height: auto;
flex: 0 0 auto;
}
.matrix-flow { .matrix-flow {
flex: 0 0 calc(100% - var(--dsp-control-heigth)); flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
padding: var(--space-2); padding: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline); border-bottom: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.matrix-gain-stepper .choice-stepper__input {
font-size: 2.8cqh;
}
@@ -101,8 +101,8 @@ function MatrixSection({
fontSize: '4cqh', fontSize: '4cqh',
padding: 0, padding: 0,
minHeight: 0, minHeight: 0,
marginTop: '2cqh', marginTop: '3cqh',
marginBottom: '2cqh', marginBottom: '3cqh',
}} }}
onClick={() => handleToggle(output, input)} onClick={() => handleToggle(output, input)}
> >
@@ -110,6 +110,7 @@ function MatrixSection({
</Button> </Button>
<ChoiceStepper <ChoiceStepper
className="matrix-gain-stepper"
value={gain} value={gain}
values={GAIN_VALUES} values={GAIN_VALUES}
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`} formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
+1
View File
@@ -160,6 +160,7 @@
background: transparent; background: transparent;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
text-align: left;
} }
.add-dsp-form { .add-dsp-form {