zds-pickers 4.2.0 → 4.3.0

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/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from './utils';
2
2
  export * from './midi/ccValues';
3
3
  export * from './midi/export';
4
4
  export * from './other/DefaultTooltip';
5
+ export * from './other/noteNames';
5
6
  export * from './other/OctavePlayer';
6
7
  export * from './other/SvgText';
7
8
  export * from './pickers/CCPicker';
@@ -1,6 +1,35 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  const MEASUREMENT_ELEMENT_ID = '__react_svg_text_measurement_id';
4
+ /**
5
+ * The only computed properties that affect measured text geometry. Copying
6
+ * the full computed style (~300 properties) onto the measurement node per
7
+ * instance forced a style flush that dominated initial render in hosts
8
+ * with many SvgText instances.
9
+ */
10
+ const FONT_PROPERTIES = [
11
+ 'font-family',
12
+ 'font-feature-settings',
13
+ 'font-kerning',
14
+ 'font-size',
15
+ 'font-stretch',
16
+ 'font-style',
17
+ 'font-variant',
18
+ 'font-weight',
19
+ 'letter-spacing',
20
+ 'text-transform',
21
+ 'word-spacing',
22
+ ];
23
+ const fontSignature = (style) => FONT_PROPERTIES.map(key => style.getPropertyValue(key)).join('|');
24
+ /**
25
+ * Word widths only depend on the word and the font it renders in, so they
26
+ * are cached module-wide by font signature. Text that repeats across
27
+ * instances (labels, numbers) measures once per font, ever.
28
+ */
29
+ const fontMetricsCache = new Map();
30
+ const __resetFontMetricsCacheForTests = () => {
31
+ fontMetricsCache.clear();
32
+ };
4
33
  const calculateWordsByLines = (text, wordWidths, maxWidth, maxHeight) => {
5
34
  const { lineHeight, spaceWidth, wordsWithComputedWidth } = wordWidths;
6
35
  return text.split(/\s+/).reduce((result, word) => {
@@ -27,19 +56,39 @@ const calculateWordsByLines = (text, wordWidths, maxWidth, maxHeight) => {
27
56
  };
28
57
  const calculateWordWidths = (style, textNode, text) => {
29
58
  if (style && textNode) {
30
- // biome-ignore lint/complexity/noForEach: <explanation>
31
- Array.from(style).forEach(key => textNode.style.setProperty(key, style.getPropertyValue(key), style.getPropertyPriority(key)));
59
+ const signature = fontSignature(style);
60
+ let metrics = fontMetricsCache.get(signature);
32
61
  const wordArray = [...new Set(String(text).split(/\s+/))];
33
- const wordsWithComputedWidth = wordArray.reduce((wordMap, word) => {
34
- textNode.textContent = word;
35
- // biome-ignore lint/performance/noAccumulatingSpread: <explanation>
36
- return { ...wordMap, [word]: textNode.getBBox().width };
37
- }, {});
38
- textNode.textContent = '\u00A0';
39
- const spaceWidth = textNode?.getComputedTextLength?.() || 8;
40
- const lineHeight = textNode.getBBox().height;
41
- textNode.setAttribute('style', '');
42
- return { wordsWithComputedWidth, spaceWidth, lineHeight };
62
+ const knownWidths = metrics?.widths;
63
+ const missingWords = knownWidths
64
+ ? wordArray.filter(word => !knownWidths.has(word))
65
+ : wordArray;
66
+ if (!metrics || missingWords.length) {
67
+ for (const key of FONT_PROPERTIES) {
68
+ textNode.style.setProperty(key, style.getPropertyValue(key), style.getPropertyPriority(key));
69
+ }
70
+ if (!metrics) {
71
+ textNode.textContent = '\u00A0';
72
+ const spaceWidth = textNode?.getComputedTextLength?.() || 8;
73
+ const lineHeight = textNode.getBBox().height;
74
+ metrics = { widths: new Map(), spaceWidth, lineHeight };
75
+ fontMetricsCache.set(signature, metrics);
76
+ }
77
+ for (const word of missingWords) {
78
+ textNode.textContent = word;
79
+ metrics.widths.set(word, textNode.getBBox().width);
80
+ }
81
+ textNode.setAttribute('style', '');
82
+ }
83
+ const wordsWithComputedWidth = {};
84
+ for (const word of wordArray) {
85
+ wordsWithComputedWidth[word] = metrics.widths.get(word) ?? 0;
86
+ }
87
+ return {
88
+ wordsWithComputedWidth,
89
+ spaceWidth: metrics.spaceWidth,
90
+ lineHeight: metrics.lineHeight,
91
+ };
43
92
  }
44
93
  return undefined;
45
94
  };
@@ -67,14 +116,28 @@ const SvgText = (props) => {
67
116
  if (el === null) {
68
117
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
69
118
  svg.setAttribute('id', MEASUREMENT_ELEMENT_ID);
119
+ svg.setAttribute('aria-hidden', 'true');
120
+ // Rendered but invisible: getBBox returns zeros under display:none,
121
+ // so park it offscreen instead. Being outside any host SVG also
122
+ // keeps per-word getBBox from forcing layout of live content.
123
+ svg.style.position = 'fixed';
124
+ svg.style.left = '-9999px';
125
+ svg.style.top = '0';
126
+ const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
127
+ svg.appendChild(textEl);
70
128
  document.body.appendChild(svg);
71
- svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'text'));
72
- measureRef.current = svg;
73
- return () => {
74
- document.body.removeChild(svg);
75
- };
129
+ // Measure against the <text> child — pointing at the <svg> container
130
+ // made every measurement return the svg's own box.
131
+ measureRef.current = textEl;
132
+ // Deliberately no cleanup: the element is a shared singleton and
133
+ // other mounted instances may still hold it as their measure target.
134
+ return;
76
135
  }
77
- measureRef.current = el;
136
+ // Hosts may provide either the <text> itself or a container around one.
137
+ measureRef.current =
138
+ el.tagName.toLowerCase() === 'text'
139
+ ? el
140
+ : (el.querySelector('text') ?? el);
78
141
  }, []);
