Zum Inhalt

src/ui/ParamSlider.tsx

import { type Component, For, Show, createMemo, createSignal } from 'solid-js';
import { Tooltip } from './Tooltip';
import { Reset } from './icons/Icon';
import { PARAM_HELP } from './paramHelp';
import {
  type DragMode,
  type Range,
  dragModeFrom,
  valueFromAbsolute,
  valueFromIncremental,
  valueFromKey,
  valueFromWheel,
} from './sliderInput';

interface Props {
  name: string;
  value: number;
  min: number;
  max: number;
  step?: number;
  unit?: string;
  ticks?: number;
  format?: (v: number) => string;
  onChange: (v: number) => void;
  /** When provided, shows a reset button that calls this. */
  onReset?: () => void;
  /** When provided, shows a (?) icon that opens a help popover. */
  info?: string;
}

export const ParamSlider: Component<Props> = (props) => {
  const fmt = createMemo(() => props.format ?? defaultFormat(props.max));
  const range = createMemo<Range>(() => ({ min: props.min, max: props.max, step: props.step }));
  const pct = createMemo(() => {
    const v = props.value;
    return Math.max(0, Math.min(1, (v - props.min) / (props.max - props.min)));
  });
  // Auto-look up help text by slider name. An explicit `info` prop overrides
  // the dictionary, so callers can still set ad-hoc tooltips.
  const helpText = createMemo(() => props.info ?? PARAM_HELP[props.name]);

  let trackRef: HTMLDivElement | undefined;
  const [editing, setEditing] = createSignal(false);

  // Drag-state. Captured at pointerdown so the modifier mode stays stable
  // across the whole drag — releasing Shift mid-drag won't suddenly snap
  // the slider to the pointer's absolute position.
  let dragMode: DragMode = 'absolute';
  let dragStartX = 0;
  let dragStartValue = 0;

  const trackWidth = (): number => trackRef?.getBoundingClientRect().width ?? 0;
  const trackLeft = (): number => trackRef?.getBoundingClientRect().left ?? 0;

  const applyFromClientX = (clientX: number): void => {
    if (!trackRef) return;
    const w = trackWidth();
    const next =
      dragMode === 'absolute'
        ? valueFromAbsolute(clientX, trackLeft(), w, range())
        : valueFromIncremental({
            clientX,
            startX: dragStartX,
            startValue: dragStartValue,
            rectWidth: w,
            mode: dragMode,
            range: range(),
          });
    props.onChange(next);
  };

  const onPointerDown = (e: PointerEvent & { currentTarget: HTMLDivElement }): void => {
    // pointer capture makes the whole drag track this element, even off-track
    e.currentTarget.setPointerCapture(e.pointerId);
    dragMode = dragModeFrom(e);
    dragStartX = e.clientX;
    dragStartValue = props.value;
    // Incremental modes (Shift/Alt) treat the click position as the anchor,
    // not a jump target — only Δx changes the value.
    if (dragMode === 'absolute') applyFromClientX(e.clientX);
  };
  const onPointerMove = (e: PointerEvent & { currentTarget: HTMLDivElement }): void => {
    if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
    applyFromClientX(e.clientX);
  };

  const onWheel = (e: WheelEvent): void => {
    // Only intercept when the slider track is the wheel target — otherwise
    // the editor panel becomes un-scrollable when the cursor sits on a slider.
    e.preventDefault();
    const next = valueFromWheel(props.value, e.deltaY, e, range());
    props.onChange(next);
  };

  const onKeyDown = (e: KeyboardEvent): void => {
    const next = valueFromKey(props.value, e.key, e, range());
    if (next === null) return;
    e.preventDefault();
    props.onChange(next);
  };

  const commitText = (raw: string): void => {
    const n = Number.parseFloat(raw);
    if (Number.isFinite(n)) {
      const clamped = Math.max(props.min, Math.min(props.max, n));
      props.onChange(clamped);
    }
    setEditing(false);
  };

  return (
    <div class="phys-slider">
      <div class="phys-slider-head">
        <span class="name">{props.name}</span>
        <div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
          <Show
            when={editing()}
            fallback={
              <button
                type="button"
                class="phys-slider-value"
                onClick={() => setEditing(true)}
                title="Click to type a value"
              >
                {fmt()(props.value)}
                {props.unit ?? ''}
              </button>
            }
          >
            <input
              class="phys-slider-input"
              type="number"
              autofocus
              value={props.value}
              min={props.min}
              max={props.max}
              step={props.step ?? 'any'}
              onBlur={(e) => commitText(e.currentTarget.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter') e.currentTarget.blur();
                else if (e.key === 'Escape') setEditing(false);
              }}
            />
          </Show>
          <Show when={helpText()}>
            <Tooltip text={helpText() ?? ''} />
          </Show>
          <Show when={props.onReset}>
            <button
              type="button"
              class="phys-iconbtn"
              style={{ padding: '3px' }}
              onClick={() => props.onReset?.()}
              title="Reset to preset default"
            >
              <Reset size={11} />
            </button>
          </Show>
        </div>
      </div>
      <div
        ref={trackRef}
        class="phys-track-wrap"
        style={{ cursor: 'pointer', 'touch-action': 'none' }}
        tabIndex={0}
        role="slider"
        aria-label={props.name}
        aria-valuemin={props.min}
        aria-valuemax={props.max}
        aria-valuenow={props.value}
        title="Drag · Shift = fine · Alt = coarse · Wheel · Arrows / Home / End"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onWheel={onWheel}
        onKeyDown={onKeyDown}
      >
        <div class="phys-track-bg" style={{ 'pointer-events': 'none' }}>
          <div class="phys-track-fill-el" style={{ width: `${pct() * 100}%` }} />
        </div>
        <div class="phys-thumb-el" style={{ left: `${pct() * 100}%`, 'pointer-events': 'none' }} />
        <div class="phys-ticks">
          <For each={Array.from({ length: props.ticks ?? 5 })}>{() => <span />}</For>
        </div>
      </div>
    </div>
  );
};

function defaultFormat(max: number): (v: number) => string {
  if (max >= 100) return (v) => Math.round(v).toString();
  if (max >= 10) return (v) => v.toFixed(1);
  return (v) => v.toFixed(2);
}