mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 08:10:40 +02:00
UI/UX improvements, scope popouts
This commit is contained in:
@@ -1,17 +1,27 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
|
||||
import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer'
|
||||
import { Oscilloscope } from '../visualizers/Oscilloscope'
|
||||
import { Vectorscope } from '../visualizers/Vectorscope'
|
||||
import { Spectrogram } from '../visualizers/Spectrogram'
|
||||
import { VUMeter } from '../visualizers/VUMeter'
|
||||
import { LUFSMeter } from '../visualizers/LUFSMeter'
|
||||
import { Waveform } from '../visualizers/Waveform'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
|
||||
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
|
||||
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
|
||||
import { Spectrogram, type SpectrogramDataSource } from '../visualizers/Spectrogram'
|
||||
import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
|
||||
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
|
||||
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
|
||||
|
||||
interface ScopeModuleProps {
|
||||
scopeKind: ScopeKind
|
||||
lineColor?: string
|
||||
settings?: ScopeSettings[ScopeKind]
|
||||
dataSource?:
|
||||
| SpectrumAnalyzerDataSource
|
||||
| OscilloscopeDataSource
|
||||
| VectorscopeDataSource
|
||||
| SpectrogramDataSource
|
||||
| VUMeterDataSource
|
||||
| LUFSMeterDataSource
|
||||
| WaveformDataSource
|
||||
}
|
||||
|
||||
interface Visualizer {
|
||||
@@ -75,36 +85,68 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
|
||||
}
|
||||
}
|
||||
|
||||
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null {
|
||||
function createVisualizer(
|
||||
scopeKind: ScopeKind,
|
||||
canvas: HTMLCanvasElement,
|
||||
mySettings: ScopeSettings[ScopeKind],
|
||||
lineColor: string,
|
||||
dataSource?: ScopeModuleProps['dataSource'],
|
||||
): Visualizer | null {
|
||||
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
|
||||
switch (scopeKind) {
|
||||
case 'spectrum':
|
||||
return new SpectrumAnalyzer(canvas, opts)
|
||||
return new SpectrumAnalyzer(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as SpectrumAnalyzerDataSource } : {}),
|
||||
})
|
||||
case 'oscilloscope':
|
||||
return new Oscilloscope(canvas, opts)
|
||||
return new Oscilloscope(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as OscilloscopeDataSource } : {}),
|
||||
})
|
||||
case 'vectorscope':
|
||||
return new Vectorscope(canvas, opts)
|
||||
return new Vectorscope(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as VectorscopeDataSource } : {}),
|
||||
})
|
||||
case 'spectrogram':
|
||||
return new Spectrogram(canvas, opts)
|
||||
return new Spectrogram(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as SpectrogramDataSource } : {}),
|
||||
})
|
||||
case 'vumeter':
|
||||
return new VUMeter(canvas, opts)
|
||||
return new VUMeter(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as VUMeterDataSource } : {}),
|
||||
})
|
||||
case 'lufsmeter':
|
||||
return new LUFSMeter(canvas, opts)
|
||||
return new LUFSMeter(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as LUFSMeterDataSource } : {}),
|
||||
})
|
||||
case 'waveform':
|
||||
return new Waveform(canvas, opts)
|
||||
return new Waveform(canvas, {
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
|
||||
export default function ScopeModule({
|
||||
scopeKind,
|
||||
lineColor = '#38bdf8',
|
||||
settings,
|
||||
dataSource,
|
||||
}: ScopeModuleProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const visualizerRef = useRef<Visualizer | null>(null)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
// Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change
|
||||
const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
|
||||
const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
|
||||
const mySettings = settings ?? storeSettings
|
||||
|
||||
// Initialize visualizer
|
||||
useEffect(() => {
|
||||
@@ -112,7 +154,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
|
||||
if (!canvas) return
|
||||
|
||||
initializedRef.current = false
|
||||
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor)
|
||||
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, dataSource)
|
||||
if (!viz) return
|
||||
|
||||
visualizerRef.current = viz
|
||||
@@ -126,14 +168,14 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
|
||||
visualizerRef.current = null
|
||||
initializedRef.current = false
|
||||
}
|
||||
}, [scopeKind])
|
||||
}, [dataSource, scopeKind])
|
||||
|
||||
// Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it)
|
||||
useEffect(() => {
|
||||
if (!visualizerRef.current || !initializedRef.current) return
|
||||
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
|
||||
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), ...(dataSource ? { dataSource } : {}) }
|
||||
visualizerRef.current.setOptions(opts)
|
||||
}, [mySettings, lineColor])
|
||||
}, [dataSource, lineColor, mySettings, scopeKind])
|
||||
|
||||
// ResizeObserver for DPI-aware canvas sizing
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import type {
|
||||
ScopePopoutAudioBatch,
|
||||
ScopePopoutSessionState,
|
||||
ScopePopoutSnapshot,
|
||||
ScopePopoutSyncStateMap,
|
||||
} from '../../types/popout'
|
||||
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
|
||||
return SCOPE_KINDS.reduce((acc, currentKind) => {
|
||||
acc[currentKind] = currentKind === kind
|
||||
return acc
|
||||
}, {} as Record<ScopeKind, boolean>)
|
||||
}
|
||||
|
||||
function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch {
|
||||
switch (kind) {
|
||||
case 'spectrum':
|
||||
return audioRouter.flushPendingSpectrumSamples()
|
||||
case 'oscilloscope':
|
||||
return audioRouter.flushPendingOscilloscopeSamples()
|
||||
case 'vectorscope':
|
||||
return audioRouter.flushPendingVectorscopeSamples()
|
||||
case 'spectrogram':
|
||||
return audioRouter.flushPendingSpectrogramSamples()
|
||||
case 'vumeter':
|
||||
return audioRouter.flushPendingVUMeterSamples()
|
||||
case 'lufsmeter':
|
||||
return audioRouter.flushPendingLUFSMeterSamples()
|
||||
case 'waveform':
|
||||
return audioRouter.flushPendingWaveformSamples()
|
||||
}
|
||||
}
|
||||
|
||||
function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessionState {
|
||||
return {
|
||||
sessionId: state.sessionId,
|
||||
sampleRate: state.sampleRate,
|
||||
channelCount: state.channelCount,
|
||||
capturing: state.capturing,
|
||||
backendKind: state.backendKind,
|
||||
}
|
||||
}
|
||||
|
||||
function isPartialSettings(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export default function ScopePopoutBridge(): null {
|
||||
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
|
||||
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
|
||||
const scopeSettings = useSettingsStore((s) => s.scopeSettings)
|
||||
const popInScope = useSettingsStore((s) => s.popInScope)
|
||||
const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds)
|
||||
const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings)
|
||||
const accent = useThemeStore((s) => s.accent)
|
||||
|
||||
const activePopoutKinds = useMemo(
|
||||
() => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)),
|
||||
[hiddenScopes, scopePopouts],
|
||||
)
|
||||
const activePopoutKindsRef = useRef<ScopeKind[]>(activePopoutKinds)
|
||||
|
||||
useEffect(() => {
|
||||
activePopoutKindsRef.current = activePopoutKinds
|
||||
}, [activePopoutKinds])
|
||||
|
||||
useEffect(() => {
|
||||
const syncState = SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = {
|
||||
shouldBeOpen: scopePopouts[kind]?.poppedOut === true && !hiddenScopes.has(kind),
|
||||
bounds: scopePopouts[kind]?.windowBounds,
|
||||
}
|
||||
return acc
|
||||
}, {} as ScopePopoutSyncStateMap)
|
||||
|
||||
window.electronAPI.syncScopePopouts(syncState)
|
||||
}, [hiddenScopes, scopePopouts])
|
||||
|
||||
useEffect(() => {
|
||||
for (const kind of activePopoutKinds) {
|
||||
const snapshot: ScopePopoutSnapshot = {
|
||||
kind,
|
||||
label: SCOPE_LABELS[kind],
|
||||
accent,
|
||||
settings: scopeSettings[kind],
|
||||
}
|
||||
window.electronAPI.sendScopePopoutSnapshot(snapshot)
|
||||
}
|
||||
}, [accent, activePopoutKinds, scopeSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
|
||||
for (const kind of activePopoutKinds) {
|
||||
window.electronAPI.sendScopePopoutSession(kind, sessionState)
|
||||
}
|
||||
}, [activePopoutKinds])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeCloseRequested = window.electronAPI.onScopePopoutCloseRequested((kind) => {
|
||||
popInScope(kind)
|
||||
})
|
||||
const unsubscribeBoundsChanged = window.electronAPI.onScopePopoutBoundsChanged((kind, bounds) => {
|
||||
updatePopoutBounds(kind, bounds)
|
||||
})
|
||||
const unsubscribeSettingsUpdate = window.electronAPI.onScopePopoutSettingsUpdate((kind, partial) => {
|
||||
if (!isPartialSettings(partial)) return
|
||||
updateScopeSettings(kind, partial as Partial<ScopeSettings[typeof kind]>)
|
||||
})
|
||||
const unsubscribeReady = window.electronAPI.onScopePopoutReady((kind) => {
|
||||
const nextHiddenScopes = useSettingsStore.getState().hiddenScopes
|
||||
const nextPopouts = useSettingsStore.getState().scopePopouts
|
||||
if (!nextPopouts[kind]?.poppedOut || nextHiddenScopes.has(kind)) return
|
||||
|
||||
window.electronAPI.sendScopePopoutSnapshot({
|
||||
kind,
|
||||
label: SCOPE_LABELS[kind],
|
||||
accent: useThemeStore.getState().accent,
|
||||
settings: useSettingsStore.getState().scopeSettings[kind],
|
||||
})
|
||||
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeCloseRequested()
|
||||
unsubscribeBoundsChanged()
|
||||
unsubscribeSettingsUpdate()
|
||||
unsubscribeReady()
|
||||
}
|
||||
}, [popInScope, updatePopoutBounds, updateScopeSettings])
|
||||
|
||||
useEffect(() => {
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
const consumerId = `popout:${kind}`
|
||||
if (activePopoutKinds.includes(kind)) {
|
||||
audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind))
|
||||
} else {
|
||||
audioRouter.clearVisualizerConsumerDemand(consumerId)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`)
|
||||
}
|
||||
}
|
||||
}, [activePopoutKinds])
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
|
||||
const flushFrame = (): void => {
|
||||
for (const kind of activePopoutKindsRef.current) {
|
||||
const batch = flushScopeAudioBatch(kind)
|
||||
if (batch.length > 0) {
|
||||
window.electronAPI.sendScopePopoutAudio(kind, batch)
|
||||
}
|
||||
}
|
||||
frameId = window.requestAnimationFrame(flushFrame)
|
||||
}
|
||||
|
||||
if (activePopoutKinds.length > 0) {
|
||||
frameId = window.requestAnimationFrame(flushFrame)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [activePopoutKinds])
|
||||
|
||||
useEffect(() => {
|
||||
return audioRouter.subscribeToSessionChanges((state) => {
|
||||
const nextSessionState = toPopoutSessionState(state)
|
||||
for (const kind of activePopoutKindsRef.current) {
|
||||
window.electronAPI.sendScopePopoutSession(kind, nextSessionState)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { JSX, ReactNode } from 'react'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { SCOPE_LABELS } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
|
||||
switch (mode) {
|
||||
case 'lissajous':
|
||||
return 'Lissajous'
|
||||
case 'polar-unipolar':
|
||||
return 'Polar Uni'
|
||||
case 'polar-bipolar':
|
||||
return 'Polar Bi'
|
||||
case 'linear-unipolar':
|
||||
return 'Linear Uni'
|
||||
case 'linear-bipolar':
|
||||
return 'Linear Bi'
|
||||
}
|
||||
}
|
||||
|
||||
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
|
||||
switch (kind) {
|
||||
case 'spectrum': {
|
||||
const scopeSettings = settings as ScopeSettings['spectrum']
|
||||
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
|
||||
}
|
||||
case 'oscilloscope': {
|
||||
const scopeSettings = settings as ScopeSettings['oscilloscope']
|
||||
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
|
||||
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
|
||||
}
|
||||
case 'vectorscope': {
|
||||
const scopeSettings = settings as ScopeSettings['vectorscope']
|
||||
return scopeSettings.multiband
|
||||
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
|
||||
: vectorscopeModeLabel(scopeSettings.mode)
|
||||
}
|
||||
case 'spectrogram': {
|
||||
const scopeSettings = settings as ScopeSettings['spectrogram']
|
||||
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
|
||||
}
|
||||
case 'vumeter': {
|
||||
const scopeSettings = settings as ScopeSettings['vumeter']
|
||||
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
|
||||
}
|
||||
case 'lufsmeter':
|
||||
return 'Bar Meter'
|
||||
case 'waveform': {
|
||||
const scopeSettings = settings as ScopeSettings['waveform']
|
||||
return scopeSettings.multiband
|
||||
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
|
||||
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToggleChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectControl({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
children: ReactNode
|
||||
onChange: (value: string) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="settings-control">
|
||||
<span className="settings-control__label">{label}</span>
|
||||
<select
|
||||
className="settings-control__select"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeControl({
|
||||
label,
|
||||
value,
|
||||
valueLabel,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
valueLabel: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
fullWidth?: boolean
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
|
||||
<span className="settings-control__label">
|
||||
{label}
|
||||
<span className="settings-control__value">{valueLabel}</span>
|
||||
</span>
|
||||
<input
|
||||
className="settings-control__range"
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScopeSettingsSectionProps {
|
||||
kind: ScopeKind
|
||||
settings: ScopeSettings[ScopeKind]
|
||||
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
|
||||
}
|
||||
|
||||
export default function ScopeSettingsSection({
|
||||
kind,
|
||||
settings,
|
||||
onUpdate,
|
||||
}: ScopeSettingsSectionProps): JSX.Element {
|
||||
return (
|
||||
<section className="settings-scope-section">
|
||||
<div className="settings-scope-section__header">
|
||||
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
|
||||
<div className="settings-scope-section__summary">{scopeSummary(kind, settings)}</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-section__controls">
|
||||
{kind === 'spectrum' && (() => {
|
||||
const current = settings as ScopeSettings['spectrum']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="FFT Size"
|
||||
value={current.fftSize}
|
||||
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
|
||||
>
|
||||
{[1024, 2048, 4096, 8192, 16384].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</SelectControl>
|
||||
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Fill"
|
||||
active={current.fillGradient}
|
||||
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Heatmap"
|
||||
active={current.heatmap}
|
||||
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Tilt"
|
||||
value={current.tiltDbPerOctave}
|
||||
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
|
||||
min={0}
|
||||
max={6}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Heat Tilt"
|
||||
value={current.heatmapTiltDbPerOctave}
|
||||
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
|
||||
min={0}
|
||||
max={6}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
disabled={!current.heatmap}
|
||||
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Smoothing"
|
||||
value={current.smoothing}
|
||||
valueLabel={current.smoothing.toFixed(2)}
|
||||
min={0}
|
||||
max={0.99}
|
||||
step={0.01}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'oscilloscope' && (() => {
|
||||
const current = settings as ScopeSettings['oscilloscope']
|
||||
return (
|
||||
<>
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Pitch Lock"
|
||||
active={current.pitchLock}
|
||||
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Underfill"
|
||||
active={current.underfillEnabled}
|
||||
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Line Width"
|
||||
value={current.lineWidth}
|
||||
valueLabel={`${current.lineWidth.toFixed(1)} px`}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'vectorscope' && (() => {
|
||||
const current = settings as ScopeSettings['vectorscope']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
|
||||
>
|
||||
<option value="lissajous">Lissajous</option>
|
||||
<option value="polar-unipolar">Polar (Uni)</option>
|
||||
<option value="polar-bipolar">Polar (Bi)</option>
|
||||
<option value="linear-unipolar">Linear (Uni)</option>
|
||||
<option value="linear-bipolar">Linear (Bi)</option>
|
||||
</SelectControl>
|
||||
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="RGB"
|
||||
active={current.multiband}
|
||||
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Persistence"
|
||||
value={current.persistence}
|
||||
valueLabel={current.persistence.toFixed(2)}
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Line Width"
|
||||
value={current.lineWidth}
|
||||
valueLabel={`${current.lineWidth.toFixed(1)} px`}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'spectrogram' && (() => {
|
||||
const current = settings as ScopeSettings['spectrogram']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="FFT Size"
|
||||
value={current.fftSize}
|
||||
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
|
||||
>
|
||||
{[512, 1024, 2048, 4096].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Scale"
|
||||
value={current.scaleMode}
|
||||
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
|
||||
>
|
||||
<option value="log">Log</option>
|
||||
<option value="mel">Mel</option>
|
||||
<option value="linear">Linear</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Clarity"
|
||||
value={current.clarityMode}
|
||||
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="sharp">Sharp</option>
|
||||
<option value="sharper">Sharper</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Color"
|
||||
value={current.colorScheme}
|
||||
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
|
||||
>
|
||||
<option value="heat">Heat</option>
|
||||
<option value="mono">Mono</option>
|
||||
</SelectControl>
|
||||
|
||||
<RangeControl
|
||||
label="Speed"
|
||||
value={current.scrollSpeed}
|
||||
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'vumeter' && (() => {
|
||||
const current = settings as ScopeSettings['vumeter']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="needle">Needle</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Orientation"
|
||||
value={current.orientation}
|
||||
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
|
||||
>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
<option value="vertical">Vertical</option>
|
||||
</SelectControl>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'lufsmeter' && (() => {
|
||||
const current = settings as ScopeSettings['lufsmeter']
|
||||
return (
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
</SelectControl>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'waveform' && (() => {
|
||||
const current = settings as ScopeSettings['waveform']
|
||||
return (
|
||||
<>
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Multiband"
|
||||
active={current.multiband}
|
||||
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Gain"
|
||||
value={current.gainDb}
|
||||
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('waveform', { gainDb: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Speed"
|
||||
value={current.scrollSpeed}
|
||||
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,494 +1,29 @@
|
||||
import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react'
|
||||
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { useMemo, type CSSProperties, type JSX } from 'react'
|
||||
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
spectrum: 'Spectrum',
|
||||
oscilloscope: 'Oscilloscope',
|
||||
vectorscope: 'Vectorscope',
|
||||
spectrogram: 'Spectrogram',
|
||||
vumeter: 'VU Meter',
|
||||
lufsmeter: 'LUFS Meter',
|
||||
waveform: 'Waveform',
|
||||
}
|
||||
|
||||
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
|
||||
switch (mode) {
|
||||
case 'lissajous':
|
||||
return 'Lissajous'
|
||||
case 'polar-unipolar':
|
||||
return 'Polar Uni'
|
||||
case 'polar-bipolar':
|
||||
return 'Polar Bi'
|
||||
case 'linear-unipolar':
|
||||
return 'Linear Uni'
|
||||
case 'linear-bipolar':
|
||||
return 'Linear Bi'
|
||||
}
|
||||
}
|
||||
|
||||
function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
|
||||
switch (kind) {
|
||||
case 'spectrum': {
|
||||
const scopeSettings = settings as ScopeSettings['spectrum']
|
||||
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
|
||||
}
|
||||
case 'oscilloscope': {
|
||||
const scopeSettings = settings as ScopeSettings['oscilloscope']
|
||||
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
|
||||
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
|
||||
}
|
||||
case 'vectorscope': {
|
||||
const scopeSettings = settings as ScopeSettings['vectorscope']
|
||||
return scopeSettings.multiband
|
||||
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
|
||||
: vectorscopeModeLabel(scopeSettings.mode)
|
||||
}
|
||||
case 'spectrogram': {
|
||||
const scopeSettings = settings as ScopeSettings['spectrogram']
|
||||
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
|
||||
}
|
||||
case 'vumeter': {
|
||||
const scopeSettings = settings as ScopeSettings['vumeter']
|
||||
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
|
||||
}
|
||||
case 'lufsmeter':
|
||||
return 'Bar Meter'
|
||||
case 'waveform': {
|
||||
const scopeSettings = settings as ScopeSettings['waveform']
|
||||
return scopeSettings.multiband
|
||||
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
|
||||
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToggleChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectControl({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
children: ReactNode
|
||||
onChange: (value: string) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="settings-control">
|
||||
<span className="settings-control__label">{label}</span>
|
||||
<select
|
||||
className="settings-control__select"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeControl({
|
||||
label,
|
||||
value,
|
||||
valueLabel,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
valueLabel: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
fullWidth?: boolean
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
|
||||
<span className="settings-control__label">
|
||||
{label}
|
||||
<span className="settings-control__value">{valueLabel}</span>
|
||||
</span>
|
||||
<input
|
||||
className="settings-control__range"
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeSettingsSection({
|
||||
kind,
|
||||
settings,
|
||||
onUpdate,
|
||||
}: {
|
||||
kind: ScopeKind
|
||||
settings: ScopeSettings
|
||||
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
|
||||
}): JSX.Element {
|
||||
const scopeSettings = settings[kind]
|
||||
|
||||
return (
|
||||
<section className="settings-scope-section">
|
||||
<div className="settings-scope-section__header">
|
||||
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
|
||||
<div className="settings-scope-section__summary">{scopeSummary(kind, scopeSettings)}</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-section__controls">
|
||||
{kind === 'spectrum' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['spectrum']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="FFT Size"
|
||||
value={current.fftSize}
|
||||
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
|
||||
>
|
||||
{[1024, 2048, 4096, 8192, 16384].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</SelectControl>
|
||||
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Fill"
|
||||
active={current.fillGradient}
|
||||
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Heatmap"
|
||||
active={current.heatmap}
|
||||
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Tilt"
|
||||
value={current.tiltDbPerOctave}
|
||||
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
|
||||
min={0}
|
||||
max={6}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Heat Tilt"
|
||||
value={current.heatmapTiltDbPerOctave}
|
||||
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
|
||||
min={0}
|
||||
max={6}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
disabled={!current.heatmap}
|
||||
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Smoothing"
|
||||
value={current.smoothing}
|
||||
valueLabel={current.smoothing.toFixed(2)}
|
||||
min={0}
|
||||
max={0.99}
|
||||
step={0.01}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'oscilloscope' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['oscilloscope']
|
||||
return (
|
||||
<>
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Pitch Lock"
|
||||
active={current.pitchLock}
|
||||
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Underfill"
|
||||
active={current.underfillEnabled}
|
||||
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Line Width"
|
||||
value={current.lineWidth}
|
||||
valueLabel={`${current.lineWidth.toFixed(1)} px`}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'vectorscope' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['vectorscope']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
|
||||
>
|
||||
<option value="lissajous">Lissajous</option>
|
||||
<option value="polar-unipolar">Polar (Uni)</option>
|
||||
<option value="polar-bipolar">Polar (Bi)</option>
|
||||
<option value="linear-unipolar">Linear (Uni)</option>
|
||||
<option value="linear-bipolar">Linear (Bi)</option>
|
||||
</SelectControl>
|
||||
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="RGB"
|
||||
active={current.multiband}
|
||||
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Grid"
|
||||
active={current.showGrid}
|
||||
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Persistence"
|
||||
value={current.persistence}
|
||||
valueLabel={current.persistence.toFixed(2)}
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Line Width"
|
||||
value={current.lineWidth}
|
||||
valueLabel={`${current.lineWidth.toFixed(1)} px`}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.5}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'spectrogram' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['spectrogram']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="FFT Size"
|
||||
value={current.fftSize}
|
||||
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
|
||||
>
|
||||
{[512, 1024, 2048, 4096].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Scale"
|
||||
value={current.scaleMode}
|
||||
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
|
||||
>
|
||||
<option value="log">Log</option>
|
||||
<option value="mel">Mel</option>
|
||||
<option value="linear">Linear</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Clarity"
|
||||
value={current.clarityMode}
|
||||
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="sharp">Sharp</option>
|
||||
<option value="sharper">Sharper</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Color"
|
||||
value={current.colorScheme}
|
||||
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
|
||||
>
|
||||
<option value="heat">Heat</option>
|
||||
<option value="mono">Mono</option>
|
||||
</SelectControl>
|
||||
|
||||
<RangeControl
|
||||
label="Speed"
|
||||
value={current.scrollSpeed}
|
||||
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'vumeter' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['vumeter']
|
||||
return (
|
||||
<>
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="needle">Needle</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Orientation"
|
||||
value={current.orientation}
|
||||
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
|
||||
>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
<option value="vertical">Vertical</option>
|
||||
</SelectControl>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'lufsmeter' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['lufsmeter']
|
||||
return (
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
</SelectControl>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'waveform' && (() => {
|
||||
const current = scopeSettings as ScopeSettings['waveform']
|
||||
return (
|
||||
<>
|
||||
<div className="settings-chip-row settings-control--full">
|
||||
<ToggleChip
|
||||
label="Multiband"
|
||||
active={current.multiband}
|
||||
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RangeControl
|
||||
label="Gain"
|
||||
value={current.gainDb}
|
||||
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('waveform', { gainDb: value })}
|
||||
/>
|
||||
|
||||
<RangeControl
|
||||
label="Speed"
|
||||
value={current.scrollSpeed}
|
||||
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
fullWidth={false}
|
||||
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
import ScopeSettingsSection from './ScopeSettingsSection'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
export default function SettingsPanel(): JSX.Element {
|
||||
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
|
||||
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights, scopePopouts } = useSettingsStore()
|
||||
|
||||
const visibleScopes = useMemo(
|
||||
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
|
||||
[scopeOrder, hiddenScopes],
|
||||
const dockedScopes = useMemo(
|
||||
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind) && !scopePopouts[kind]?.poppedOut),
|
||||
[hiddenScopes, scopeOrder, scopePopouts],
|
||||
)
|
||||
const scopeTrackStyle = useMemo(() => {
|
||||
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
|
||||
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
|
||||
if (!gridTemplateColumns) return undefined
|
||||
return { gridTemplateColumns } as CSSProperties
|
||||
}, [visibleScopes, widthWeights])
|
||||
}, [dockedScopes, widthWeights])
|
||||
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-panel__scope-track" style={scopeTrackStyle}>
|
||||
{visibleScopes.map((kind) => (
|
||||
{dockedScopes.map((kind) => (
|
||||
<ScopeSettingsSection
|
||||
key={kind}
|
||||
kind={kind}
|
||||
settings={scopeSettings}
|
||||
settings={scopeSettings[kind]}
|
||||
onUpdate={updateScopeSettings}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
|
||||
import type { WindowBounds } from '../../types/popout'
|
||||
import ScopeModule from './ScopeModule'
|
||||
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
@@ -9,44 +10,46 @@ import { audioRouter } from '../audio/AudioRouter'
|
||||
export default function Strip(): JSX.Element {
|
||||
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
|
||||
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
|
||||
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
|
||||
const widthWeights = useSettingsStore((s) => s.widthWeights)
|
||||
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
|
||||
const popOutScope = useSettingsStore((s) => s.popOutScope)
|
||||
const accent = useThemeStore((s) => s.accent)
|
||||
const stripRef = useRef<HTMLDivElement>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
|
||||
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
|
||||
const visibleScopes = useMemo(
|
||||
() => scopeOrder.filter((k) => !hiddenScopes.has(k)),
|
||||
[hiddenScopes, scopeOrder],
|
||||
const dockedScopes = useMemo(
|
||||
() => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut),
|
||||
[hiddenScopes, scopeOrder, scopePopouts],
|
||||
)
|
||||
const visibleScopeKey = useMemo(() => visibleScopes.join('|'), [visibleScopes])
|
||||
const visibleScopeKey = useMemo(() => dockedScopes.join('|'), [dockedScopes])
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
|
||||
}, [visibleScopes, widthWeights])
|
||||
return buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
|
||||
}, [dockedScopes, widthWeights])
|
||||
const gridStyle = useMemo(() => {
|
||||
if (!gridTemplateColumns) return undefined
|
||||
return { gridTemplateColumns } as CSSProperties
|
||||
}, [gridTemplateColumns])
|
||||
|
||||
const updateHandleOffsets = useCallback((): void => {
|
||||
if (visibleScopes.length < 2) {
|
||||
if (dockedScopes.length < 2) {
|
||||
setHandleOffsets([])
|
||||
return
|
||||
}
|
||||
|
||||
const nextOffsets: number[] = []
|
||||
for (let index = 0; index < visibleScopes.length - 1; index += 1) {
|
||||
const leftElement = scopeRefs.current[visibleScopes[index]]
|
||||
for (let index = 0; index < dockedScopes.length - 1; index += 1) {
|
||||
const leftElement = scopeRefs.current[dockedScopes[index]]
|
||||
if (!leftElement) continue
|
||||
nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth)
|
||||
}
|
||||
|
||||
setHandleOffsets(nextOffsets)
|
||||
}, [visibleScopes])
|
||||
}, [dockedScopes])
|
||||
|
||||
useEffect(() => {
|
||||
const visibleScopeSet = new Set(visibleScopes)
|
||||
const visibleScopeSet = new Set(dockedScopes)
|
||||
audioRouter.setVisualizerConsumerDemand('docked-strip', {
|
||||
spectrum: visibleScopeSet.has('spectrum'),
|
||||
oscilloscope: visibleScopeSet.has('oscilloscope'),
|
||||
@@ -60,7 +63,7 @@ export default function Strip(): JSX.Element {
|
||||
return () => {
|
||||
audioRouter.clearVisualizerConsumerDemand('docked-strip')
|
||||
}
|
||||
}, [visibleScopeKey, visibleScopes])
|
||||
}, [visibleScopeKey, dockedScopes])
|
||||
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current
|
||||
@@ -97,7 +100,7 @@ export default function Strip(): JSX.Element {
|
||||
observer?.observe(gridRef.current)
|
||||
}
|
||||
|
||||
for (const scope of visibleScopes) {
|
||||
for (const scope of dockedScopes) {
|
||||
const element = scopeRefs.current[scope]
|
||||
if (element) {
|
||||
observer?.observe(element)
|
||||
@@ -110,11 +113,11 @@ export default function Strip(): JSX.Element {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', updateHandleOffsets)
|
||||
}
|
||||
}, [gridTemplateColumns, updateHandleOffsets, visibleScopes])
|
||||
}, [dockedScopes, gridTemplateColumns, updateHandleOffsets])
|
||||
|
||||
const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const leftKind = visibleScopes[handleIndex]
|
||||
const rightKind = visibleScopes[handleIndex + 1]
|
||||
const leftKind = dockedScopes[handleIndex]
|
||||
const rightKind = dockedScopes[handleIndex + 1]
|
||||
if (!leftKind || !rightKind) return
|
||||
|
||||
const leftElement = scopeRefs.current[leftKind]
|
||||
@@ -164,12 +167,30 @@ export default function Strip(): JSX.Element {
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}, [setScopeWidthWeight, visibleScopes])
|
||||
}, [dockedScopes, setScopeWidthWeight])
|
||||
|
||||
const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise<void> => {
|
||||
const element = scopeRefs.current[kind]
|
||||
const rect = element?.getBoundingClientRect()
|
||||
const windowBounds = await window.electronAPI.getWindowBounds()
|
||||
|
||||
let nextBounds: WindowBounds | undefined
|
||||
if (rect && windowBounds) {
|
||||
nextBounds = {
|
||||
x: Math.round(windowBounds.x + rect.left),
|
||||
y: Math.round(windowBounds.y + rect.top),
|
||||
width: Math.max(220, Math.round(rect.width)),
|
||||
height: Math.max(160, Math.round(rect.height)),
|
||||
}
|
||||
}
|
||||
|
||||
popOutScope(kind, nextBounds)
|
||||
}, [popOutScope])
|
||||
|
||||
return (
|
||||
<div ref={stripRef} className="scope-strip">
|
||||
<div ref={gridRef} className="scope-strip__grid" style={gridStyle}>
|
||||
{visibleScopes.map((kind) => (
|
||||
{dockedScopes.map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
ref={(element) => {
|
||||
@@ -177,6 +198,19 @@ export default function Strip(): JSX.Element {
|
||||
}}
|
||||
className="scope-strip__cell"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="scope-strip__popout-button"
|
||||
onClick={() => {
|
||||
void handlePopoutScope(kind)
|
||||
}}
|
||||
aria-label={`Pop out ${SCOPE_LABELS[kind]}`}
|
||||
title={`Pop out ${SCOPE_LABELS[kind]}`}
|
||||
>
|
||||
<span className="scope-strip__popout-icon" aria-hidden="true">
|
||||
↗
|
||||
</span>
|
||||
</button>
|
||||
<ScopeModule
|
||||
scopeKind={kind}
|
||||
lineColor={accent}
|
||||
@@ -185,14 +219,14 @@ export default function Strip(): JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visibleScopes.length > 1 && handleOffsets.map((offset, index) => (
|
||||
{dockedScopes.length > 1 && handleOffsets.map((offset, index) => (
|
||||
<button
|
||||
key={`${visibleScopes[index]}:${visibleScopes[index + 1]}`}
|
||||
key={`${dockedScopes[index]}:${dockedScopes[index + 1]}`}
|
||||
type="button"
|
||||
className="scope-strip__resize-handle"
|
||||
style={{ left: `${offset}px` }}
|
||||
onMouseDown={(event) => startResizeDrag(index, event)}
|
||||
aria-label={`Resize between ${visibleScopes[index]} and ${visibleScopes[index + 1]}`}
|
||||
aria-label={`Resize between ${dockedScopes[index]} and ${dockedScopes[index + 1]}`}
|
||||
>
|
||||
<span className="scope-strip__resize-handle-grip" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -68,6 +68,8 @@ interface ToolbarProps {
|
||||
settingsOpen: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
|
||||
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
|
||||
const profiles = useSettingsStore((s) => s.profiles)
|
||||
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
|
||||
@@ -78,11 +80,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
|
||||
const [showReposition, setShowReposition] = useState(false)
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
|
||||
const profileButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
|
||||
@@ -90,26 +89,69 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
// Close profile menu on outside click
|
||||
useEffect(() => {
|
||||
if (!showProfileMenu) return
|
||||
const handleClick = (e: MouseEvent): void => {
|
||||
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) {
|
||||
setShowProfileMenu(false)
|
||||
setRenamingId(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showProfileMenu])
|
||||
const handleSaveNew = useCallback(() => {
|
||||
const count = Object.keys(useSettingsStore.getState().profiles).length
|
||||
saveProfile(`Profile ${count}`)
|
||||
setIsProfileMenuOpen(false)
|
||||
}, [saveProfile])
|
||||
|
||||
// Focus rename input when it appears
|
||||
useEffect(() => {
|
||||
if (renamingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus()
|
||||
renameInputRef.current.select()
|
||||
const handleSaveOverwrite = useCallback(() => {
|
||||
updateActiveProfile()
|
||||
setIsProfileMenuOpen(false)
|
||||
}, [updateActiveProfile])
|
||||
|
||||
const handleRenameActive = useCallback((id: string) => {
|
||||
const profile = useSettingsStore.getState().profiles[id]
|
||||
if (!profile || id === DEFAULT_PROFILE_ID) {
|
||||
setIsProfileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
}, [renamingId])
|
||||
|
||||
const nextName = window.prompt('Rename preset', profile.name)?.trim()
|
||||
if (nextName) {
|
||||
renameProfile(id, nextName)
|
||||
}
|
||||
setIsProfileMenuOpen(false)
|
||||
}, [renameProfile])
|
||||
|
||||
const handleDeleteActive = useCallback((id: string) => {
|
||||
const profile = useSettingsStore.getState().profiles[id]
|
||||
if (!profile || id === DEFAULT_PROFILE_ID) {
|
||||
setIsProfileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm(`Delete "${profile.name}"?`)) {
|
||||
setIsProfileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
deleteProfile(id)
|
||||
setIsProfileMenuOpen(false)
|
||||
}, [deleteProfile])
|
||||
|
||||
useEffect(() => {
|
||||
const offClosed = window.electronAPI.onProfileMenuClosed(() => {
|
||||
setIsProfileMenuOpen(false)
|
||||
})
|
||||
const offLoad = window.electronAPI.onProfileMenuLoad((id) => {
|
||||
loadProfile(id)
|
||||
setIsProfileMenuOpen(false)
|
||||
})
|
||||
const offSaveNew = window.electronAPI.onProfileMenuSaveNew(handleSaveNew)
|
||||
const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(handleSaveOverwrite)
|
||||
const offRename = window.electronAPI.onProfileMenuRenameActive(handleRenameActive)
|
||||
const offDelete = window.electronAPI.onProfileMenuDeleteActive(handleDeleteActive)
|
||||
|
||||
return () => {
|
||||
offClosed()
|
||||
offLoad()
|
||||
offSaveNew()
|
||||
offSaveOverwrite()
|
||||
offRename()
|
||||
offDelete()
|
||||
}
|
||||
}, [handleDeleteActive, handleRenameActive, handleSaveNew, handleSaveOverwrite, loadProfile])
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
window.electronAPI.toggleAlwaysOnTop()
|
||||
@@ -120,30 +162,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
setShowReposition(false)
|
||||
}, [])
|
||||
|
||||
const handleSaveNew = useCallback(() => {
|
||||
const count = Object.keys(profiles).length
|
||||
saveProfile(`Profile ${count}`)
|
||||
setShowProfileMenu(false)
|
||||
}, [profiles, saveProfile])
|
||||
const handleOpenProfileMenu = useCallback(() => {
|
||||
const buttonRect = profileButtonRef.current?.getBoundingClientRect()
|
||||
if (!buttonRect) return
|
||||
|
||||
const handleSaveOverwrite = useCallback(() => {
|
||||
updateActiveProfile()
|
||||
setShowProfileMenu(false)
|
||||
}, [updateActiveProfile])
|
||||
setShowReposition(false)
|
||||
setIsProfileMenuOpen(true)
|
||||
window.electronAPI.openProfileMenu({
|
||||
x: Math.round(buttonRect.left),
|
||||
y: Math.round(buttonRect.bottom + 4),
|
||||
activeProfileId,
|
||||
profiles: Object.entries(profiles).map(([id, profile]) => ({
|
||||
id,
|
||||
name: profile.name,
|
||||
isDefault: id === DEFAULT_PROFILE_ID,
|
||||
})),
|
||||
})
|
||||
}, [activeProfileId, profiles])
|
||||
|
||||
const handleStartRename = useCallback((id: string, currentName: string) => {
|
||||
setRenamingId(id)
|
||||
setRenameValue(currentName)
|
||||
}, [])
|
||||
|
||||
const handleFinishRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
renameProfile(renamingId, renameValue.trim())
|
||||
}
|
||||
setRenamingId(null)
|
||||
}, [renamingId, renameValue, renameProfile])
|
||||
|
||||
const profileIds = Object.keys(profiles)
|
||||
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
|
||||
|
||||
return (
|
||||
@@ -164,11 +200,12 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
<span className="toolbar__brand-text">Prism</span>
|
||||
</div>
|
||||
|
||||
<div className="toolbar__profile" ref={profileMenuRef}>
|
||||
<div className="toolbar__profile">
|
||||
<button
|
||||
ref={profileButtonRef}
|
||||
type="button"
|
||||
className={`toolbar__profile-button ${showProfileMenu ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => setShowProfileMenu((prev) => !prev)}
|
||||
className={`toolbar__profile-button ${isProfileMenuOpen ? 'is-active' : ''}`.trim()}
|
||||
onClick={handleOpenProfileMenu}
|
||||
title="Presets"
|
||||
>
|
||||
<span className="toolbar__profile-name">
|
||||
@@ -176,95 +213,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
</span>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
|
||||
{showProfileMenu && (
|
||||
<div className="toolbar__profile-menu">
|
||||
<div className="toolbar__profile-menu-section">
|
||||
<div className="toolbar__profile-menu-label">Presets</div>
|
||||
{profileIds.map((id) => {
|
||||
const profile = profiles[id]
|
||||
const isActive = id === activeProfileId
|
||||
const isDefault = id === 'profile_default'
|
||||
|
||||
if (renamingId === id) {
|
||||
return (
|
||||
<div key={id} className="toolbar__profile-menu-item">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="toolbar__profile-rename-input"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleFinishRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleFinishRename()
|
||||
if (e.key === 'Escape') setRenamingId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`toolbar__profile-menu-item ${isActive ? 'is-active' : ''}`.trim()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-item-name"
|
||||
onClick={() => {
|
||||
loadProfile(id)
|
||||
setShowProfileMenu(false)
|
||||
}}
|
||||
>
|
||||
{isActive && <span className="toolbar__profile-check">✓</span>}
|
||||
{profile.name}
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<div className="toolbar__profile-menu-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-action"
|
||||
onClick={() => handleStartRename(id, profile.name)}
|
||||
title="Rename"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
|
||||
onClick={() => deleteProfile(id)}
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="toolbar__profile-menu-divider" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-action-row"
|
||||
onClick={handleSaveNew}
|
||||
>
|
||||
Save as New Preset
|
||||
</button>
|
||||
{activeProfileId && (
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__profile-menu-action-row"
|
||||
onClick={handleSaveOverwrite}
|
||||
>
|
||||
Save to "{activeProfile?.name}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user