@tremolo-ui/vue 0.7.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/index.cjs +2486 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1678 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.ts +1678 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +2444 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +78 -0
  12. package/src/components/AnimationCanvas/AnimationCanvas.ts +105 -0
  13. package/src/components/AnimationCanvas/index.ts +5 -0
  14. package/src/components/DropZone/DropZone.ts +43 -0
  15. package/src/components/DropZone/index.ts +1 -0
  16. package/src/components/FileInput/FileInput.ts +82 -0
  17. package/src/components/FileInput/FileInputTrigger.ts +24 -0
  18. package/src/components/FileInput/context.ts +18 -0
  19. package/src/components/FileInput/index.ts +3 -0
  20. package/src/components/Knob/Knob.ts +237 -0
  21. package/src/components/Knob/KnobActiveLine.ts +31 -0
  22. package/src/components/Knob/KnobInactiveLine.ts +36 -0
  23. package/src/components/Knob/KnobSVGRoot.ts +25 -0
  24. package/src/components/Knob/KnobThumb.ts +53 -0
  25. package/src/components/Knob/context.ts +30 -0
  26. package/src/components/Knob/index.ts +6 -0
  27. package/src/components/NumberInput/NumberInput.ts +258 -0
  28. package/src/components/NumberInput/NumberInputDecrementStepper.ts +7 -0
  29. package/src/components/NumberInput/NumberInputField.ts +128 -0
  30. package/src/components/NumberInput/NumberInputIncrementStepper.ts +7 -0
  31. package/src/components/NumberInput/NumberInputStepper.ts +54 -0
  32. package/src/components/NumberInput/context.ts +63 -0
  33. package/src/components/NumberInput/index.ts +6 -0
  34. package/src/components/NumberInput/stepper-button.ts +65 -0
  35. package/src/components/Piano/Piano.ts +243 -0
  36. package/src/components/Piano/index.ts +1 -0
  37. package/src/components/PointsEditor/PointsEditor.ts +146 -0
  38. package/src/components/PointsEditor/PointsEditorBackground.ts +17 -0
  39. package/src/components/PointsEditor/PointsEditorContainer.ts +73 -0
  40. package/src/components/PointsEditor/PointsEditorPoint.ts +253 -0
  41. package/src/components/PointsEditor/PointsEditorSelectionBox.ts +36 -0
  42. package/src/components/PointsEditor/context.ts +39 -0
  43. package/src/components/PointsEditor/index.ts +9 -0
  44. package/src/components/Slider/Slider.ts +224 -0
  45. package/src/components/Slider/SliderMarks.ts +57 -0
  46. package/src/components/Slider/SliderMarksOption.ts +85 -0
  47. package/src/components/Slider/SliderThumb.ts +92 -0
  48. package/src/components/Slider/SliderTrack.ts +50 -0
  49. package/src/components/Slider/context.ts +33 -0
  50. package/src/components/Slider/index.ts +6 -0
  51. package/src/components/XYPad/XYPad.ts +250 -0
  52. package/src/components/XYPad/XYPadArea.ts +20 -0
  53. package/src/components/XYPad/XYPadThumb.ts +105 -0
  54. package/src/components/XYPad/context.ts +33 -0
  55. package/src/components/XYPad/index.ts +4 -0
  56. package/src/components/_util/context.ts +8 -0
  57. package/src/components/_util/placement.ts +36 -0
  58. package/src/components/_util/props.ts +32 -0
  59. package/src/components/_util/useCheckSteps.ts +22 -0
  60. package/src/components/_util/visually-hidden-range-input.ts +16 -0
  61. package/src/composables/useDrag.ts +22 -0
  62. package/src/composables/useDragValue.ts +20 -0
  63. package/src/composables/useDropZone.ts +32 -0
  64. package/src/composables/useInstance.ts +52 -0
  65. package/src/composables/useLongPress.ts +20 -0
  66. package/src/composables/useMIDIAccess.ts +58 -0
  67. package/src/composables/useMIDIInput.ts +33 -0
  68. package/src/composables/useMIDIMessage.ts +30 -0
  69. package/src/composables/useWheel.ts +23 -0
  70. package/src/index.ts +67 -0
