zds-pickers 4.1.3 → 4.1.5

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.
@@ -16,32 +16,30 @@ const baseNotes = [
16
16
  'A#',
17
17
  'B',
18
18
  ];
19
- // const { midiToFreq, midiToNoteName } = Midi
20
- // console.log(Note.names(), Note.midi('C4'))
21
- // const firstNote = 21
22
- // const lastNote = 108
23
19
  const numNotes = 12;
24
20
  const whiteNotes = numNotes * 0.7; // approx
25
21
  const audioContext = new window.AudioContext();
26
- const BaseOctavePlayer = ({ octave, height, width, ...rest }) => {
22
+ const BaseOctavePlayer = ({ className, octave, height, renderNoteLabel, width, ...rest }) => {
27
23
  const keyWidth = width / whiteNotes + 2;
28
24
  const keyWidthToHeight = keyWidth / height;
29
25
  const firstNote = Note.midi(baseNotes[0] + String(octave)) || 0;
30
26
  const lastNote = Note.midi(baseNotes[baseNotes.length - 1] + String(octave)) || 0;
31
- return (_jsx("div", { children: _jsx(ReactPiano, { keyWidthToHeight: keyWidthToHeight, noteRange: { first: firstNote, last: lastNote }, width: width, ...rest }) }));
27
+ return (_jsx("div", { children: _jsx(ReactPiano, { className: className, keyWidthToHeight: keyWidthToHeight, noteRange: { first: firstNote, last: lastNote }, renderNoteLabel: renderNoteLabel, width: width, ...rest }) }));
32
28
  };
33
- const WithProvider = ({ format, height, hostname, instrumentName, octave = 4, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, selectedNotes, soundfont, Tooltip, width, }) => (_jsx(SoundfontProvider, { audioContext,
29
+ const WithProvider = ({ className, format, height, hostname, instrumentName, octave = 4, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, renderNoteLabel, selectedNotes, soundfont, Tooltip, width, }) => (_jsx(SoundfontProvider, { audioContext,
34
30
  format,
35
31
  hostname,
36
32
  instrumentName,
37
33
  onChange,
38
- soundfont, render: ({ isLoading, playNote, stopNote }) => (_jsx(BaseOctavePlayer, { disabled: isLoading, height,
34
+ soundfont, render: ({ isLoading, playNote, stopNote }) => (_jsx(BaseOctavePlayer, { disabled: isLoading, className,
35
+ height,
39
36
  octave,
40
37
  onClick,
41
38
  onDoubleClick,
42
39
  onKeyMouseEnter,
43
40
  onKeyMouseLeave,
44
41
  playNote,
42
+ renderNoteLabel,
45
43
  selectedNotes,
46
44
  stopNote,
47
45
  Tooltip,
@@ -9,90 +9,104 @@ if (typeof window !== 'undefined') {
9
9
  window.MIDI = { Soundfont: {} };
10
10
  }
11
11
  }
12
- // Also ensure it's available globally (not just on window)
13
- if (typeof globalThis !== 'undefined') {
14
- if (!globalThis.MIDI) {
15
- globalThis.MIDI = window.MIDI || { Soundfont: {} };
16
- }
17
- }
18
- // Also set it on the global object for Node.js compatibility
19
- if (typeof global !== 'undefined') {
20
- if (!global.MIDI) {
21
- global.MIDI = window.MIDI || { Soundfont: {} };
22
- }
12
+ // Also ensure it's on globalThis (browser sync + Node/SSR; avoid bare `global`)
13
+ if (typeof globalThis !== 'undefined' && !globalThis.MIDI) {
14
+ globalThis.MIDI =
15
+ typeof window !== 'undefined' && window.MIDI
16
+ ? window.MIDI
17
+ : { Soundfont: {} };
23
18
  }
19
+ // CRITICAL: Import at top level to ensure bundler processes it
24
20
  // Import bundled soundfont to avoid CSP issues in Electron apps
25
21
  // NOTE: This adds ~2.2MB to the bundle size. Currently all consumers need piano,
26
22
  // but if future web apps don't need audio, consider making this import conditional
27
23
  // or implementing lazy loading to reduce bundle size for audio-free use cases.
28
24
  import '../soundfonts/acoustic_grand_piano-mp3';
29
25
  const isDefined = (item) => item !== undefined && item !== null && !Number.isNaN(item);
26
+ const midiToNoteName = (midiNumber) => {
27
+ const num = typeof midiNumber === 'string'
28
+ ? Number.parseInt(midiNumber, 10)
29
+ : midiNumber;
30
+ return Note.fromMidi(num);
31
+ };
32
+ const createBundledPlayer = (soundfontData) => {
33
+ const player = {
34
+ play: (note, velocity) => {
35
+ const noteName = midiToNoteName(note);
36
+ const noteData = soundfontData[noteName];
37
+ if (!noteData) {
38
+ return { stop: () => { } };
39
+ }
40
+ const audio = new Audio(noteData);
41
+ audio.volume = (velocity ?? 127) / 127;
42
+ audio.play();
43
+ return {
44
+ stop: () => {
45
+ audio.pause();
46
+ audio.currentTime = 0;
47
+ },
48
+ };
49
+ },
50
+ stop: () => [],
51
+ };
52
+ return player;
53
+ };
54
+ const loadBundledOrRemoteInstrument = (audioContext, instrumentName, format, soundfont, hostname) => {
55
+ const soundfontData = window.MIDI?.Soundfont?.[instrumentName];
56
+ if (soundfontData) {
57
+ return Promise.resolve(createBundledPlayer(soundfontData));
58
+ }
59
+ // biome-ignore lint/suspicious/noConsole: intentional diagnostics for missing bundled soundfont
60
+ console.warn(`[zds-pickers] Bundled soundfont "${instrumentName}" not found; falling back to remote load.`);
61
+ if (isDefined(hostname)) {
62
+ return Soundfont.instrument(audioContext, instrumentName, {
63
+ format,
64
+ soundfont,
65
+ nameToUrl: (name, sf, fmt) => `${hostname}/${sf}/${name}-${fmt}.js`,
66
+ });
67
+ }
68
+ return Soundfont.instrument(audioContext, instrumentName, {
69
+ format,
70
+ soundfont,
71
+ });
72
+ };
30
73
  const SoundfontProvider = (props) => {
31
74
  const { audioContext, format, hostname, instrumentName, onChange, render, soundfont, } = props;
32
75
  const [isLoading, setIsLoading] = useState(true);
33
76
  const [activeAudioNodes, setActiveAudioNodes] = useState({});
34
77
  const [instrument, setInstrument] = useState(null);
35
78
  const loadInstrument = useCallback((nm) => {
36
- // Re-trigger loading state
37
79
  setInstrument(null);
80
+ setIsLoading(true);
81
+ if (!instrumentName) {
82
+ setIsLoading(false);
83
+ return;
84
+ }
38
85
  if (isDefined(hostname)) {
39
- // pull from an url
40
86
  Soundfont.instrument(audioContext, nm, {
41
87
  format,
42
88
  soundfont,
43
89
  nameToUrl: (name, sf, fmt) => `${hostname}/${sf}/${name}-${fmt}.js`,
44
- }).then((newInstrument) => {
90
+ })
91
+ .then(newInstrument => {
45
92
  setInstrument(newInstrument);
46
- });
47
- }
48
- else if (instrumentName) {
49
- // the host app has already injected the soundfont into global space
50
- // Try to create a player directly from our bundled soundfont data
51
- const soundfontData = window.MIDI?.Soundfont?.[instrumentName];
52
- if (soundfontData) {
53
- // MIDI note number to note name conversion using Tonal
54
- const midiToNoteName = (midiNumber) => {
55
- const num = typeof midiNumber === 'string'
56
- ? Number.parseInt(midiNumber, 10)
57
- : midiNumber;
58
- return Note.fromMidi(num);
59
- };
60
- // Create a simple player that can play notes from our bundled data
61
- const player = {
62
- play: (note, velocity) => {
63
- // Convert MIDI note number to note name if needed
64
- const noteName = midiToNoteName(note);
65
- const noteData = soundfontData[noteName];
66
- if (noteData) {
67
- // Create audio from the data URL
68
- const audio = new Audio(noteData);
69
- audio.volume = (velocity || 127) / 127;
70
- audio.play();
71
- // Return a compatible object with stop method
72
- return {
73
- stop: () => {
74
- audio.pause();
75
- audio.currentTime = 0;
76
- },
77
- };
78
- }
79
- return null;
80
- },
81
- stop: () => {
82
- // Simple stop implementation
83
- },
84
- };
85
- setInstrument(player);
93
+ })
94
+ .catch(error => {
95
+ // biome-ignore lint/suspicious/noConsole: intentional diagnostics for load failures
96
+ console.error(`[zds-pickers] Failed to load soundfont "${nm}" from ${hostname}:`, error);
86
97
  setIsLoading(false);
87
- }
88
- else {
89
- setIsLoading(false);
90
- }
98
+ });
99
+ return;
91
100
  }
92
- else {
93
- // sound must be disabled
101
+ loadBundledOrRemoteInstrument(audioContext, nm, format, soundfont, hostname)
102
+ .then(newInstrument => {
103
+ setInstrument(newInstrument);
104
+ })
105
+ .catch(error => {
106
+ // biome-ignore lint/suspicious/noConsole: intentional diagnostics for load failures
107
+ console.error(`[zds-pickers] Failed to load soundfont "${nm}":`, error);
94
108
  setIsLoading(false);
95
- }
109
+ });
96
110
  }, [audioContext, format, hostname, instrumentName, soundfont]);
97
111
  useEffect(() => {
98
112
  if (instrumentName) {
@@ -108,7 +122,7 @@ const SoundfontProvider = (props) => {
108
122
  if (instrument) {
109
123
  audioContext.resume().then(() => {
110
124
  const audioNode = instrument.play(String(midiNumber));
111
- setActiveAudioNodes((prev) => ({ ...prev, [midiNumber]: audioNode }));
125
+ setActiveAudioNodes(prev => ({ ...prev, [midiNumber]: audioNode }));
112
126
  });
113
127
  }
114
128
  };
@@ -119,7 +133,7 @@ const SoundfontProvider = (props) => {
119
133
  }
120
134
  const audioNode = activeAudioNodes[midiNumber];
121
135
  audioNode.stop();
122
- setActiveAudioNodes((prev) => {
136
+ setActiveAudioNodes(prev => {
123
137
  const { [midiNumber]: _, ...rest } = prev;
124
138
  return rest;
125
139
  });
@@ -135,7 +149,7 @@ const SoundfontProvider = (props) => {
135
149
  node.stop();
136
150
  }
137
151
  }
138
- setActiveAudioNodes(nodes);
152
+ setActiveAudioNodes({});
139
153
  });
140
154
  };
141
155
  return render({
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,27 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import classNames from 'classnames';
2
3
  import { useCallback } from 'react';
4
+ import { Note } from 'tonal';
3
5
  import { OctavePlayer } from '../other/OctavePlayer';
4
6
  import { noSelection } from './Select';
7
+ const formatPitchName = (pitchClass) => pitchClass.replace(/([A-G])b/g, '$1\u266D');
8
+ const NATURAL_LABEL_NUDGE_LEFT = new Set(['C', 'F']);
9
+ const NATURAL_LABEL_NUDGE_RIGHT = new Set(['E', 'B']);
10
+ const noteNameLabelRenderer = ({ isAccidental, isActive, midiNumber, }) => {
11
+ const pitchClass = Note.pitchClass(Note.fromMidi(midiNumber) ?? '');
12
+ const pitchName = formatPitchName(pitchClass);
13
+ if (!pitchName)
14
+ return null;
15
+ return (_jsx("div", { className: classNames('ReactPiano__NoteLabel', 'ReactPiano__NoteLabel--noteName', {
16
+ 'ReactPiano__NoteLabel--active': isActive,
17
+ 'ReactPiano__NoteLabel--accidental': isAccidental,
18
+ 'ReactPiano__NoteLabel--natural': !isAccidental,
19
+ 'ReactPiano__NoteLabel--nudgeLeft': !isAccidental && NATURAL_LABEL_NUDGE_LEFT.has(pitchClass),
20
+ 'ReactPiano__NoteLabel--nudgeRight': !isAccidental && NATURAL_LABEL_NUDGE_RIGHT.has(pitchClass),
21
+ }), children: pitchName }));
22
+ };
5
23
  const KeyPicker = (props) => {
6
- const { value, onChange, disabled = false, height = 100, width = 300, octave = 4, ...rest } = props;
24
+ const { value, onChange, disabled = false, height = 100, showNoteNames = false, width = 300, octave = 4, ...rest } = props;
7
25
  const handleKeyClick = useCallback((note) => {
8
26
  if (disabled)
9
27
  return;
@@ -14,6 +32,6 @@ const KeyPicker = (props) => {
14
32
  const octaveStart = 60 + (octave - 4) * 12;
15
33
  const octaveEnd = octaveStart + 11;
16
34
  const shouldHighlight = value !== noSelection && value >= octaveStart && value <= octaveEnd;
17
- return (_jsx(OctavePlayer, { ...rest, selectedNotes: shouldHighlight ? [value] : [], disabled: disabled, height: height, width: width, octave: octave, onClick: handleKeyClick, instrumentName: "acoustic_grand_piano" }));
35
+ return (_jsx(OctavePlayer, { ...rest, className: showNoteNames ? 'ReactPiano--showNoteNames' : undefined, selectedNotes: shouldHighlight ? [value] : [], disabled: disabled, height: height, renderNoteLabel: showNoteNames ? noteNameLabelRenderer : undefined, width: width, octave: octave, onClick: handleKeyClick, instrumentName: "acoustic_grand_piano" }));
18
36
  };
19
37
  export { KeyPicker };
@@ -1,34 +1,6 @@
1
- import type { PianoProps } from 'lib/pickers/PianoPicker';
2
- import type Soundfont from 'soundfont-player';
3
- import type { TooltipProps } from './DefaultTooltip';
4
- type PianoMidiNote = {
5
- accidental: boolean;
6
- left: number;
7
- midiNumber: number;
8
- width: number;
9
- };
10
- type OctavePlayerProps = PianoProps & {
11
- disabled?: boolean;
12
- height: number;
13
- octave: number;
14
- width: number;
15
- };
16
- type WithProviderProps = PianoProps & {
17
- format?: 'mp3' | 'ogg';
18
- height: number;
19
- hostname?: string;
20
- instrumentName?: Soundfont.InstrumentName;
21
- octave?: number;
22
- onChange?: (note: number) => void;
23
- onClick?: (note: number) => void;
24
- onDoubleClick?: (note: number) => void;
25
- onKeyMouseEnter?: (note: PianoMidiNote) => void;
26
- onKeyMouseLeave?: (note: number) => void;
27
- selectedNotes?: number[];
28
- soundfont?: 'MusyngKite' | 'FluidR3_GM';
29
- Tooltip?: TooltipProps;
30
- width: number;
31
- };
32
- declare const WithProvider: ({ format, height, hostname, instrumentName, octave, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, selectedNotes, soundfont, Tooltip, width, }: WithProviderProps) => import("react/jsx-runtime").JSX.Element;
1
+ import type { OctavePlayerProps, PianoMidiNote, PianoProviderProps } from './pianoTypes';
2
+ declare const WithProvider: ({ className, format, height, hostname, instrumentName, octave, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, renderNoteLabel, selectedNotes, soundfont, Tooltip, width, }: PianoProviderProps) => import("react/jsx-runtime").JSX.Element;
33
3
  export { WithProvider as OctavePlayer };
34
- export type { OctavePlayerProps, PianoMidiNote, WithProviderProps };
4
+ export type { OctavePlayerProps, PianoMidiNote, PianoProviderProps };
5
+ /** @deprecated Use PianoProviderProps */
6
+ export type WithProviderProps = PianoProviderProps;
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+ import type Soundfont from 'soundfont-player';
3
+ import type { TooltipProps } from './DefaultTooltip';
4
+ type NoteLabelRenderProps = {
5
+ isAccidental: boolean;
6
+ isActive: boolean;
7
+ keyboardShortcut?: string | null;
8
+ midiNumber: number;
9
+ };
10
+ type PianoMidiNote = {
11
+ accidental: boolean;
12
+ left: number;
13
+ midiNumber: number;
14
+ width: number;
15
+ };
16
+ type PianoProps = {
17
+ className?: string;
18
+ disabled?: boolean;
19
+ height: number;
20
+ renderNoteLabel?: (props: NoteLabelRenderProps) => ReactNode;
21
+ selectedNotes?: number[];
22
+ width: number;
23
+ };
24
+ type PianoProviderProps = PianoProps & {
25
+ format?: 'mp3' | 'ogg';
26
+ hostname?: string;
27
+ instrumentName?: Soundfont.InstrumentName;
28
+ octave?: number;
29
+ onChange?: (note: number) => void;
30
+ onClick?: (note: number) => void;
31
+ onDoubleClick?: (note: number) => void;
32
+ onKeyMouseEnter?: (note: PianoMidiNote) => void;
33
+ onKeyMouseLeave?: (note: number) => void;
34
+ soundfont?: 'MusyngKite' | 'FluidR3_GM';
35
+ Tooltip?: TooltipProps;
36
+ };
37
+ type OctavePlayerProps = PianoProps & {
38
+ octave: number;
39
+ };
40
+ export type { NoteLabelRenderProps, OctavePlayerProps, PianoMidiNote, PianoProps, PianoProviderProps, };
@@ -1,9 +1,10 @@
1
1
  import { type OctavePlayerProps } from '../other/OctavePlayer';
2
- type KeyPickerProps = Omit<OctavePlayerProps, 'selectedNotes' | 'onClick'> & {
2
+ type KeyPickerProps = Omit<OctavePlayerProps, 'className' | 'onClick' | 'renderNoteLabel' | 'selectedNotes'> & {
3
3
  value: number;
4
4
  onChange: (value: number) => void;
5
5
  disabled?: boolean;
6
6
  height?: number;
7
+ showNoteNames?: boolean;
7
8
  width?: number;
8
9
  octave?: number;
9
10
  };
@@ -1,11 +1,4 @@
1
- import type { WithProviderProps } from '../other/OctavePlayer';
2
- type PianoProps = {
3
- activeNotes?: number[];
4
- selectedNotes?: number[];
5
- disabled: boolean;
6
- height: number;
7
- width: number;
8
- };
9
- declare const WithProvider: ({ format, height, hostname, instrumentName, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, selectedNotes, soundfont, width, }: WithProviderProps) => import("react/jsx-runtime").JSX.Element;
1
+ import type { PianoProps, PianoProviderProps } from '../other/pianoTypes';
2
+ declare const WithProvider: ({ format, height, hostname, instrumentName, onChange, onClick, onDoubleClick, onKeyMouseEnter, onKeyMouseLeave, selectedNotes, soundfont, width, }: PianoProviderProps) => import("react/jsx-runtime").JSX.Element;
10
3
  export { WithProvider as PianoPicker };
11
4
  export type { PianoProps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zds-pickers",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "main": "./dist/index.cjs.js",
5
5
  "module": "./dist/index.es.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -16,11 +16,8 @@
16
16
  "access": "public",
17
17
  "registry": "https://registry.npmjs.org/"
18
18
  },
19
- "sideEffects": false,
20
- "files": [
21
- "dist",
22
- "lib/soundfonts"
23
- ],
19
+ "sideEffects": ["lib/soundfonts/*.js", "dist/soundfonts/*.js"],
20
+ "files": ["dist", "lib/soundfonts"],
24
21
  "homepage": "https://github.com/dkadrios/zds-pickers#readme",
25
22
  "license": "MIT",
26
23
  "author": "Darin Kadrioski <dkadrios@gmail.com>",
@@ -34,13 +31,12 @@
34
31
  },
35
32
  "devDependencies": {
36
33
  "@biomejs/biome": "1.9.4",
37
- "@storybook/addon-actions": "8.6.3",
38
- "@storybook/addon-docs": "8.6.3",
39
- "@storybook/addon-essentials": "8.6.3",
40
- "@storybook/addon-links": "8.6.3",
41
- "@storybook/builder-vite": "8.6.3",
42
- "@storybook/react": "8.6.3",
43
- "@storybook/react-vite": "8.6.3",
34
+ "@storybook/addon-docs": "8.6.18",
35
+ "@storybook/addon-essentials": "8.6.18",
36
+ "@storybook/addon-links": "8.6.18",
37
+ "@storybook/builder-vite": "8.6.18",
38
+ "@storybook/react": "8.6.18",
39
+ "@storybook/react-vite": "8.6.18",
44
40
  "@types/d3-drag": "3.0.7",
45
41
  "@types/d3-scale": "4.0.9",
46
42
  "@types/d3-selection": "3.0.11",
@@ -53,7 +49,10 @@
53
49
  "nodemon": "3.1.9",
54
50
  "prop-types": "15.8.1",
55
51
  "rc-slider": "11.1.8",
56
- "storybook": "8.6.3",
52
+ "react": "18.3.1",
53
+ "react-dom": "18.3.1",
54
+ "react-select": "5.10.1",
55
+ "storybook": "8.6.18",
57
56
  "typescript": "5.8.2",
58
57
  "vite": "^6.3.6",
59
58
  "zds-mappings": "1.4.9"