@moises.ai/design-system 3.11.1 → 3.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moises.ai/design-system",
3
- "version": "3.11.1",
3
+ "version": "3.11.3",
4
4
  "description": "Design System package based on @radix-ui/themes with custom defaults",
5
5
  "private": false,
6
6
  "type": "module",
@@ -0,0 +1,205 @@
1
+ import { memo, useCallback, useRef, useState } from 'react'
2
+ import styles from './InputLevelMeter.module.css'
3
+
4
+ const SEGMENT_MIN = 5
5
+ const SEGMENT_RANGE = 25
6
+ const UNITY_GAIN_POS = 0.667
7
+ const MAX_BOOST_DB = 6
8
+
9
+ function linearToMeterLevel(linear) {
10
+ const peakDb = 20 * Math.log10(Math.max(linear, 1e-6))
11
+ return Math.max(0, Math.min(30, (peakDb + 60) / 2))
12
+ }
13
+
14
+ function clampLevel(level) {
15
+ if (!Number.isFinite(level)) return 0
16
+ return Math.max(0, Math.min(30, level))
17
+ }
18
+
19
+ function clampPeakDb(peakDb) {
20
+ if (!Number.isFinite(peakDb)) return Number.NEGATIVE_INFINITY
21
+ return Math.max(-96, peakDb)
22
+ }
23
+
24
+ function getNormalizedLevel(level) {
25
+ return Math.max(
26
+ 0,
27
+ Math.min(100, ((clampLevel(level) - SEGMENT_MIN) / SEGMENT_RANGE) * 100),
28
+ )
29
+ }
30
+
31
+ function getPeakHoldColor(peakPct) {
32
+ if (peakPct >= 88) return 'rgba(254, 78, 84, 0.89)'
33
+ if (peakPct >= 69) return 'rgb(255, 197, 61)'
34
+ return '#00dae8'
35
+ }
36
+
37
+ function gainToFaderPosition(gain) {
38
+ if (gain <= 0) return 0
39
+ const db = 20 * Math.log10(gain)
40
+ if (db <= -96) return 0
41
+ if (db <= 0) return ((db + 96) / 96) * UNITY_GAIN_POS
42
+ return (
43
+ UNITY_GAIN_POS +
44
+ (Math.min(db, MAX_BOOST_DB) / MAX_BOOST_DB) * (1 - UNITY_GAIN_POS)
45
+ )
46
+ }
47
+
48
+ function faderPositionToGain(pos) {
49
+ if (pos <= 0) return 0
50
+ let db
51
+ if (pos <= UNITY_GAIN_POS) {
52
+ db = (pos / UNITY_GAIN_POS) * 96 - 96
53
+ } else {
54
+ db = ((pos - UNITY_GAIN_POS) / (1 - UNITY_GAIN_POS)) * MAX_BOOST_DB
55
+ }
56
+ return 10 ** (db / 20)
57
+ }
58
+
59
+ function MeterRow({ level }) {
60
+ const pct = getNormalizedLevel(level)
61
+ return (
62
+ <div className={styles.barRow}>
63
+ <div className={styles.barTrack} />
64
+ <div
65
+ className={styles.barFill}
66
+ style={{
67
+ width: `${pct}%`,
68
+ backgroundSize: pct > 0 ? `${10000 / pct}% 100%` : '100% 100%',
69
+ }}
70
+ />
71
+ </div>
72
+ )
73
+ }
74
+
75
+ export const InputLevelMeter = memo(function InputLevelMeter({
76
+ linear = [0, 0],
77
+ volume = 1,
78
+ onVolumeChange,
79
+ showHandler = true,
80
+ showMeter = true,
81
+ }) {
82
+ const [linearL, linearR] = linear
83
+ const leftLevel = clampLevel(linearToMeterLevel(linearL ?? 0))
84
+ const rightLevel = clampLevel(linearToMeterLevel(linearR ?? 0))
85
+
86
+ const [isPressed, setIsPressed] = useState(false)
87
+ const isPressedRef = useRef(false)
88
+ const meterRef = useRef(null)
89
+ const peakHoldRef = useRef(0)
90
+
91
+ const faderPercent = Math.max(
92
+ 0,
93
+ Math.min(100, gainToFaderPosition(volume) * 100),
94
+ )
95
+
96
+ const leftNormalized = getNormalizedLevel(leftLevel)
97
+ const rightNormalized = getNormalizedLevel(rightLevel)
98
+ const currentPeakPct = Math.max(leftNormalized, rightNormalized)
99
+
100
+ if (currentPeakPct > peakHoldRef.current) {
101
+ peakHoldRef.current = currentPeakPct
102
+ }
103
+ if (currentPeakPct <= 0) {
104
+ peakHoldRef.current = 0
105
+ }
106
+
107
+ const peakHoldPct = peakHoldRef.current
108
+ const peakHoldColor = getPeakHoldColor(peakHoldPct)
109
+ const showPeakHold = peakHoldPct > 0
110
+
111
+ const handlePointerDown = useCallback(
112
+ (e) => {
113
+ if (!onVolumeChange) return
114
+ e.currentTarget.setPointerCapture(e.pointerId)
115
+ isPressedRef.current = true
116
+ setIsPressed(true)
117
+ const rect = meterRef.current?.getBoundingClientRect()
118
+ if (rect) {
119
+ const pos = Math.max(
120
+ 0,
121
+ Math.min(1, (e.clientX - rect.left) / rect.width),
122
+ )
123
+ onVolumeChange([faderPositionToGain(pos)])
124
+ }
125
+ },
126
+ [onVolumeChange],
127
+ )
128
+
129
+ const handlePointerMove = useCallback(
130
+ (e) => {
131
+ if (!isPressedRef.current || !onVolumeChange) return
132
+ const rect = meterRef.current?.getBoundingClientRect()
133
+ if (rect) {
134
+ const pos = Math.max(
135
+ 0,
136
+ Math.min(1, (e.clientX - rect.left) / rect.width),
137
+ )
138
+ onVolumeChange([faderPositionToGain(pos)])
139
+ }
140
+ },
141
+ [onVolumeChange],
142
+ )
143
+
144
+ const handlePointerUp = useCallback(() => {
145
+ isPressedRef.current = false
146
+ setIsPressed(false)
147
+ }, [])
148
+
149
+ const meterClassName = [styles.bars, isPressed ? styles.pressed : '']
150
+ .filter(Boolean)
151
+ .join(' ')
152
+
153
+ const hasHandler = onVolumeChange && showHandler
154
+
155
+ return (
156
+ <div
157
+ className={styles.container}
158
+ onMouseLeave={() => {
159
+ if (!isPressed) setIsPressed(false)
160
+ }}
161
+ onPointerDown={handlePointerDown}
162
+ onPointerMove={handlePointerMove}
163
+ onPointerUp={handlePointerUp}
164
+ >
165
+ <div ref={meterRef} className={meterClassName}>
166
+ <MeterRow level={leftLevel} />
167
+ <MeterRow level={rightLevel} />
168
+
169
+ {hasHandler && (
170
+ <div
171
+ className={`${styles.thumb} ${isPressed ? styles.pressed : ''}`}
172
+ style={{ left: `${faderPercent}%` }}
173
+ />
174
+ )}
175
+
176
+ {showPeakHold && (
177
+ <>
178
+ <div
179
+ className={styles.peakDotTop}
180
+ style={{
181
+ left: `min(calc(${peakHoldPct}% + 3px), calc(100% - 3px))`,
182
+ background: peakHoldColor,
183
+ }}
184
+ />
185
+ <div
186
+ className={styles.peakDotBottom}
187
+ style={{
188
+ left: `min(calc(${peakHoldPct}% + 3px), calc(100% - 3px))`,
189
+ background: peakHoldColor,
190
+ }}
191
+ />
192
+ </>
193
+ )}
194
+ </div>
195
+
196
+ {showMeter && (
197
+ <div
198
+ className={`${styles.zeroDbDot} ${isPressed ? styles.pressed : ''}`}
199
+ />
200
+ )}
201
+ </div>
202
+ )
203
+ })
204
+
205
+ InputLevelMeter.displayName = 'InputLevelMeter'
@@ -0,0 +1,111 @@
1
+ .container {
2
+ position: relative;
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: 4px;
6
+ align-items: flex-start;
7
+ width: 100%;
8
+ }
9
+
10
+ .bars {
11
+ position: relative;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 1px;
15
+ padding: 2px;
16
+ border-radius: 3px;
17
+ width: 100%;
18
+ background: rgba(216, 244, 246, 0.04);
19
+ transition: background-color 0.1s ease;
20
+ }
21
+
22
+ .bars.pressed {
23
+ background: rgba(221, 234, 248, 0.08);
24
+ }
25
+
26
+ .barRow {
27
+ display: inline-grid;
28
+ grid-template-rows: max-content;
29
+ place-items: start;
30
+ width: 100%;
31
+ position: relative;
32
+ line-height: 0;
33
+ }
34
+
35
+ .barTrack {
36
+ grid-column: 1;
37
+ grid-row: 1;
38
+ height: 3px;
39
+ width: 100%;
40
+ border-radius: 9999px;
41
+ }
42
+
43
+ .barFill {
44
+ grid-column: 1;
45
+ grid-row: 1;
46
+ height: 3px;
47
+ border-radius: 9999px;
48
+ background: linear-gradient(
49
+ 270deg,
50
+ rgba(254, 78, 84, 0.894) 12%,
51
+ rgb(255, 197, 61) 12%,
52
+ rgb(255, 197, 61) 31%,
53
+ rgb(0, 218, 232) 31%
54
+ );
55
+ will-change: width;
56
+ }
57
+
58
+ .thumb {
59
+ position: absolute;
60
+ top: 50%;
61
+ transform: translate(-50%, -50%);
62
+ width: 8px;
63
+ height: 19px;
64
+ border-radius: 9999px;
65
+ background: #edeef0;
66
+ cursor: ew-resize;
67
+ z-index: 2;
68
+ touch-action: none;
69
+ }
70
+
71
+ .thumb.pressed {
72
+ background: white;
73
+ }
74
+
75
+ .peakDot {
76
+ position: absolute;
77
+ width: 1px;
78
+ height: 3px;
79
+ border-radius: 9999px;
80
+ z-index: 1;
81
+ pointer-events: none;
82
+ }
83
+
84
+ .peakDotTop {
85
+ composes: peakDot;
86
+ top: calc(50% - 2px);
87
+ transform: translate(-50%, -50%);
88
+ }
89
+
90
+ .peakDotBottom {
91
+ composes: peakDot;
92
+ top: calc(50% + 2px);
93
+ transform: translate(-50%, -50%);
94
+ }
95
+
96
+ .zeroDbDot {
97
+ position: absolute;
98
+ left: 66.7%;
99
+ top: 12px;
100
+ width: 2px;
101
+ height: 2px;
102
+ border-radius: 50%;
103
+ background: rgba(255, 255, 255, 0.12);
104
+ pointer-events: none;
105
+ transform: translateX(-50%);
106
+ }
107
+
108
+ .zeroDbDot.pressed {
109
+ width: 3px;
110
+ height: 3px;
111
+ }
@@ -0,0 +1,27 @@
1
+ import { InputLevelMeter } from './InputLevelMeter'
2
+ import { useSimulatedInputLevel } from './useSimulatedInputLevel'
3
+
4
+ export default {
5
+ title: 'Components/InputLevelMeter',
6
+ component: InputLevelMeter,
7
+ tags: ['autodocs'],
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ decorators: [
12
+ (Story) => (
13
+ <div style={{ padding: '16px', background: '#1d1d1d', width: '200px' }}>
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ }
19
+
20
+ function AnimatedMeter() {
21
+ const linear = useSimulatedInputLevel()
22
+ return <InputLevelMeter linear={linear} />
23
+ }
24
+
25
+ export const Default = {
26
+ render: () => <AnimatedMeter />,
27
+ }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ export function useSimulatedInputLevel() {
4
+ const [linear, setLinear] = useState([0.5, 0.5])
5
+ const targetRef = useRef([0.5, 0.5])
6
+ const currentRef = useRef([0.5, 0.5])
7
+ const frameRef = useRef(null)
8
+ const tickRef = useRef(0)
9
+
10
+ useEffect(() => {
11
+ function animate() {
12
+ tickRef.current++
13
+
14
+ if (tickRef.current % 20 === 0) {
15
+ targetRef.current = [Math.random(), Math.random()]
16
+ }
17
+
18
+ currentRef.current = currentRef.current.map((cur, i) => {
19
+ const target = targetRef.current[i]
20
+ const speed = cur > target ? 0.15 : 0.08
21
+ return cur + (target - cur) * speed
22
+ })
23
+
24
+ setLinear([...currentRef.current])
25
+ frameRef.current = requestAnimationFrame(animate)
26
+ }
27
+
28
+ frameRef.current = requestAnimationFrame(animate)
29
+ return () => cancelAnimationFrame(frameRef.current)
30
+ }, [])
31
+
32
+ return linear
33
+ }
@@ -0,0 +1,82 @@
1
+ import { Flex, Text } from '@radix-ui/themes'
2
+ import { Popover } from 'radix-ui'
3
+ import { useCallback, useState } from 'react'
4
+ import { InputLevelMeter } from '../InputLevelMeter/InputLevelMeter'
5
+ import { Select } from '../Select/Select'
6
+ import styles from './RecordSettingsPopover.module.css'
7
+
8
+ export function RecordSettingsPopover({
9
+ isRecord,
10
+ device,
11
+ deviceOptions,
12
+ onDeviceChange,
13
+ channel,
14
+ channelOptions,
15
+ onChannelChange,
16
+ inputLevel,
17
+ children,
18
+ }) {
19
+ const [opened, setOpened] = useState(false)
20
+
21
+ const handleOpenChange = useCallback(
22
+ (open) => {
23
+ if (!open || !isRecord) {
24
+ setOpened(open)
25
+ }
26
+ },
27
+ [open, isRecord],
28
+ )
29
+
30
+ const handleContextMenu = useCallback(() => {
31
+ e.preventDefault()
32
+ setOpened(true)
33
+ }, [])
34
+
35
+ return (
36
+ <Popover.Root open={opened} onOpenChange={handleOpenChange}>
37
+ <Popover.Trigger asChild onContextMenu={handleContextMenu}>
38
+ {children}
39
+ </Popover.Trigger>
40
+
41
+ <Popover.Content width="360px">
42
+ <Flex className={styles.root} direction="column" gap="4">
43
+ <Flex direction="column" gap="1">
44
+ <Text size="1" weight="medium">
45
+ Source
46
+ </Text>
47
+
48
+ {deviceOptions?.length ? (
49
+ <Select
50
+ items={deviceOptions}
51
+ defaultValue={device}
52
+ onChange={onDeviceChange}
53
+ size="1"
54
+ />
55
+ ) : (
56
+ <Text size="1">No device options.</Text>
57
+ )}
58
+
59
+ {channelOptions?.length && (
60
+ <Select
61
+ items={channelOptions}
62
+ defaultValue={channel}
63
+ onChange={onChannelChange}
64
+ size="1"
65
+ />
66
+ )}
67
+ </Flex>
68
+
69
+ <Flex direction="column" gap="1">
70
+ <Text size="1" weight="medium">
71
+ Input Level
72
+ </Text>
73
+
74
+ {opened && (
75
+ <InputLevelMeter linear={inputLevel} showHandler={false} />
76
+ )}
77
+ </Flex>
78
+ </Flex>
79
+ </Popover.Content>
80
+ </Popover.Root>
81
+ )
82
+ }
@@ -0,0 +1,13 @@
1
+ .root {
2
+ width: 200px;
3
+ padding: var(--space-3);
4
+ border-radius: var(--radius-4, 8px);
5
+ border: 1px solid
6
+ var(--colors-neutral-neutral-alpha-3, rgba(221, 234, 248, 0.08));
7
+ background: var(--panel-solid, #18191b);
8
+
9
+ /* Shadows/shadow-5 */
10
+ box-shadow:
11
+ 0 12px 32px -16px var(--overlays-black-alpha-5, rgba(0, 0, 0, 0.3)),
12
+ 0 12px 60px 0 var(--overlays-black-alpha-3, rgba(0, 0, 0, 0.15));
13
+ }
@@ -9,29 +9,36 @@ import {
9
9
  Box,
10
10
  DropdownMenu,
11
11
  Flex,
12
- HorizontalVolume,
13
12
  IconButton,
13
+ InputLevelMeter,
14
14
  PanControl,
15
15
  Text,
16
16
  Tooltip,
17
17
  TrackControlsToggle,
18
18
  } from '../../index'
19
+ import { RecordSettingsPopover } from './RecordSettingsPopover'
19
20
  import styles from './TrackHeader.module.css'
20
21
 
22
+ const DND_PROTECTION = {
23
+ onPointerDown: (e) => e.stopPropagation(),
24
+ onMouseDown: (e) => e.stopPropagation(),
25
+ onTouchStart: (e) => e.stopPropagation(),
26
+ }
27
+
21
28
  export const TrackHeader = memo(
22
29
  ({
23
30
  // Config
24
31
  title,
25
32
  menuOptions,
26
- instrumentOptions,
27
- onInstrumentChange,
28
- instrumentSelected,
29
33
  showPan = true,
30
34
  showVolumeControls = true,
31
- isGrouped = false,
32
35
  height = 81,
33
36
  compact = false,
34
37
  isActive = false,
38
+ instrumentOptions,
39
+ deviceOptions,
40
+ channelOptions,
41
+ inputLevel: inputLevel,
35
42
 
36
43
  // State
37
44
  volume,
@@ -41,6 +48,9 @@ export const TrackHeader = memo(
41
48
  isAutoMuted,
42
49
  isSolo,
43
50
  isOpen,
51
+ instrumentSelected,
52
+ device,
53
+ channel,
44
54
 
45
55
  // State change listeners
46
56
  onVolumeChange,
@@ -49,6 +59,9 @@ export const TrackHeader = memo(
49
59
  onRecordChange,
50
60
  onSoloChange,
51
61
  onOpenChange,
62
+ onInstrumentChange,
63
+ onDeviceChange,
64
+ onChannelChange,
52
65
 
53
66
  // Children (having content means group/takelanes)
54
67
  children,
@@ -94,9 +107,7 @@ export const TrackHeader = memo(
94
107
  style={{ minWidth: 0, overflow: 'hidden' }}
95
108
  >
96
109
  <IconButton
97
- onPointerDown={(e) => e.stopPropagation()}
98
- onMouseDown={(e) => e.stopPropagation()}
99
- onTouchStart={(e) => e.stopPropagation()}
110
+ {...DND_PROTECTION}
100
111
  variant="ghost"
101
112
  size="1"
102
113
  onClick={handleToggleOpen}
@@ -142,39 +153,41 @@ export const TrackHeader = memo(
142
153
  </Flex>
143
154
 
144
155
  <Flex
156
+ {...DND_PROTECTION}
145
157
  direction="row"
146
- gap="3"
158
+ gap="2px"
147
159
  align="center"
148
- onPointerDown={(e) => e.stopPropagation()}
149
- onMouseDown={(e) => e.stopPropagation()}
150
- onTouchStart={(e) => e.stopPropagation()}
151
160
  >
152
- <Flex gap="2px">
161
+ <RecordSettingsPopover
162
+ isRecord={isRecord}
163
+ deviceOptions={deviceOptions}
164
+ device={device}
165
+ onDeviceChange={onDeviceChange}
166
+ channelOptions={channelOptions}
167
+ channel={channel}
168
+ onChannelChange={onChannelChange}
169
+ inputLevel={inputLevel}
170
+ >
153
171
  <TrackControlsToggle
154
172
  type="record"
155
173
  selected={isRecord}
156
- onClick={onRecordChange}
174
+ onClick={() => onRecordChange?.()}
157
175
  />
158
- <TrackControlsToggle
159
- type={
160
- isMuted ? 'mute' : isAutoMuted ? 'autoMute' : 'mute'
161
- }
162
- selected={isMuted || (!isSolo && isAutoMuted)}
163
- onClick={onMutedChange}
164
- />
165
- <TrackControlsToggle
166
- type="solo"
167
- selected={isSolo}
168
- onClick={onSoloChange}
169
- />
170
- </Flex>
176
+ </RecordSettingsPopover>
177
+ <TrackControlsToggle
178
+ type={isMuted ? 'mute' : isAutoMuted ? 'autoMute' : 'mute'}
179
+ selected={isMuted || (!isSolo && isAutoMuted)}
180
+ onClick={onMutedChange}
181
+ />
182
+ <TrackControlsToggle
183
+ type="solo"
184
+ selected={isSolo}
185
+ onClick={onSoloChange}
186
+ />
171
187
  </Flex>
172
188
 
173
189
  {menuOptions && (
174
- <Box
175
- onPointerDown={(e) => e.stopPropagation()}
176
- onMouseDown={(e) => e.stopPropagation()}
177
- onTouchStart={(e) => e.stopPropagation()}>
190
+ <Box {...DND_PROTECTION}>
178
191
  <DropdownMenu
179
192
  trigger={
180
193
  <IconButton
@@ -193,21 +206,16 @@ export const TrackHeader = memo(
193
206
  />
194
207
  </Box>
195
208
  )}
196
-
197
209
  </Flex>
198
210
  </div>
199
211
 
200
- <div
201
- className={styles.trackControls}
202
- onPointerDown={(e) => e.stopPropagation()}
203
- onMouseDown={(e) => e.stopPropagation()}
204
- onTouchStart={(e) => e.stopPropagation()}
205
- >
212
+ <div {...DND_PROTECTION} className={styles.trackControls}>
206
213
  {!compact && showVolumeControls && (
207
214
  <>
208
- <HorizontalVolume
215
+ <InputLevelMeter
216
+ linear={inputLevel}
209
217
  volume={volume}
210
- onChangeValue={onVolumeChange}
218
+ onVolumeChange={onVolumeChange}
211
219
  />
212
220
  {showPan && (
213
221
  <PanControl
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { BassIcon, DrumsIcon, ElectricGuitarIcon, KeysIcon } from '../../icons'
3
+ import { useSimulatedInputLevel } from '../InputLevelMeter/useSimulatedInputLevel'
3
4
  import { TrackHeader } from './TrackHeader'
4
5
  export default {
5
6
  title: 'Components/TrackHeader',
@@ -130,7 +131,33 @@ export const Default = {
130
131
  instrumentOptions,
131
132
  instrumentSelected: instrumentOptions[0],
132
133
  },
133
- render: (args) => <TrackHeader {...args} />,
134
+ render: (args) => {
135
+ const inputLevel = useSimulatedInputLevel()
136
+ const [isRecord, setIsRecord] = useState(false)
137
+ const [isMuted, setIsMuted] = useState(args.isMuted)
138
+ const [isSolo, setIsSolo] = useState(args.isSolo)
139
+
140
+ return (
141
+ <TrackHeader
142
+ {...args}
143
+ deviceOptions={[
144
+ { value: 'mic', label: 'Built-in Microphone' },
145
+ { value: 'interface', label: 'Audio Interface' },
146
+ ]}
147
+ channelOptions={[
148
+ { value: '1', label: 'Channel 1' },
149
+ { value: '2', label: 'Channel 2' },
150
+ ]}
151
+ isRecord={isRecord}
152
+ onRecordChange={() => setIsRecord((v) => !v)}
153
+ isMuted={isMuted}
154
+ onMutedChange={() => setIsMuted((v) => !v)}
155
+ isSolo={isSolo}
156
+ onSoloChange={() => setIsSolo((v) => !v)}
157
+ inputLevel={inputLevel}
158
+ />
159
+ )
160
+ },
134
161
  }
135
162
 
136
163
  export const Group = {