@@ -0,0 +1,243 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ h,
5
+ ref,
6
+ watch,
7
+ type HTMLAttributes,
8
+ type PropType,
9
+ type SlotsType,
10
+ } from 'vue'
11
+
12
+ import {
13
+ blackKeyWidth,
14
+ createPianoInput,
15
+ fitWhiteKeyWidth,
16
+ getNoteRangeArray,
17
+ notePosition,
18
+ pianoWidth,
19
+ type KeyboardShortcuts,
20
+ type KeyboardShortcutsScope,
21
+ type NoteRange,
22
+ type PianoInputInstance,
23
+ type PianoLayout,
24
+ } from '@tremolo-ui/dom'
25
+ import { isWhiteKey, noteKey } from '@tremolo-ui/functions'
26
+
27
+ /** What a key is, when the `label` slot or `keyProps` is asked about it. */
28
+ export interface KeyState {
29
+ /** Position in the note range, counting from `noteRange.first`. */
30
+ index: number
31
+ keyType: 'white' | 'black'
32
+ /** Whether the note is currently sounding. */
33
+ active: boolean
34
+ /** Whether the note is above `midiMax` and cannot sound. */
35
+ disabled: boolean
36
+ }
37
+
38
+ /** What `keyProps` may return for one key. */
39
+ export type KeyAttributes = HTMLAttributes &
40
+ Record<`data-${string}`, string | number | boolean | undefined>
41
+
42
+ /**
43
+ * Customizable piano. The keys are drawn by the component; what goes inside
44
+ * one is the `label` slot, which receives the note and its state.
45
+ */
46
+ export const Piano = /* @__PURE__ */ defineComponent({
47
+ name: 'Piano',
48
+ props: {
49
+ /** The notes drawn, from `first` to `last`. */
50
+ noteRange: { type: Object as PropType<NoteRange>, required: true },
51
+ /** Let a pointer slide from one key to the next. @default true */
52
+ glissando: { type: Boolean, default: true },
53
+ /** Highest note that can sound. @default 127 */
54
+ midiMax: { type: Number, default: 127 },
55
+ /** Play notes from the computer keyboard. See `SHORTCUTS` in `@tremolo-ui/dom`. */
56
+ keyboardShortcuts: Object as PropType<KeyboardShortcuts>,
57
+ /** Where keyboard shortcuts listen. @default 'root' */
58
+ keyboardShortcutsScope: {
59
+ type: String as PropType<KeyboardShortcutsScope>,
60
+ default: 'root',
61
+ },
62
+ /** Follow the width of the parent element. */
63
+ resizable: Boolean,
64
+ /** Width of a white key in pixels. @default 40 */
65
+ whiteKeyWidth: { type: Number, default: 40 },
66
+ /** Space between two white keys, in pixels. @default 1 */
67
+ keyGap: { type: Number, default: 1 },
68
+ /** Width of a black key, as a fraction of a white one. @default 0.65 */
69
+ blackKeyWidthRatio: { type: Number, default: 0.65 },
70
+ /** Height of a black key, as a fraction of the keyboard. @default 0.6 */
71
+ blackKeyHeightRatio: { type: Number, default: 0.6 },
72
+ /** Classes for the label inside each key. */
73
+ classes: Object as PropType<{
74
+ keyLabelWrapper?: string
75
+ keyLabel?: string
76
+ }>,
77
+ /**
78
+ * Extra attributes for one key, by note. The geometry of the key is
79
+ * applied after the returned style and cannot be overridden.
80
+ */
81
+ keyProps: Function as PropType<
82
+ (note: number, state: KeyState) => KeyAttributes
83
+ >,
84
+ },
85
+ emits: {
86
+ /** A note started sounding. */
87
+ playNote: (note: number, _velocity?: number) => typeof note === 'number',
88
+ /** Everything holding a note let go of it. */
89
+ stopNote: (note: number) => typeof note === 'number',
90
+ },
91
+ slots: Object as SlotsType<{
92
+ label: { note: number; state: KeyState }
93
+ }>,
94
+ setup(props, { slots, emit, expose }) {
95
+ const root = ref<HTMLDivElement | null>(null)
96
+ const activeNotes = ref<number[]>([])
97
+ /** Set while `resizable` is on, measured from the parent. */
98
+ const resizedKeyWidth = ref<number | null>(null)
99
+
100
+ const notes = computed(() => getNoteRangeArray(props.noteRange))
101
+ const layout = computed((): PianoLayout => ({
102
+ noteRange: props.noteRange,
103
+ whiteKeyWidth: props.resizable
104
+ ? (resizedKeyWidth.value ?? props.whiteKeyWidth)
105
+ : props.whiteKeyWidth,
106
+ keyGap: props.keyGap,
107
+ blackKeyWidthRatio: props.blackKeyWidthRatio,
108
+ blackKeyHeightRatio: props.blackKeyHeightRatio,
109
+ }))
110
+ const settings = () => ({
111
+ layout: layout.value,
112
+ glissando: props.glissando,
113
+ midiMax: props.midiMax,
114
+ keyboardShortcuts: props.keyboardShortcuts,
115
+ keyboardShortcutsScope: props.keyboardShortcutsScope,
116
+ })
117
+
118
+ let instance: PianoInputInstance | null = null
119
+
120
+ // Only the element decides how the instance is wired. Everything else is
121
+ // pushed with update(), so that changing the layout mid-drag — the parent
122
+ // being resized under `resizable`, say — does not abort the drag.
123
+ watch(
124
+ root,
125
+ (element, _, onCleanup) => {
126
+ if (!element) return
127
+ const current = createPianoInput(element, {
128
+ ...settings(),
129
+ onPlayNote: (note, velocity) => emit('playNote', note, velocity),
130
+ onStopNote: (note) => emit('stopNote', note),
131
+ onActiveNotesChange: (next) => {
132
+ activeNotes.value = next
133
+ },
134
+ })
135
+ instance = current
136
+ onCleanup(() => {
137
+ instance = null
138
+ current.destroy()
139
+ })
140
+ },
141
+ { immediate: true, flush: 'post' },
142
+ )
143
+ watch(settings, (next) => instance?.update(next))
144
+
145
+ watch(
146
+ () =>
147
+ [
148
+ root.value,
149
+ props.resizable,
150
+ props.noteRange.first,
151
+ props.noteRange.last,
152
+ props.keyGap,
153
+ props.blackKeyWidthRatio,
154
+ ] as const,
155
+ (
156
+ [element, resizable, first, last, keyGap, blackKeyWidthRatio],
157
+ _,
158
+ onCleanup,
159
+ ) => {
160
+ if (!resizable || !element) return
161
+ const parent = element.parentElement
162
+ if (!parent) throw new Error("doesn't have a parent element.")
163
+ const observer = new ResizeObserver(() => {
164
+ resizedKeyWidth.value = fitWhiteKeyWidth(element.clientWidth, {
165
+ noteRange: { first, last },
166
+ keyGap,
167
+ blackKeyWidthRatio,
168
+ })
169
+ })
170
+ observer.observe(parent)
171
+ onCleanup(() => observer.disconnect())
172
+ },
173
+ { immediate: true, flush: 'post' },
174
+ )
175
+
176
+ expose({
177
+ /** Start a note, as a key would. */
178
+ playNote: (note: number, velocity?: number) =>
179
+ instance?.noteOn(note, { source: 'api', velocity }),
180
+ /** Release a note started with `playNote`. */
181
+ stopNote: (note: number) => instance?.noteOff(note, { source: 'api' }),
182
+ })
183
+
184
+ // The group can own keyboard shortcuts and must receive focus. The width
185
+ // is computed from the layout rather than chosen, so it stays inline.
186
+ return () =>
187
+ h(
188
+ 'div',
189
+ {
190
+ ref: root,
191
+ role: 'group',
192
+ tabindex: 0,
193
+ 'data-resizable': props.resizable ? '' : undefined,
194
+ style: {
195
+ position: 'relative',
196
+ width: props.resizable ? '100%' : `${pianoWidth(layout.value)}px`,
197
+ },
198
+ },
199
+ notes.value.map((note, index) => {
200
+ const state: KeyState = {
201
+ index,
202
+ keyType: isWhiteKey(note) ? 'white' : 'black',
203
+ active: activeNotes.value.includes(note),
204
+ disabled: note > props.midiMax,
205
+ }
206
+ const { style: keyStyle, ...attributes } =
207
+ props.keyProps?.(note, state) ?? {}
208
+ const white = state.keyType === 'white'
209
+ const label = slots.label?.({ note, state })
210
+ // Placed and sized from the layout; the black keys sit over the
211
+ // white ones whichever order they are drawn in. The geometry comes
212
+ // after the caller's style.
213
+ return h(
214
+ 'div',
215
+ {
216
+ key: note,
217
+ 'data-note': note,
218
+ 'data-note-key': noteKey(note),
219
+ 'data-active': state.active ? '' : undefined,
220
+ 'data-disabled': state.disabled ? '' : undefined,
221
+ ...attributes,
222
+ style: [
223
+ { position: 'absolute', zIndex: white ? 1 : 2 },
224
+ keyStyle,
225
+ {
226
+ left: `${notePosition(note, layout.value)}px`,
227
+ width: `${white ? layout.value.whiteKeyWidth : blackKeyWidth(layout.value)}px`,
228
+ height: white
229
+ ? '100%'
230
+ : `${props.blackKeyHeightRatio * 100}%`,
231
+ },
232
+ ],
233
+ },
234
+ label
235
+ ? h('div', { class: props.classes?.keyLabelWrapper }, [
236
+ h('div', { class: props.classes?.keyLabel }, label),
237
+ ])
238
+ : undefined,
239
+ )
240
+ }),
241
+ )
242
+ },
243
+ })
@@ -0,0 +1 @@
1
+ export { Piano, type KeyAttributes, type KeyState } from './Piano'
@@ -0,0 +1,146 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ h,
5
+ onScopeDispose,
6
+ provide,
7
+ shallowRef,
8
+ watchEffect,
9
+ type PropType,
10
+ } from 'vue'
11
+
12
+ import {
13
+ createPointsEditor,
14
+ DEFAULT_DRAG_SENSITIVITY,
15
+ POINTS_EDITOR_DEFAULT_KEYBOARD,
16
+ POINTS_EDITOR_DEFAULT_WHEEL,
17
+ type InputEventOption,
18
+ type ModifierValue,
19
+ type SelectionBoxRect,
20
+ } from '@tremolo-ui/dom'
21
+
22
+ import { PointsEditorKey } from './context'
23
+
24
+ const EMPTY: readonly string[] = []
25
+
26
+ /**
27
+ * Multiple point controller. Bind the selection with `v-model:selection` to
28
+ * hold it yourself; the editor keeps its own otherwise.
29
+ */
30
+ export const PointsEditor = /* @__PURE__ */ defineComponent({
31
+ name: 'PointsEditor',
32
+ props: {
33
+ /** Make the points unchangeable and remove them from the tab order. */
34
+ disabled: Boolean,
35
+ /** Make the points unmovable. */
36
+ readonly: Boolean,
37
+ /** The cursor to show while dragging a point. @default { cursor: 'grabbing' } */
38
+ externalStyles: Object as PropType<{ cursor?: string }>,
39
+ /** How much one wheel notch moves the focused point. */
40
+ wheel: {
41
+ type: [Array, Object] as PropType<ModifierValue<InputEventOption> | null>,
42
+ default: () => POINTS_EDITOR_DEFAULT_WHEEL,
43
+ },
44
+ /** How much one arrow key press moves a point. */
45
+ keyboard: {
46
+ type: [Array, Object] as PropType<ModifierValue<InputEventOption> | null>,
47
+ default: () => POINTS_EDITOR_DEFAULT_KEYBOARD,
48
+ },
49
+ /** How much a drag moves a point, per modifier key. */
50
+ dragSensitivity: {
51
+ type: [Number, Object] as PropType<ModifierValue<number>>,
52
+ default: () => DEFAULT_DRAG_SENSITIVITY,
53
+ },
54
+ /**
55
+ * Let points be selected, and a selection be moved as one. A selection
56
+ * updates several points in the same tick.
57
+ */
58
+ selectable: Boolean,
59
+ /** Ids of the selected points. Bind it with `v-model:selection`. */
60
+ selection: Array as PropType<string[]>,
61
+ },
62
+ emits: {
63
+ 'update:selection': (selection: string[]) => Array.isArray(selection),
64
+ },
65
+ setup(props, { slots, emit }) {
66
+ const ownSelection = shallowRef<string[]>(props.selection ?? [])
67
+ const selectionBox = shallowRef<SelectionBoxRect | null>(null)
68
+ const container = shallowRef<HTMLElement | null>(null)
69
+
70
+ // Nothing is selected while selection is off, so a drag picks up only
71
+ // the point it started on.
72
+ const current = computed(() =>
73
+ props.selectable ? (props.selection ?? ownSelection.value) : EMPTY,
74
+ )
75
+
76
+ // The selection lives here, where Vue state can hold it; which points a
77
+ // press or a box selects, and how a selection moves, is the core's.
78
+ const editor = createPointsEditor({
79
+ onSelectionBoxChange: (rect) => {
80
+ selectionBox.value = rect
81
+ },
82
+ onSelectionChange: (next) => {
83
+ ownSelection.value = next
84
+ emit('update:selection', next)
85
+ },
86
+ })
87
+ watchEffect(
88
+ () =>
89
+ editor.update({
90
+ selectable: props.selectable,
91
+ selection: current.value,
92
+ }),
93
+ { flush: 'sync' },
94
+ )
95
+ onScopeDispose(() => editor.destroy())
96
+
97
+ provide(PointsEditorKey, {
98
+ get disabled() {
99
+ return props.disabled
100
+ },
101
+ get readonly() {
102
+ return props.readonly
103
+ },
104
+ get wheel() {
105
+ return props.wheel
106
+ },
107
+ get keyboard() {
108
+ return props.keyboard
109
+ },
110
+ get dragSensitivity() {
111
+ return props.dragSensitivity
112
+ },
113
+ get cursor() {
114
+ return props.externalStyles?.cursor ?? 'grabbing'
115
+ },
116
+ get selectable() {
117
+ return props.selectable
118
+ },
119
+ get selection() {
120
+ return current.value
121
+ },
122
+ get selectionBox() {
123
+ return selectionBox.value
124
+ },
125
+ get container() {
126
+ return container.value
127
+ },
128
+ editor,
129
+ setContainer: (element) => {
130
+ container.value = element
131
+ },
132
+ })
133
+
134
+ // The layers inside are placed against this box.
135
+ return () =>
136
+ h(
137
+ 'div',
138
+ {
139
+ 'data-disabled': props.disabled ? '' : undefined,
140
+ 'data-readonly': props.readonly ? '' : undefined,
141
+ style: { position: 'relative' },
142
+ },
143
+ slots.default?.(),
144
+ )
145
+ },
146
+ })
@@ -0,0 +1,17 @@
1
+ import { defineComponent, h } from 'vue'
2
+
3
+ /**
4
+ * Behind the points, filling the editor. The order of the layers holds
5
+ * whichever way they are written, so the z-index is part of the mechanics.
6
+ */
7
+ export const PointsEditorBackground = /* @__PURE__ */ defineComponent({
8
+ name: 'PointsEditorBackground',
9
+ setup(_, { slots }) {
10
+ return () =>
11
+ h(
12
+ 'div',
13
+ { style: { position: 'absolute', inset: 0, zIndex: 0 } },
14
+ slots.default?.(),
15
+ )
16
+ },
17
+ })
@@ -0,0 +1,73 @@
1
+ import { computed, defineComponent, h, ref, watch, watchEffect } from 'vue'
2
+
3
+ import {
4
+ createDragValue,
5
+ elementMapping,
6
+ POINT_AXIS,
7
+ wheelMove,
8
+ } from '@tremolo-ui/dom'
9
+
10
+ import { useWheel } from '../../composables/useWheel'
11
+ import { providePlacement } from '../_util/placement'
12
+
13
+ import { usePointsEditorContext } from './context'
14
+
15
+ /** The layer the points are placed in, over the background. */
16
+ export const PointsEditorContainer = /* @__PURE__ */ defineComponent({
17
+ name: 'PointsEditorContainer',
18
+ setup(_, { slots }) {
19
+ const points = usePointsEditorContext()
20
+ providePlacement('PointsEditorContainer')
21
+ const el = ref<HTMLDivElement | null>(null)
22
+ watchEffect(() => points.setContainer(el.value))
23
+
24
+ // A drag on empty space draws a selection box. It is only attached while
25
+ // there is a selection to draw: `createDrag` puts `touch-action: none` on
26
+ // what it holds, and an editor that cannot select should not stop the
27
+ // page scrolling under a finger.
28
+ const selecting = computed(() => points.selectable && !points.disabled)
29
+ watch(
30
+ [el, selecting],
31
+ ([element, on], _, onCleanup) => {
32
+ if (!element || !on) return
33
+ const drag = createDragValue(element, {
34
+ axis: POINT_AXIS,
35
+ mapping: elementMapping(() => element),
36
+ // A press that landed on a point belongs to that point. Declining
37
+ // here rather than later matters: by then the container would
38
+ // already have taken the pointer capture away from the point.
39
+ shouldStart: (event) =>
40
+ !points.editor.isPointElement(event.target as Element | null),
41
+ onDragStart: ([x, y], state) =>
42
+ points.editor.beginSelectionBox({ x, y }, state.event),
43
+ onChange: ([x, y]) => points.editor.moveSelectionBox({ x, y }),
44
+ onDragEnd: () => points.editor.endSelectionBox(),
45
+ })
46
+ onCleanup(() => drag.destroy())
47
+ },
48
+ { immediate: true, flush: 'post' },
49
+ )
50
+
51
+ // One listener for the whole editor rather than one per point: a wheel
52
+ // event only reaches what the cursor is over.
53
+ useWheel(el, (event) => {
54
+ // Scrolling up moves the point towards y = 0; shift switches to x.
55
+ const move = wheelMove(event)
56
+ if (!move) return
57
+ const axis = move.axis === 0 ? 'x' : 'y'
58
+ if (points.editor.nudgeFocusedPoint(axis, move.direction, event)) {
59
+ event.preventDefault()
60
+ }
61
+ })
62
+
63
+ return () =>
64
+ h(
65
+ 'div',
66
+ {
67
+ ref: el,
68
+ style: { position: 'absolute', inset: 0, zIndex: 10 },
69
+ },
70
+ slots.default?.(),
71
+ )
72
+ },
73
+ })