79
142
  useEffect(() => {
80
143
  const wordWithWidths = calculateWordWidths(style, measureRef.current, text);
@@ -105,4 +168,4 @@ const SvgText = (props) => {
105
168
  // textAnchor={textAnchor}
106
169
  transform: transform, ...rest, children: _jsxs(_Fragment, { children: [displayedLines.map((line, idx) => (_jsx("tspan", { dx: x + dx, dy: idx === 0 ? startDy + dy : wordWidths?.lineHeight || 0, x: 0, children: line.words.join(' ') }, idx))), textLines.length && displayedLines.length !== textLines.length && (_jsxs(_Fragment, { children: [_jsx("tspan", { children: "..." }), _jsx("title", { children: text })] }))] }) }));
107
170
  };
108
- export { SvgText, MEASUREMENT_ELEMENT_ID };
171
+ export { __resetFontMetricsCacheForTests, calculateWordsByLines, calculateWordWidths, MEASUREMENT_ELEMENT_ID, SvgText, };
@@ -0,0 +1,22 @@
1
+ import { Midi, Note } from 'tonal';
2
+ const { midiToNoteName } = Midi;
3
+ /**
4
+ * Rewrites ASCII accidentals in a note name to their Unicode glyphs, so
5
+ * labels read as `D♭` / `F♯` rather than `Db` / `F#`.
6
+ */
7
+ const formatPitchName = (name) => name.replace(/([A-G])b/g, '$1♭').replace(/([A-G])#/g, '$1♯');
8
+ /**
9
+ * Pitch class (no octave) for a MIDI note, as a display label.
10
+ *
11
+ * `noteLabels`, when supplied, is a chroma-indexed array of names — index 0 is
12
+ * C, index 1 is C♯/D♭, and so on. It lets a caller impose key-aware spelling
13
+ * (a scale may legitimately need both B♭ and F♯), which a single sharps/flats
14
+ * flag cannot express. Missing entries fall back to the default flat spelling.
15
+ */
16
+ const pitchClassLabel = (midiNumber, noteLabels) => noteLabels?.[((midiNumber % 12) + 12) % 12] ??
17
+ formatPitchName(Note.pitchClass(Note.fromMidi(midiNumber) ?? ''));
18
+ /**
19
+ * Note name with octave for a MIDI note, flats by default (`70` → `B♭4`).
20
+ */
21
+ const midiNoteLabel = (note) => formatPitchName(midiToNoteName(note, { sharps: false }));
22
+ export { formatPitchName, midiNoteLabel, pitchClassLabel };
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import classNames from 'classnames';
3
- import { useCallback } from 'react';
3
+ import { useCallback, useMemo } from 'react';
4
4
  import { Note } from 'tonal';
5
5
  import { OctavePlayer } from '../other/OctavePlayer';
6
+ import { pitchClassLabel } from '../other/noteNames';
6
7
  import { noSelection } from './Select';
7
- const formatPitchName = (pitchClass) => pitchClass.replace(/([A-G])b/g, '$1\u266D');
8
8
  const NATURAL_LABEL_NUDGE_LEFT = new Set(['C', 'F']);
9
9
  const NATURAL_LABEL_NUDGE_RIGHT = new Set(['E', 'B']);
10
- const noteNameLabelRenderer = ({ isAccidental, isActive, midiNumber, }) => {
10
+ const makeNoteNameLabelRenderer = (noteLabels) => ({ isAccidental, isActive, midiNumber }) => {
11
+ // Nudge classes key off the natural letter, which never varies with the
12
+ // caller's spelling choice, so they read the default name rather than the
13
+ // (possibly overridden) label.
11
14
  const pitchClass = Note.pitchClass(Note.fromMidi(midiNumber) ?? '');
12
- const pitchName = formatPitchName(pitchClass);
15
+ const pitchName = pitchClassLabel(midiNumber, noteLabels);
13
16
  if (!pitchName)
14
17
  return null;
15
18
  return (_jsx("div", { className: classNames('ReactPiano__NoteLabel', 'ReactPiano__NoteLabel--noteName', {
@@ -21,7 +24,8 @@ const noteNameLabelRenderer = ({ isAccidental, isActive, midiNumber, }) => {
21
24
  }), children: pitchName }));
22
25
  };
23
26
  const KeyPicker = (props) => {
24
- const { value, onChange, disabled = false, height = 100, showNoteNames = false, width = 300, octave = 4, ...rest } = props;
27
+ const { value, onChange, disabled = false, height = 100, noteLabels, showNoteNames = false, width = 300, octave = 4, ...rest } = props;
28
+ const renderNoteLabel = useMemo(() => makeNoteNameLabelRenderer(noteLabels), [noteLabels]);
25
29
  const handleKeyClick = useCallback((note) => {
26
30
  if (disabled)
27
31
  return;
@@ -32,6 +36,6 @@ const KeyPicker = (props) => {
32
36
  const octaveStart = 60 + (octave - 4) * 12;
33
37
  const octaveEnd = octaveStart + 11;
34
38
  const shouldHighlight = value !== noSelection && value >= octaveStart && value <= octaveEnd;
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" }));
39
+ return (_jsx(OctavePlayer, { ...rest, className: showNoteNames ? 'ReactPiano--showNoteNames' : undefined, selectedNotes: shouldHighlight ? [value] : [], disabled: disabled, height: height, renderNoteLabel: showNoteNames ? renderNoteLabel : undefined, width: width, octave: octave, onClick: handleKeyClick, instrumentName: "acoustic_grand_piano" }));
36
40
  };
37
41
  export { KeyPicker };
@@ -1,11 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useMemo } from 'react';
3
- import { Midi } from 'tonal';
4
3
  import { emptyMapping } from 'zds-mappings';
5
4
  import useStateWithDynamicDefault from '../hooks/useStateWithDynamicDefault';
5
+ import { midiNoteLabel } from '../other/noteNames';
6
6
  import { assertRange } from '../utils';
7
7
  import { Select } from './Select';
8
- const { midiToNoteName } = Midi;
9
8
  const formattedMapEntry = ({ note, name }) => `${note} ${name.length ? '-' : ''} ${name}`;
10
9
  const formattedListEntry = (label, idx) => ({
11
10
  label,
@@ -16,12 +15,7 @@ const NotePicker = forwardRef((props, ref) => {
16
15
  const options = useMemo(() => {
17
16
  if (isMelodicMode) {
18
17
  return emptyMapping()
19
- .map(({ note }) => {
20
- const midiNoteName = midiToNoteName(note, { sharps: false })
21
- .replace('b', '♭')
22
- .replace('#', '♯');
23
- return `${midiNoteName} (#${note})`;
24
- })
18
+ .map(({ note }) => `${midiNoteLabel(note)} (#${note})`)
25
19
  .map(formattedListEntry);
26
20
  }
27
21
  return (mapping || emptyMapping())
@@ -2,6 +2,7 @@ export * from './utils';
2
2
  export * from './midi/ccValues';
3
3
  export * from './midi/export';
4
4
  export * from './other/DefaultTooltip';
5
+ export * from './other/noteNames';
5
6
  export * from './other/OctavePlayer';
6
7
  export * from './other/SvgText';
7
8
  export * from './pickers/CCPicker';
@@ -1,14 +1,17 @@
1
1
  declare const MEASUREMENT_ELEMENT_ID = "__react_svg_text_measurement_id";
2
+ declare const __resetFontMetricsCacheForTests: () => void;
2
3
  type WordsByLine = {
3
4
  words: string[];
4
5
  width: number;
5
6
  showLine: boolean;
6
7
  };
8
+ declare const calculateWordsByLines: (text: string, wordWidths: WordWidths, maxWidth: number, maxHeight: number) => WordsByLine[];
7
9
  type WordWidths = {
8
10
  wordsWithComputedWidth: Record<string, number>;
9
11
  spaceWidth: number;
10
12
  lineHeight: number;
11
13
  };
14
+ declare const calculateWordWidths: (style?: CSSStyleDeclaration, textNode?: SVGGraphicsElement | null, text?: string) => WordWidths | undefined;
12
15
  type TextProps = Partial<HTMLOrSVGElement> & {
13
16
  className?: string;
14
17
  dx?: number;
@@ -23,5 +26,5 @@ type TextProps = Partial<HTMLOrSVGElement> & {
23
26
  y?: number;
24
27
  };
25
28
  declare const SvgText: (props: TextProps) => import("react/jsx-runtime").JSX.Element;
26
- export { SvgText, MEASUREMENT_ELEMENT_ID };
29
+ export { __resetFontMetricsCacheForTests, calculateWordsByLines, calculateWordWidths, MEASUREMENT_ELEMENT_ID, SvgText, };
27
30
  export type { TextProps, WordWidths, WordsByLine };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rewrites ASCII accidentals in a note name to their Unicode glyphs, so
3
+ * labels read as `D♭` / `F♯` rather than `Db` / `F#`.
4
+ */
5
+ declare const formatPitchName: (name: string) => string;
6
+ /**
7
+ * Pitch class (no octave) for a MIDI note, as a display label.
8
+ *
9
+ * `noteLabels`, when supplied, is a chroma-indexed array of names — index 0 is
10
+ * C, index 1 is C♯/D♭, and so on. It lets a caller impose key-aware spelling
11
+ * (a scale may legitimately need both B♭ and F♯), which a single sharps/flats
12
+ * flag cannot express. Missing entries fall back to the default flat spelling.
13
+ */
14
+ declare const pitchClassLabel: (midiNumber: number, noteLabels?: readonly string[]) => string;
15
+ /**
16
+ * Note name with octave for a MIDI note, flats by default (`70` → `B♭4`).
17
+ */
18
+ declare const midiNoteLabel: (note: number) => string;
19
+ export { formatPitchName, midiNoteLabel, pitchClassLabel };
@@ -4,6 +4,12 @@ type KeyPickerProps = Omit<PianoProviderProps, 'className' | 'instrumentName' |
4
4
  onChange: (value: number) => void;
5
5
  disabled?: boolean;
6
6
  height?: number;
7
+ /**
8
+ * Chroma-indexed note names (index 0 = C) overriding the default flat
9
+ * spelling — lets a caller label keys for a specific key/scale, where both
10
+ * sharps and flats can be correct at once. Missing entries fall back.
11
+ */
12
+ noteLabels?: readonly string[];
7
13
  showNoteNames?: boolean;
8
14
  width?: number;
9
15
  octave?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zds-pickers",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "main": "./dist/index.cjs.js",
5
5
  "module": "./dist/index.es.js",
6
6
  "types": "./dist/types/index.d.ts",