@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.cjs +2486 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1678 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +1678 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2444 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -0
- package/src/components/AnimationCanvas/AnimationCanvas.ts +105 -0
- package/src/components/AnimationCanvas/index.ts +5 -0
- package/src/components/DropZone/DropZone.ts +43 -0
- package/src/components/DropZone/index.ts +1 -0
- package/src/components/FileInput/FileInput.ts +82 -0
- package/src/components/FileInput/FileInputTrigger.ts +24 -0
- package/src/components/FileInput/context.ts +18 -0
- package/src/components/FileInput/index.ts +3 -0
- package/src/components/Knob/Knob.ts +237 -0
- package/src/components/Knob/KnobActiveLine.ts +31 -0
- package/src/components/Knob/KnobInactiveLine.ts +36 -0
- package/src/components/Knob/KnobSVGRoot.ts +25 -0
- package/src/components/Knob/KnobThumb.ts +53 -0
- package/src/components/Knob/context.ts +30 -0
- package/src/components/Knob/index.ts +6 -0
- package/src/components/NumberInput/NumberInput.ts +258 -0
- package/src/components/NumberInput/NumberInputDecrementStepper.ts +7 -0
- package/src/components/NumberInput/NumberInputField.ts +128 -0
- package/src/components/NumberInput/NumberInputIncrementStepper.ts +7 -0
- package/src/components/NumberInput/NumberInputStepper.ts +54 -0
- package/src/components/NumberInput/context.ts +63 -0
- package/src/components/NumberInput/index.ts +6 -0
- package/src/components/NumberInput/stepper-button.ts +65 -0
- package/src/components/Piano/Piano.ts +243 -0
- package/src/components/Piano/index.ts +1 -0
- package/src/components/PointsEditor/PointsEditor.ts +146 -0
- package/src/components/PointsEditor/PointsEditorBackground.ts +17 -0
- package/src/components/PointsEditor/PointsEditorContainer.ts +73 -0
- package/src/components/PointsEditor/PointsEditorPoint.ts +253 -0
- package/src/components/PointsEditor/PointsEditorSelectionBox.ts +36 -0
- package/src/components/PointsEditor/context.ts +39 -0
- package/src/components/PointsEditor/index.ts +9 -0
- package/src/components/Slider/Slider.ts +224 -0
- package/src/components/Slider/SliderMarks.ts +57 -0
- package/src/components/Slider/SliderMarksOption.ts +85 -0
- package/src/components/Slider/SliderThumb.ts +92 -0
- package/src/components/Slider/SliderTrack.ts +50 -0
- package/src/components/Slider/context.ts +33 -0
- package/src/components/Slider/index.ts +6 -0
- package/src/components/XYPad/XYPad.ts +250 -0
- package/src/components/XYPad/XYPadArea.ts +20 -0
- package/src/components/XYPad/XYPadThumb.ts +105 -0
- package/src/components/XYPad/context.ts +33 -0
- package/src/components/XYPad/index.ts +4 -0
- package/src/components/_util/context.ts +8 -0
- package/src/components/_util/placement.ts +36 -0
- package/src/components/_util/props.ts +32 -0
- package/src/components/_util/useCheckSteps.ts +22 -0
- package/src/components/_util/visually-hidden-range-input.ts +16 -0
- package/src/composables/useDrag.ts +22 -0
- package/src/composables/useDragValue.ts +20 -0
- package/src/composables/useDropZone.ts +32 -0
- package/src/composables/useInstance.ts +52 -0
- package/src/composables/useLongPress.ts +20 -0
- package/src/composables/useMIDIAccess.ts +58 -0
- package/src/composables/useMIDIInput.ts +33 -0
- package/src/composables/useMIDIMessage.ts +30 -0
- package/src/composables/useWheel.ts +23 -0
- package/src/index.ts +67 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2486 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let vue = require("vue");
|
|
3
|
+
let _tremolo_ui_dom = require("@tremolo-ui/dom");
|
|
4
|
+
let _tremolo_ui_functions = require("@tremolo-ui/functions");
|
|
5
|
+
//#region src/components/AnimationCanvas/AnimationCanvas.ts
|
|
6
|
+
/** A `<canvas>` drawn by `draw` on every animation frame. */
|
|
7
|
+
const AnimationCanvas = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
8
|
+
name: "AnimationCanvas",
|
|
9
|
+
inheritAttrs: false,
|
|
10
|
+
props: {
|
|
11
|
+
/** Draws a frame. */
|
|
12
|
+
draw: {
|
|
13
|
+
type: Function,
|
|
14
|
+
required: true
|
|
15
|
+
},
|
|
16
|
+
/** Sets the canvas up before the first frame and after a resize. */
|
|
17
|
+
init: Function,
|
|
18
|
+
/** Draw on every animation frame. @default true */
|
|
19
|
+
animate: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: true
|
|
22
|
+
},
|
|
23
|
+
/** Settings for the 2D context. Read once, when the canvas is created. */
|
|
24
|
+
options: Object,
|
|
25
|
+
/** Keep the drawing while the canvas is resized. @default true */
|
|
26
|
+
reduceFlickering: {
|
|
27
|
+
type: Boolean,
|
|
28
|
+
default: true
|
|
29
|
+
},
|
|
30
|
+
/** Follow the size of the parent element; `width` and `height` are ignored. */
|
|
31
|
+
resizable: Boolean,
|
|
32
|
+
/** Width in CSS pixels, while not `resizable`. @default 100 */
|
|
33
|
+
width: {
|
|
34
|
+
type: Number,
|
|
35
|
+
default: 100
|
|
36
|
+
},
|
|
37
|
+
/** Height in CSS pixels, while not `resizable`. @default 100 */
|
|
38
|
+
height: {
|
|
39
|
+
type: Number,
|
|
40
|
+
default: 100
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
setup(props, { attrs }) {
|
|
44
|
+
const canvas = (0, vue.ref)(null);
|
|
45
|
+
const instance = (0, vue.shallowRef)(null);
|
|
46
|
+
(0, vue.watch)([canvas, () => props.resizable], ([element, resizable], _, onCleanup) => {
|
|
47
|
+
if (!element) return;
|
|
48
|
+
const current = (0, _tremolo_ui_dom.createAnimationCanvas)(element, {
|
|
49
|
+
draw: (context, frame) => props.draw(context, frame),
|
|
50
|
+
init: (context, size) => props.init?.(context, size),
|
|
51
|
+
animate: props.animate,
|
|
52
|
+
size: {
|
|
53
|
+
width: props.width,
|
|
54
|
+
height: props.height
|
|
55
|
+
},
|
|
56
|
+
reduceFlickering: props.reduceFlickering,
|
|
57
|
+
resizable,
|
|
58
|
+
contextAttributes: props.options
|
|
59
|
+
});
|
|
60
|
+
instance.value = current;
|
|
61
|
+
onCleanup(() => {
|
|
62
|
+
if (instance.value === current) instance.value = null;
|
|
63
|
+
current.destroy();
|
|
64
|
+
});
|
|
65
|
+
}, {
|
|
66
|
+
immediate: true,
|
|
67
|
+
flush: "post"
|
|
68
|
+
});
|
|
69
|
+
(0, vue.watchEffect)(() => {
|
|
70
|
+
props.draw;
|
|
71
|
+
props.init;
|
|
72
|
+
instance.value?.update({
|
|
73
|
+
animate: props.animate,
|
|
74
|
+
size: {
|
|
75
|
+
width: props.width,
|
|
76
|
+
height: props.height
|
|
77
|
+
},
|
|
78
|
+
reduceFlickering: props.reduceFlickering
|
|
79
|
+
});
|
|
80
|
+
}, { flush: "post" });
|
|
81
|
+
return () => (0, vue.h)("canvas", {
|
|
82
|
+
onContextmenu: (event) => event.preventDefault(),
|
|
83
|
+
...attrs,
|
|
84
|
+
ref: canvas
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/composables/useInstance.ts
|
|
90
|
+
/**
|
|
91
|
+
* Create an instance for the element `target` holds, and destroy it when the
|
|
92
|
+
* element goes away or changes. Options are pushed to the instance with
|
|
93
|
+
* `update` as they change, so that changing them does not interrupt a drag in
|
|
94
|
+
* progress.
|
|
95
|
+
*
|
|
96
|
+
* Call it during `setup`: the watchers stop with the component.
|
|
97
|
+
*/
|
|
98
|
+
function useInstance(target, options, create, update) {
|
|
99
|
+
let instance = null;
|
|
100
|
+
(0, vue.watch)(() => (0, vue.toValue)(target), (element, _, onCleanup) => {
|
|
101
|
+
if (!element) return;
|
|
102
|
+
const current = create(element, (0, vue.toValue)(options));
|
|
103
|
+
instance = current;
|
|
104
|
+
onCleanup(() => {
|
|
105
|
+
current.destroy();
|
|
106
|
+
if (instance === current) instance = null;
|
|
107
|
+
});
|
|
108
|
+
}, {
|
|
109
|
+
immediate: true,
|
|
110
|
+
flush: "post"
|
|
111
|
+
});
|
|
112
|
+
(0, vue.watch)(() => ({ ...(0, vue.toValue)(options) }), (next, previous) => {
|
|
113
|
+
if (instance) update(instance, (0, _tremolo_ui_dom.replaceOptions)(previous, next));
|
|
114
|
+
}, { flush: "post" });
|
|
115
|
+
return { current: () => instance };
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/composables/useDropZone.ts
|
|
119
|
+
/**
|
|
120
|
+
* Take files dropped on an element. See `createDropZone` in
|
|
121
|
+
* `@tremolo-ui/dom`. Returns whether files are over it, and whether they
|
|
122
|
+
* would be refused, for styling it while they are.
|
|
123
|
+
*/
|
|
124
|
+
function useDropZone(target, options = {}) {
|
|
125
|
+
const state = (0, vue.shallowRef)({
|
|
126
|
+
over: false,
|
|
127
|
+
invalid: false
|
|
128
|
+
});
|
|
129
|
+
const withState = () => ({
|
|
130
|
+
...(0, vue.toValue)(options),
|
|
131
|
+
onStateChange: (next) => {
|
|
132
|
+
state.value = next;
|
|
133
|
+
(0, vue.toValue)(options).onStateChange?.(next);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
useInstance(target, withState, _tremolo_ui_dom.createDropZone, (instance, next) => instance.update(next));
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/components/DropZone/DropZone.ts
|
|
141
|
+
/** A region that takes dropped files. */
|
|
142
|
+
const DropZone = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
143
|
+
name: "DropZone",
|
|
144
|
+
props: {
|
|
145
|
+
/** Which files to take, as the `accept` attribute of a file input. */
|
|
146
|
+
accept: String,
|
|
147
|
+
/** Take every file dropped, rather than the first. */
|
|
148
|
+
multiple: Boolean,
|
|
149
|
+
/** Ignore drops. The zone carries `data-disabled`. */
|
|
150
|
+
disabled: Boolean
|
|
151
|
+
},
|
|
152
|
+
emits: {
|
|
153
|
+
/** The dropped files that satisfy `accept`. */
|
|
154
|
+
drop: (files, _event) => Array.isArray(files),
|
|
155
|
+
/** The dropped files that do not satisfy `accept`, after `drop` for the same drop. */
|
|
156
|
+
reject: (files, _event) => Array.isArray(files)
|
|
157
|
+
},
|
|
158
|
+
setup(props, { slots, emit }) {
|
|
159
|
+
const el = (0, vue.ref)(null);
|
|
160
|
+
const state = useDropZone(el, () => ({
|
|
161
|
+
accept: props.accept,
|
|
162
|
+
multiple: props.multiple,
|
|
163
|
+
disabled: props.disabled,
|
|
164
|
+
onDrop: (files, event) => emit("drop", files, event),
|
|
165
|
+
onReject: (files, event) => emit("reject", files, event)
|
|
166
|
+
}));
|
|
167
|
+
return () => (0, vue.h)("div", {
|
|
168
|
+
ref: el,
|
|
169
|
+
"data-dragover": state.value.over ? "" : void 0,
|
|
170
|
+
"data-invalid": state.value.invalid ? "" : void 0,
|
|
171
|
+
"data-disabled": props.disabled ? "" : void 0
|
|
172
|
+
}, slots.default?.());
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/components/_util/context.ts
|
|
177
|
+
/** Read a component's context, or explain which parent is missing. */
|
|
178
|
+
function injectContext(key, parent) {
|
|
179
|
+
const context = (0, vue.inject)(key, null);
|
|
180
|
+
if (!context) throw new Error(`Missing ${parent} in the tree`);
|
|
181
|
+
return context;
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/components/FileInput/context.ts
|
|
185
|
+
const FileInputKey = Symbol("FileInput");
|
|
186
|
+
/** The context of the enclosing `FileInput`, for a part of your own. */
|
|
187
|
+
function useFileInputContext() {
|
|
188
|
+
return injectContext(FileInputKey, "FileInput");
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/components/FileInput/FileInput.ts
|
|
192
|
+
/**
|
|
193
|
+
* Picks files. The control is the native file input, out of sight but in the
|
|
194
|
+
* tab order, with `FileInputTrigger` as its label.
|
|
195
|
+
*/
|
|
196
|
+
const FileInput = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
197
|
+
name: "FileInput",
|
|
198
|
+
inheritAttrs: false,
|
|
199
|
+
props: {
|
|
200
|
+
/**
|
|
201
|
+
* Which files to take: extensions, MIME types and type groups. It is
|
|
202
|
+
* checked again when the files arrive, since the picker only takes it as
|
|
203
|
+
* a hint.
|
|
204
|
+
*/
|
|
205
|
+
accept: String,
|
|
206
|
+
/** Let several files be picked at once. */
|
|
207
|
+
multiple: Boolean,
|
|
208
|
+
/** Make the input unusable. The parts carry `data-disabled`. */
|
|
209
|
+
disabled: Boolean
|
|
210
|
+
},
|
|
211
|
+
emits: {
|
|
212
|
+
/** The picked files that satisfy `accept`. */
|
|
213
|
+
change: (files) => Array.isArray(files),
|
|
214
|
+
/** The picked files that do not satisfy `accept`, after `change` for the same selection. */
|
|
215
|
+
reject: (files) => Array.isArray(files)
|
|
216
|
+
},
|
|
217
|
+
setup(props, { slots, emit, attrs }) {
|
|
218
|
+
const inputId = (0, vue.useId)();
|
|
219
|
+
(0, vue.provide)(FileInputKey, {
|
|
220
|
+
inputId,
|
|
221
|
+
get disabled() {
|
|
222
|
+
return props.disabled;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
function onChange(event) {
|
|
226
|
+
const input = event.target;
|
|
227
|
+
const { accepted, rejected } = (0, _tremolo_ui_dom.partitionByAccept)(Array.from(input.files ?? []), props.accept);
|
|
228
|
+
input.value = "";
|
|
229
|
+
if (accepted.length > 0) emit("change", accepted);
|
|
230
|
+
if (rejected.length > 0) emit("reject", rejected);
|
|
231
|
+
}
|
|
232
|
+
return () => {
|
|
233
|
+
const { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...rest } = attrs;
|
|
234
|
+
return (0, vue.h)("div", {
|
|
235
|
+
...rest,
|
|
236
|
+
"data-disabled": props.disabled ? "" : void 0
|
|
237
|
+
}, [(0, vue.h)("input", {
|
|
238
|
+
id: inputId,
|
|
239
|
+
type: "file",
|
|
240
|
+
accept: props.accept,
|
|
241
|
+
multiple: props.multiple,
|
|
242
|
+
disabled: props.disabled,
|
|
243
|
+
style: _tremolo_ui_dom.visuallyHiddenStyle,
|
|
244
|
+
"aria-label": ariaLabel,
|
|
245
|
+
"aria-labelledby": ariaLabelledby,
|
|
246
|
+
"aria-describedby": ariaDescribedby,
|
|
247
|
+
onChange
|
|
248
|
+
}), slots.default?.()]);
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/components/FileInput/FileInputTrigger.ts
|
|
254
|
+
/**
|
|
255
|
+
* Opens the file picker and names the input. A `<label>` rather than a
|
|
256
|
+
* button: the browser forwards the click, the input stays the one thing in
|
|
257
|
+
* the tab order, and its name comes from this text.
|
|
258
|
+
*/
|
|
259
|
+
const FileInputTrigger = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
260
|
+
name: "FileInputTrigger",
|
|
261
|
+
setup(_, { slots }) {
|
|
262
|
+
const fileInput = useFileInputContext();
|
|
263
|
+
return () => (0, vue.h)("label", {
|
|
264
|
+
for: fileInput.inputId,
|
|
265
|
+
"data-disabled": fileInput.disabled ? "" : void 0
|
|
266
|
+
}, slots.default?.());
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/components/Knob/context.ts
|
|
271
|
+
const KnobKey = Symbol("Knob");
|
|
272
|
+
/** The context of the enclosing `Knob`, for a part of your own. */
|
|
273
|
+
function useKnobContext() {
|
|
274
|
+
return injectContext(KnobKey, "Knob");
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/composables/useDragValue.ts
|
|
278
|
+
/**
|
|
279
|
+
* Drive a value with a pointer drag. See `createDragValue` in
|
|
280
|
+
* `@tremolo-ui/dom` for the options; `elementMapping` and `relativeMapping`
|
|
281
|
+
* from there choose how the pointer becomes a value. `mapping` is read when
|
|
282
|
+
* the element is attached.
|
|
283
|
+
*/
|
|
284
|
+
function useDragValue(target, options) {
|
|
285
|
+
useInstance(target, options, _tremolo_ui_dom.createDragValue, (instance, next) => instance.update(next));
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/composables/useWheel.ts
|
|
289
|
+
/**
|
|
290
|
+
* Listen to the wheel on an element, as a non-passive listener so that
|
|
291
|
+
* `preventDefault()` can keep the page from scrolling. See `createWheel` in
|
|
292
|
+
* `@tremolo-ui/dom`.
|
|
293
|
+
*/
|
|
294
|
+
function useWheel(target, onWheel, options = {}) {
|
|
295
|
+
useInstance(target, options, (element, opts) => (0, _tremolo_ui_dom.createWheel)(element, (event) => onWheel(event), opts), (instance, next) => instance.update(next));
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/components/_util/useCheckSteps.ts
|
|
299
|
+
/**
|
|
300
|
+
* Warn, in development only, when a key press or a wheel notch cannot produce
|
|
301
|
+
* a change the user can see. The probing is `checkSteps` in the core.
|
|
302
|
+
*
|
|
303
|
+
* Takes a getter, so that the check runs again when the settings change.
|
|
304
|
+
*/
|
|
305
|
+
function useCheckSteps(options) {
|
|
306
|
+
try {
|
|
307
|
+
if (process.env.NODE_ENV === "production") return;
|
|
308
|
+
(0, vue.watchEffect)(() => {
|
|
309
|
+
for (const warning of (0, _tremolo_ui_dom.checkSteps)(options())) console.warn(warning);
|
|
310
|
+
});
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/components/Knob/Knob.ts
|
|
315
|
+
/**
|
|
316
|
+
* Interactive rotary knob, drawn in SVG by `KnobSVGRoot` and the parts inside
|
|
317
|
+
* it. Bind the value with `v-model`.
|
|
318
|
+
*/
|
|
319
|
+
const Knob = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
320
|
+
name: "Knob",
|
|
321
|
+
props: {
|
|
322
|
+
/** The current value. Bind it with `v-model`. */
|
|
323
|
+
modelValue: {
|
|
324
|
+
type: Number,
|
|
325
|
+
required: true
|
|
326
|
+
},
|
|
327
|
+
/** The value with the knob turned all the way down. */
|
|
328
|
+
min: {
|
|
329
|
+
type: Number,
|
|
330
|
+
required: true
|
|
331
|
+
},
|
|
332
|
+
/** The value with the knob turned all the way up. */
|
|
333
|
+
max: {
|
|
334
|
+
type: Number,
|
|
335
|
+
required: true
|
|
336
|
+
},
|
|
337
|
+
/** Granularity of the value. @default 1 */
|
|
338
|
+
step: {
|
|
339
|
+
type: Number,
|
|
340
|
+
default: 1
|
|
341
|
+
},
|
|
342
|
+
/** How the value is distributed across the travel. @default linearScale */
|
|
343
|
+
scale: {
|
|
344
|
+
type: Object,
|
|
345
|
+
default: () => _tremolo_ui_functions.linearScale
|
|
346
|
+
},
|
|
347
|
+
/** The value a double click restores. @default min */
|
|
348
|
+
defaultValue: Number,
|
|
349
|
+
/** Where the active arc starts. @default min */
|
|
350
|
+
startValue: Number,
|
|
351
|
+
/** Width and height of the knob. Sets `--knob-size`. */
|
|
352
|
+
size: [Number, String],
|
|
353
|
+
/** The cursor to show while dragging. @default { cursor: 'grabbing' } */
|
|
354
|
+
externalStyles: Object,
|
|
355
|
+
/** How much one notch of the wheel moves the value. `null` turns it off. */
|
|
356
|
+
wheel: {
|
|
357
|
+
type: [Array, Object],
|
|
358
|
+
default: () => _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS
|
|
359
|
+
},
|
|
360
|
+
/** How much one arrow key press moves the value. `null` turns it off. */
|
|
361
|
+
keyboard: {
|
|
362
|
+
type: [Array, Object],
|
|
363
|
+
default: () => _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS
|
|
364
|
+
},
|
|
365
|
+
/** How much a drag moves the value, per modifier key. */
|
|
366
|
+
dragSensitivity: {
|
|
367
|
+
type: [Number, Object],
|
|
368
|
+
default: () => _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY
|
|
369
|
+
},
|
|
370
|
+
/** Hide the cursor while dragging and read the movement directly. */
|
|
371
|
+
pointerLock: Boolean,
|
|
372
|
+
/** Restore `defaultValue` on a double click. @default true */
|
|
373
|
+
enableDoubleClickDefault: {
|
|
374
|
+
type: Boolean,
|
|
375
|
+
default: true
|
|
376
|
+
},
|
|
377
|
+
/** Make the knob unchangeable and remove it from the tab order. */
|
|
378
|
+
disabled: Boolean,
|
|
379
|
+
/** Make the knob unchangeable while leaving it focusable. */
|
|
380
|
+
readonly: Boolean,
|
|
381
|
+
/** How far the knob turns from `min` to `max`, in degrees. @default 270 */
|
|
382
|
+
angleRange: {
|
|
383
|
+
type: Number,
|
|
384
|
+
default: 270
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
emits: { "update:modelValue": (value) => typeof value === "number" },
|
|
388
|
+
setup(props, { slots, emit, expose }) {
|
|
389
|
+
const root = (0, vue.ref)(null);
|
|
390
|
+
const dragging = (0, vue.ref)(false);
|
|
391
|
+
const inactive = (0, vue.computed)(() => props.disabled || props.readonly);
|
|
392
|
+
const range = (0, vue.computed)(() => ({
|
|
393
|
+
min: props.min,
|
|
394
|
+
max: props.max,
|
|
395
|
+
step: props.step,
|
|
396
|
+
scale: props.scale
|
|
397
|
+
}));
|
|
398
|
+
const angles = (0, vue.computed)(() => (0, _tremolo_ui_dom.knobAngles)({
|
|
399
|
+
value: props.modelValue,
|
|
400
|
+
min: props.min,
|
|
401
|
+
max: props.max,
|
|
402
|
+
scale: props.scale,
|
|
403
|
+
startValue: props.startValue ?? props.min,
|
|
404
|
+
angleRange: props.angleRange
|
|
405
|
+
}));
|
|
406
|
+
useCheckSteps(() => ({
|
|
407
|
+
component: "Knob",
|
|
408
|
+
range: range.value,
|
|
409
|
+
keyboard: props.keyboard,
|
|
410
|
+
wheel: props.wheel
|
|
411
|
+
}));
|
|
412
|
+
(0, vue.provide)(KnobKey, {
|
|
413
|
+
get value() {
|
|
414
|
+
return props.modelValue;
|
|
415
|
+
},
|
|
416
|
+
get min() {
|
|
417
|
+
return props.min;
|
|
418
|
+
},
|
|
419
|
+
get max() {
|
|
420
|
+
return props.max;
|
|
421
|
+
},
|
|
422
|
+
get step() {
|
|
423
|
+
return props.step;
|
|
424
|
+
},
|
|
425
|
+
get scale() {
|
|
426
|
+
return props.scale;
|
|
427
|
+
},
|
|
428
|
+
get startValue() {
|
|
429
|
+
return props.startValue ?? props.min;
|
|
430
|
+
},
|
|
431
|
+
get angleRange() {
|
|
432
|
+
return props.angleRange;
|
|
433
|
+
},
|
|
434
|
+
get angles() {
|
|
435
|
+
return angles.value;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
const change = (next) => emit("update:modelValue", next);
|
|
439
|
+
useDragValue(root, () => ({
|
|
440
|
+
axis: [range.value, {
|
|
441
|
+
...range.value,
|
|
442
|
+
reverse: true
|
|
443
|
+
}],
|
|
444
|
+
mapping: (0, _tremolo_ui_dom.relativeMapping)({
|
|
445
|
+
pixelRange: 100,
|
|
446
|
+
sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(props.dragSensitivity, state.event).value
|
|
447
|
+
}),
|
|
448
|
+
getValue: () => [props.modelValue, props.modelValue],
|
|
449
|
+
threshold: 1,
|
|
450
|
+
cursor: inactive.value ? void 0 : props.externalStyles?.cursor ?? "grabbing",
|
|
451
|
+
pointerLock: inactive.value ? false : props.pointerLock,
|
|
452
|
+
shouldStart: () => !inactive.value,
|
|
453
|
+
onChange: (v) => {
|
|
454
|
+
if (!inactive.value) change(v[1]);
|
|
455
|
+
},
|
|
456
|
+
onDragStart: () => {
|
|
457
|
+
dragging.value = true;
|
|
458
|
+
},
|
|
459
|
+
onDragEnd: () => {
|
|
460
|
+
dragging.value = false;
|
|
461
|
+
}
|
|
462
|
+
}));
|
|
463
|
+
useWheel(root, (event) => {
|
|
464
|
+
if (!props.wheel || inactive.value) return;
|
|
465
|
+
const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
|
|
466
|
+
if (direction === null) return;
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
change((0, _tremolo_ui_dom.applyDelta)(props.modelValue, direction, props.wheel, range.value, event));
|
|
469
|
+
}, { requireFocus: true });
|
|
470
|
+
expose({
|
|
471
|
+
/** Move the focus to the knob. */
|
|
472
|
+
focus: () => {
|
|
473
|
+
if (!props.disabled) root.value?.focus();
|
|
474
|
+
},
|
|
475
|
+
/** Take the focus away from the knob. */
|
|
476
|
+
blur: () => root.value?.blur()
|
|
477
|
+
});
|
|
478
|
+
return () => (0, vue.h)("div", {
|
|
479
|
+
ref: root,
|
|
480
|
+
role: "slider",
|
|
481
|
+
tabindex: props.disabled ? -1 : 0,
|
|
482
|
+
"aria-valuenow": props.modelValue,
|
|
483
|
+
"aria-valuemin": props.min,
|
|
484
|
+
"aria-valuemax": props.max,
|
|
485
|
+
"aria-disabled": props.disabled,
|
|
486
|
+
"aria-readonly": props.readonly,
|
|
487
|
+
"data-disabled": props.disabled ? "" : void 0,
|
|
488
|
+
"data-readonly": props.readonly ? "" : void 0,
|
|
489
|
+
"data-dragging": dragging.value ? "" : void 0,
|
|
490
|
+
style: { "--knob-size": (0, _tremolo_ui_dom.cssLength)(props.size) },
|
|
491
|
+
onKeydown: (event) => {
|
|
492
|
+
if (!props.keyboard || inactive.value) return;
|
|
493
|
+
const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
|
|
494
|
+
if (direction === null) return;
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
change((0, _tremolo_ui_dom.applyDelta)(props.modelValue, direction, props.keyboard, range.value, event));
|
|
497
|
+
},
|
|
498
|
+
onDblclick: () => {
|
|
499
|
+
if (!inactive.value && props.enableDoubleClickDefault) change(props.defaultValue ?? props.min);
|
|
500
|
+
}
|
|
501
|
+
}, slots.default?.());
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/components/_util/placement.ts
|
|
506
|
+
const PLACEMENT = Symbol("tremolo-ui placement");
|
|
507
|
+
/**
|
|
508
|
+
* Mark the children of this component as being inside `name`, for
|
|
509
|
+
* {@link checkPlacement}. Call it during `setup`.
|
|
510
|
+
*/
|
|
511
|
+
function providePlacement(name) {
|
|
512
|
+
(0, vue.provide)(PLACEMENT, name);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Warn, in development only, when `child` is rendered outside `parent`. A
|
|
516
|
+
* part positioned against its parent still renders anywhere else, so nothing
|
|
517
|
+
* fails when it is misplaced — its position just comes out wrong. The names
|
|
518
|
+
* are the ones written in templates (`'KnobThumb'`), since they are what the
|
|
519
|
+
* warning shows. Call it during `setup`.
|
|
520
|
+
*/
|
|
521
|
+
function checkPlacement(child, parent) {
|
|
522
|
+
try {
|
|
523
|
+
if (process.env.NODE_ENV === "production") return;
|
|
524
|
+
const found = (0, vue.inject)(PLACEMENT, null);
|
|
525
|
+
if (found === parent) return;
|
|
526
|
+
console.warn(`[tremolo-ui] ${child} has to be rendered inside ${parent}` + (found === null ? "." : `, but it is inside ${found}.`) + " It renders either way, so nothing fails — its position just comes out wrong.");
|
|
527
|
+
} catch {}
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/components/Knob/KnobActiveLine.ts
|
|
531
|
+
/** The arc between `startValue` and the value, as an SVG `<path>`. */
|
|
532
|
+
const KnobActiveLine = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
533
|
+
name: "KnobActiveLine",
|
|
534
|
+
props: {
|
|
535
|
+
stroke: {
|
|
536
|
+
type: String,
|
|
537
|
+
default: "currentColor"
|
|
538
|
+
},
|
|
539
|
+
strokeWidth: {
|
|
540
|
+
type: [Number, String],
|
|
541
|
+
default: 6
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
setup(props) {
|
|
545
|
+
checkPlacement("KnobActiveLine", "KnobSVGRoot");
|
|
546
|
+
const knob = useKnobContext();
|
|
547
|
+
return () => (0, vue.h)("path", {
|
|
548
|
+
d: (0, _tremolo_ui_dom.knobArcPath)(knob.angles.r2, knob.angles.r3, (0, _tremolo_ui_dom.knobArcRadius)(props.strokeWidth)),
|
|
549
|
+
fill: "none",
|
|
550
|
+
stroke: props.stroke,
|
|
551
|
+
"stroke-width": props.strokeWidth
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/components/Knob/KnobInactiveLine.ts
|
|
557
|
+
/**
|
|
558
|
+
* The travel outside the value, as SVG `<path>`s: one on each side of the
|
|
559
|
+
* active arc that has any length.
|
|
560
|
+
*/
|
|
561
|
+
const KnobInactiveLine = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
562
|
+
name: "KnobInactiveLine",
|
|
563
|
+
inheritAttrs: false,
|
|
564
|
+
props: {
|
|
565
|
+
stroke: {
|
|
566
|
+
type: String,
|
|
567
|
+
default: "currentColor"
|
|
568
|
+
},
|
|
569
|
+
strokeWidth: {
|
|
570
|
+
type: [Number, String],
|
|
571
|
+
default: 6
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
setup(props, { attrs }) {
|
|
575
|
+
checkPlacement("KnobInactiveLine", "KnobSVGRoot");
|
|
576
|
+
const knob = useKnobContext();
|
|
577
|
+
const path = (from, to) => (0, vue.h)("path", {
|
|
578
|
+
...attrs,
|
|
579
|
+
d: (0, _tremolo_ui_dom.knobArcPath)(from, to, (0, _tremolo_ui_dom.knobArcRadius)(props.strokeWidth)),
|
|
580
|
+
fill: "none",
|
|
581
|
+
stroke: props.stroke,
|
|
582
|
+
"stroke-width": props.strokeWidth
|
|
583
|
+
});
|
|
584
|
+
return () => [knob.startValue > knob.min ? path(knob.angles.r1, knob.angles.r2) : null, knob.startValue < knob.max ? path(knob.angles.r3, knob.angles.r4) : null];
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/components/Knob/KnobSVGRoot.ts
|
|
589
|
+
/**
|
|
590
|
+
* The `<svg>` the drawing parts go in, in the order you want them painted.
|
|
591
|
+
* It sets the view box they are drawn against.
|
|
592
|
+
*/
|
|
593
|
+
const KnobSVGRoot = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
594
|
+
name: "KnobSVGRoot",
|
|
595
|
+
setup(_, { slots }) {
|
|
596
|
+
providePlacement("KnobSVGRoot");
|
|
597
|
+
return () => (0, vue.h)("svg", {
|
|
598
|
+
viewBox: `0 0 ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE} ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE}`,
|
|
599
|
+
style: { display: "block" }
|
|
600
|
+
}, slots.default?.());
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/components/Knob/KnobThumb.ts
|
|
605
|
+
/** The circle that turns with the value, and the line on it that points at it. */
|
|
606
|
+
const KnobThumb = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
607
|
+
name: "KnobThumb",
|
|
608
|
+
props: {
|
|
609
|
+
/** Fill colour of the circle. @default 'currentColor' */
|
|
610
|
+
thumb: {
|
|
611
|
+
type: String,
|
|
612
|
+
default: "currentColor"
|
|
613
|
+
},
|
|
614
|
+
/** Colour of the line that points at the value. @default 'currentColor' */
|
|
615
|
+
thumbLine: {
|
|
616
|
+
type: String,
|
|
617
|
+
default: "currentColor"
|
|
618
|
+
},
|
|
619
|
+
/** Diameter of the circle, as a percentage of the knob. @default 84 */
|
|
620
|
+
thumbSize: {
|
|
621
|
+
type: Number,
|
|
622
|
+
default: 84
|
|
623
|
+
},
|
|
624
|
+
/** Thickness of the line, as a percentage of the knob. @default 6 */
|
|
625
|
+
thumbLineWeight: {
|
|
626
|
+
type: Number,
|
|
627
|
+
default: 6
|
|
628
|
+
},
|
|
629
|
+
/** How far down the line reaches, as a percentage. @default 35 */
|
|
630
|
+
thumbLineLength: {
|
|
631
|
+
type: Number,
|
|
632
|
+
default: 35
|
|
633
|
+
},
|
|
634
|
+
/** Classes for the line that points at the value. */
|
|
635
|
+
classes: Object
|
|
636
|
+
},
|
|
637
|
+
setup(props) {
|
|
638
|
+
checkPlacement("KnobThumb", "KnobSVGRoot");
|
|
639
|
+
const knob = useKnobContext();
|
|
640
|
+
return () => (0, vue.h)("svg", null, [(0, vue.h)("circle", {
|
|
641
|
+
cx: "50%",
|
|
642
|
+
cy: "50%",
|
|
643
|
+
r: `${props.thumbSize / 2}%`,
|
|
644
|
+
fill: props.thumb
|
|
645
|
+
}), (0, vue.h)("line", {
|
|
646
|
+
class: props.classes?.thumbLine,
|
|
647
|
+
x1: "50%",
|
|
648
|
+
y1: `${(_tremolo_ui_dom.KNOB_VIEWBOX_SIZE - (0, _tremolo_ui_functions.clamp)(props.thumbSize, 0, 100)) / 2}%`,
|
|
649
|
+
x2: "50%",
|
|
650
|
+
y2: `${props.thumbLineLength}%`,
|
|
651
|
+
stroke: props.thumbLine,
|
|
652
|
+
"stroke-width": `${props.thumbLineWeight}%`,
|
|
653
|
+
style: {
|
|
654
|
+
transform: `rotate(${knob.angles.r1 + knob.angles.p * knob.angleRange}deg)`,
|
|
655
|
+
transformOrigin: "50% 50%"
|
|
656
|
+
}
|
|
657
|
+
})]);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/components/NumberInput/context.ts
|
|
662
|
+
const NumberInputKey = Symbol("NumberInput");
|
|
663
|
+
/** The context of the enclosing `NumberInput`, for a part of your own. */
|
|
664
|
+
function useNumberInputContext() {
|
|
665
|
+
return injectContext(NumberInputKey, "NumberInput");
|
|
666
|
+
}
|
|
667
|
+
/** @internal Whether the drag on `NumberInputStepper` has moved the value. */
|
|
668
|
+
const StepperKey = Symbol("NumberInputStepper");
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/components/_util/props.ts
|
|
671
|
+
/** How much one notch of the wheel moves the value. `null` turns it off. */
|
|
672
|
+
const wheelProp = {
|
|
673
|
+
type: [Array, Object],
|
|
674
|
+
default: () => _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS
|
|
675
|
+
};
|
|
676
|
+
/** How much one arrow key press moves the value. `null` turns it off. */
|
|
677
|
+
const keyboardProp = {
|
|
678
|
+
type: [Array, Object],
|
|
679
|
+
default: () => _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS
|
|
680
|
+
};
|
|
681
|
+
/** How much a drag moves the value, per modifier key. */
|
|
682
|
+
const dragSensitivityProp = {
|
|
683
|
+
type: [Number, Object],
|
|
684
|
+
default: () => _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY
|
|
685
|
+
};
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/components/NumberInput/NumberInput.ts
|
|
688
|
+
/** Input with some useful functions for entering numbers. Bind it with `v-model`. */
|
|
689
|
+
const NumberInput = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
690
|
+
name: "NumberInput",
|
|
691
|
+
props: {
|
|
692
|
+
/** The current value. Bind it with `v-model`. */
|
|
693
|
+
modelValue: {
|
|
694
|
+
type: Number,
|
|
695
|
+
required: true
|
|
696
|
+
},
|
|
697
|
+
/** The lowest value. Leave it out for no lower end. */
|
|
698
|
+
min: Number,
|
|
699
|
+
/** The highest value. Leave it out for no upper end. */
|
|
700
|
+
max: Number,
|
|
701
|
+
/** Granularity of the value. @default 1 */
|
|
702
|
+
step: {
|
|
703
|
+
type: Number,
|
|
704
|
+
default: 1
|
|
705
|
+
},
|
|
706
|
+
/** How the value is distributed, for a `normalized` amount. */
|
|
707
|
+
scale: {
|
|
708
|
+
type: Object,
|
|
709
|
+
default: () => _tremolo_ui_functions.linearScale
|
|
710
|
+
},
|
|
711
|
+
/** The text shown for a value. @default String */
|
|
712
|
+
format: {
|
|
713
|
+
type: Function,
|
|
714
|
+
default: String
|
|
715
|
+
},
|
|
716
|
+
/** Read a value back out of the text. Has to undo `format`. */
|
|
717
|
+
parse: {
|
|
718
|
+
type: Function,
|
|
719
|
+
default: _tremolo_ui_dom.parseNumberText
|
|
720
|
+
},
|
|
721
|
+
/** Keep the value within `min` and `max` when committed or stepped. */
|
|
722
|
+
clampValue: {
|
|
723
|
+
type: Boolean,
|
|
724
|
+
default: true
|
|
725
|
+
},
|
|
726
|
+
wheel: wheelProp,
|
|
727
|
+
keyboard: keyboardProp,
|
|
728
|
+
dragSensitivity: dragSensitivityProp,
|
|
729
|
+
/** Pixels of vertical drag on the stepper per `step`. `null` turns it off. */
|
|
730
|
+
drag: {
|
|
731
|
+
type: Number,
|
|
732
|
+
default: 1
|
|
733
|
+
},
|
|
734
|
+
/** Hide the cursor while dragging the stepper. */
|
|
735
|
+
pointerLock: Boolean,
|
|
736
|
+
/** Select the text when the field takes focus. @default 'none' */
|
|
737
|
+
selectOnFocus: {
|
|
738
|
+
type: String,
|
|
739
|
+
default: "none"
|
|
740
|
+
},
|
|
741
|
+
/** Show the plain value while the field has focus. */
|
|
742
|
+
unformatOnFocus: Boolean,
|
|
743
|
+
/** Put the caret back at the same digit after an arrow key step. */
|
|
744
|
+
keepCaretOnStep: Boolean,
|
|
745
|
+
/** Commit and leave the field on Enter. @default true */
|
|
746
|
+
blurOnEnter: {
|
|
747
|
+
type: Boolean,
|
|
748
|
+
default: true
|
|
749
|
+
},
|
|
750
|
+
/** Make the input unchangeable and remove it from the tab order. */
|
|
751
|
+
disabled: Boolean,
|
|
752
|
+
/** Make the value unchangeable while leaving the field focusable. */
|
|
753
|
+
readonly: Boolean
|
|
754
|
+
},
|
|
755
|
+
emits: { "update:modelValue": (value) => typeof value === "number" },
|
|
756
|
+
setup(props, { slots, emit, expose }) {
|
|
757
|
+
const root = (0, vue.ref)(null);
|
|
758
|
+
/**
|
|
759
|
+
* The text being typed. The only state here: not a copy of the value, but
|
|
760
|
+
* the half-finished entry that has no value to be derived from yet.
|
|
761
|
+
*/
|
|
762
|
+
const draft = (0, vue.ref)(null);
|
|
763
|
+
let input = null;
|
|
764
|
+
const inactive = (0, vue.computed)(() => props.disabled || props.readonly);
|
|
765
|
+
const ranges = (0, vue.computed)(() => (0, _tremolo_ui_dom.numberInputRanges)({
|
|
766
|
+
min: props.min,
|
|
767
|
+
max: props.max,
|
|
768
|
+
step: props.step,
|
|
769
|
+
scale: props.scale,
|
|
770
|
+
clampValue: props.clampValue
|
|
771
|
+
}));
|
|
772
|
+
const bounds = (0, vue.computed)(() => (0, _tremolo_ui_dom.numberInputBounds)(props.modelValue, {
|
|
773
|
+
min: props.min,
|
|
774
|
+
max: props.max,
|
|
775
|
+
clampValue: props.clampValue
|
|
776
|
+
}));
|
|
777
|
+
const text = (0, vue.computed)(() => draft.value ?? props.format(props.modelValue));
|
|
778
|
+
useCheckSteps(() => ({
|
|
779
|
+
component: "NumberInput",
|
|
780
|
+
range: props.min !== void 0 && props.max !== void 0 && props.min < props.max ? {
|
|
781
|
+
min: props.min,
|
|
782
|
+
max: props.max,
|
|
783
|
+
step: props.step,
|
|
784
|
+
scale: props.scale
|
|
785
|
+
} : null,
|
|
786
|
+
keyboard: props.keyboard,
|
|
787
|
+
wheel: props.wheel,
|
|
788
|
+
format: props.format
|
|
789
|
+
}));
|
|
790
|
+
function changeValue(next) {
|
|
791
|
+
if (inactive.value) return;
|
|
792
|
+
draft.value = null;
|
|
793
|
+
if (next !== props.modelValue) emit("update:modelValue", next);
|
|
794
|
+
}
|
|
795
|
+
(0, vue.provide)(NumberInputKey, {
|
|
796
|
+
get value() {
|
|
797
|
+
return props.modelValue;
|
|
798
|
+
},
|
|
799
|
+
get min() {
|
|
800
|
+
return props.min;
|
|
801
|
+
},
|
|
802
|
+
get max() {
|
|
803
|
+
return props.max;
|
|
804
|
+
},
|
|
805
|
+
get step() {
|
|
806
|
+
return props.step;
|
|
807
|
+
},
|
|
808
|
+
get disabled() {
|
|
809
|
+
return props.disabled;
|
|
810
|
+
},
|
|
811
|
+
get readonly() {
|
|
812
|
+
return props.readonly;
|
|
813
|
+
},
|
|
814
|
+
get keyboard() {
|
|
815
|
+
return props.keyboard;
|
|
816
|
+
},
|
|
817
|
+
get drag() {
|
|
818
|
+
return props.drag;
|
|
819
|
+
},
|
|
820
|
+
get dragSensitivity() {
|
|
821
|
+
return props.dragSensitivity;
|
|
822
|
+
},
|
|
823
|
+
get pointerLock() {
|
|
824
|
+
return props.pointerLock;
|
|
825
|
+
},
|
|
826
|
+
get selectOnFocus() {
|
|
827
|
+
return props.selectOnFocus;
|
|
828
|
+
},
|
|
829
|
+
get unformatOnFocus() {
|
|
830
|
+
return props.unformatOnFocus;
|
|
831
|
+
},
|
|
832
|
+
get keepCaretOnStep() {
|
|
833
|
+
return props.keepCaretOnStep;
|
|
834
|
+
},
|
|
835
|
+
get blurOnEnter() {
|
|
836
|
+
return props.blurOnEnter;
|
|
837
|
+
},
|
|
838
|
+
get text() {
|
|
839
|
+
return text.value;
|
|
840
|
+
},
|
|
841
|
+
get editing() {
|
|
842
|
+
return draft.value !== null;
|
|
843
|
+
},
|
|
844
|
+
get outOfRange() {
|
|
845
|
+
return draft.value === null && bounds.value.outOfRange;
|
|
846
|
+
},
|
|
847
|
+
get atMin() {
|
|
848
|
+
return bounds.value.atMin;
|
|
849
|
+
},
|
|
850
|
+
get atMax() {
|
|
851
|
+
return bounds.value.atMax;
|
|
852
|
+
},
|
|
853
|
+
get rawRange() {
|
|
854
|
+
return ranges.value.raw;
|
|
855
|
+
},
|
|
856
|
+
setDraft: (next) => {
|
|
857
|
+
if (inactive.value) return;
|
|
858
|
+
draft.value = next;
|
|
859
|
+
const parsed = props.parse(next);
|
|
860
|
+
if (Number.isFinite(parsed)) emit("update:modelValue", parsed);
|
|
861
|
+
},
|
|
862
|
+
commitDraft: () => {
|
|
863
|
+
if (draft.value === null || inactive.value) return;
|
|
864
|
+
const committed = (0, _tremolo_ui_dom.commitNumberInputText)(draft.value, props.parse, {
|
|
865
|
+
min: props.min,
|
|
866
|
+
max: props.max,
|
|
867
|
+
clampValue: props.clampValue
|
|
868
|
+
});
|
|
869
|
+
if (committed === null) draft.value = null;
|
|
870
|
+
else changeValue(committed);
|
|
871
|
+
},
|
|
872
|
+
changeValue,
|
|
873
|
+
nudge: (direction, option, modifiers) => changeValue((0, _tremolo_ui_dom.nudgeNumberInput)(props.modelValue, direction, option, ranges.value, modifiers)),
|
|
874
|
+
setInput: (element) => {
|
|
875
|
+
input = element;
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
useWheel(root, (event) => {
|
|
879
|
+
const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
|
|
880
|
+
if (!props.wheel || inactive.value || direction === null) return;
|
|
881
|
+
event.preventDefault();
|
|
882
|
+
changeValue((0, _tremolo_ui_dom.nudgeNumberInput)(props.modelValue, direction, props.wheel, ranges.value, event));
|
|
883
|
+
}, { requireFocus: true });
|
|
884
|
+
expose({
|
|
885
|
+
focus: () => {
|
|
886
|
+
if (!props.disabled) input?.focus();
|
|
887
|
+
},
|
|
888
|
+
blur: () => input?.blur()
|
|
889
|
+
});
|
|
890
|
+
return () => (0, vue.h)("div", {
|
|
891
|
+
ref: root,
|
|
892
|
+
"data-disabled": props.disabled ? "" : void 0,
|
|
893
|
+
"data-readonly": props.readonly ? "" : void 0
|
|
894
|
+
}, slots.default?.());
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/composables/useLongPress.ts
|
|
899
|
+
/**
|
|
900
|
+
* Repeat `onPress` while a pointer is held down: once on the press, then
|
|
901
|
+
* every `interval` after `delay`. See `createLongPress` in `@tremolo-ui/dom`.
|
|
902
|
+
*
|
|
903
|
+
* Returns the function that starts a press, for `@pointerdown`.
|
|
904
|
+
*/
|
|
905
|
+
function useLongPress(options) {
|
|
906
|
+
const instance = (0, _tremolo_ui_dom.createLongPress)((0, vue.toValue)(options));
|
|
907
|
+
(0, vue.watch)(() => (0, vue.toValue)(options), (next) => instance.update(next));
|
|
908
|
+
(0, vue.onScopeDispose)(() => instance.destroy());
|
|
909
|
+
return (event) => instance.start(event);
|
|
910
|
+
}
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region src/components/NumberInput/stepper-button.ts
|
|
913
|
+
/** The body of `NumberInputIncrementStepper` and `NumberInputDecrementStepper`. */
|
|
914
|
+
function stepperButton(name, direction) {
|
|
915
|
+
return (0, vue.defineComponent)({
|
|
916
|
+
name,
|
|
917
|
+
setup(_, { slots }) {
|
|
918
|
+
const field = useNumberInputContext();
|
|
919
|
+
const stepper = (0, vue.inject)(StepperKey, null);
|
|
920
|
+
const blocked = (0, vue.computed)(() => field.disabled || (direction > 0 ? field.atMax : field.atMin));
|
|
921
|
+
const press = useLongPress({ onPress: () => {
|
|
922
|
+
if (field.disabled || field.readonly) return;
|
|
923
|
+
if (stepper?.moved()) return;
|
|
924
|
+
field.nudge(direction, ["raw", field.step]);
|
|
925
|
+
} });
|
|
926
|
+
return () => (0, vue.h)("div", {
|
|
927
|
+
role: "button",
|
|
928
|
+
tabindex: -1,
|
|
929
|
+
"aria-label": direction > 0 ? "Increment" : "Decrement",
|
|
930
|
+
"aria-disabled": blocked.value,
|
|
931
|
+
"data-disabled": blocked.value ? "" : void 0,
|
|
932
|
+
"data-readonly": field.readonly ? "" : void 0,
|
|
933
|
+
onPointerdown: (event) => press(event)
|
|
934
|
+
}, slots.default ? slots.default() : (0, vue.h)("svg", {
|
|
935
|
+
style: {
|
|
936
|
+
width: "var(--stepper-icon-size)",
|
|
937
|
+
height: "var(--stepper-icon-size)"
|
|
938
|
+
},
|
|
939
|
+
viewBox: "0 0 24 24",
|
|
940
|
+
fill: "none",
|
|
941
|
+
stroke: "currentColor",
|
|
942
|
+
"stroke-width": 2,
|
|
943
|
+
"stroke-linecap": "round",
|
|
944
|
+
"stroke-linejoin": "round"
|
|
945
|
+
}, (0, vue.h)("polyline", { points: direction > 0 ? "18 15 12 9 6 15" : "6 9 12 15 18 9" })));
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/components/NumberInput/NumberInputDecrementStepper.ts
|
|
951
|
+
/** Lowers the value by one `step`, repeating while held. */
|
|
952
|
+
const NumberInputDecrementStepper = /* @__PURE__ */ stepperButton("NumberInputDecrementStepper", -1);
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/components/NumberInput/NumberInputField.ts
|
|
955
|
+
/**
|
|
956
|
+
* The text field, and the only place the value can be typed. While the user
|
|
957
|
+
* types, their own text stands rather than `format(value)`.
|
|
958
|
+
*/
|
|
959
|
+
const NumberInputField = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
960
|
+
name: "NumberInputField",
|
|
961
|
+
setup(_, { expose }) {
|
|
962
|
+
const field = useNumberInputContext();
|
|
963
|
+
const input = (0, vue.ref)(null);
|
|
964
|
+
const focused = (0, vue.ref)(false);
|
|
965
|
+
(0, vue.watchEffect)(() => field.setInput(input.value));
|
|
966
|
+
(0, vue.onBeforeUnmount)(() => field.setInput(null));
|
|
967
|
+
const shown = (0, vue.computed)(() => field.unformatOnFocus && focused.value && !field.editing ? String(field.value) : field.text);
|
|
968
|
+
async function select() {
|
|
969
|
+
if (field.selectOnFocus === "none") return;
|
|
970
|
+
await (0, vue.nextTick)();
|
|
971
|
+
const element = input.value;
|
|
972
|
+
if (!element || !focused.value) return;
|
|
973
|
+
const span = field.selectOnFocus === "all" ? {
|
|
974
|
+
start: 0,
|
|
975
|
+
end: element.value.length
|
|
976
|
+
} : (0, _tremolo_ui_dom.numberSpan)(element.value);
|
|
977
|
+
if (span) element.setSelectionRange(span.start, span.end);
|
|
978
|
+
}
|
|
979
|
+
async function step(direction, event) {
|
|
980
|
+
const element = event.currentTarget;
|
|
981
|
+
const from = element.value;
|
|
982
|
+
const offset = field.keepCaretOnStep && element.selectionStart !== null ? (0, _tremolo_ui_dom.caretDecimalOffset)(from, element.selectionStart) : null;
|
|
983
|
+
field.nudge(direction, field.keyboard, event);
|
|
984
|
+
if (offset === null) return;
|
|
985
|
+
await (0, vue.nextTick)();
|
|
986
|
+
if (element.value === from) return;
|
|
987
|
+
const caret = (0, _tremolo_ui_dom.caretAtDecimalOffset)(element.value, offset);
|
|
988
|
+
element.setSelectionRange(caret, caret);
|
|
989
|
+
}
|
|
990
|
+
expose({
|
|
991
|
+
focus: () => input.value?.focus(),
|
|
992
|
+
blur: () => input.value?.blur()
|
|
993
|
+
});
|
|
994
|
+
return () => (0, vue.h)("input", {
|
|
995
|
+
ref: input,
|
|
996
|
+
type: "text",
|
|
997
|
+
inputmode: "decimal",
|
|
998
|
+
role: "spinbutton",
|
|
999
|
+
value: shown.value,
|
|
1000
|
+
disabled: field.disabled,
|
|
1001
|
+
readonly: field.readonly,
|
|
1002
|
+
"aria-disabled": field.disabled,
|
|
1003
|
+
"aria-readonly": field.readonly,
|
|
1004
|
+
"aria-valuenow": field.value,
|
|
1005
|
+
"aria-valuemin": field.min,
|
|
1006
|
+
"aria-valuemax": field.max,
|
|
1007
|
+
"aria-valuetext": field.text,
|
|
1008
|
+
step: field.step,
|
|
1009
|
+
"data-disabled": field.disabled ? "" : void 0,
|
|
1010
|
+
"data-readonly": field.readonly ? "" : void 0,
|
|
1011
|
+
"data-out-of-range": field.outOfRange ? "" : void 0,
|
|
1012
|
+
onInput: (event) => field.setDraft(event.target.value),
|
|
1013
|
+
onFocus: () => {
|
|
1014
|
+
focused.value = true;
|
|
1015
|
+
select();
|
|
1016
|
+
},
|
|
1017
|
+
onBlur: () => {
|
|
1018
|
+
focused.value = false;
|
|
1019
|
+
field.commitDraft();
|
|
1020
|
+
},
|
|
1021
|
+
onKeydown: (event) => {
|
|
1022
|
+
const key = event.key;
|
|
1023
|
+
if (key === "Enter") {
|
|
1024
|
+
field.commitDraft();
|
|
1025
|
+
if (field.blurOnEnter) event.target.blur();
|
|
1026
|
+
} else if (field.keyboard && !field.disabled && !field.readonly && (key === "ArrowUp" || key === "ArrowDown") && !event.isComposing) {
|
|
1027
|
+
event.preventDefault();
|
|
1028
|
+
step(key === "ArrowUp" ? 1 : -1, event);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/components/NumberInput/NumberInputIncrementStepper.ts
|
|
1036
|
+
/** Raises the value by one `step`, repeating while held. */
|
|
1037
|
+
const NumberInputIncrementStepper = /* @__PURE__ */ stepperButton("NumberInputIncrementStepper", 1);
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/components/NumberInput/NumberInputStepper.ts
|
|
1040
|
+
/**
|
|
1041
|
+
* The area the steppers sit in, and a drag handle in its own right: dragging
|
|
1042
|
+
* it up and down moves the value one `step` every `drag` pixels.
|
|
1043
|
+
*/
|
|
1044
|
+
const NumberInputStepper = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1045
|
+
name: "NumberInputStepper",
|
|
1046
|
+
setup(_, { slots }) {
|
|
1047
|
+
const field = useNumberInputContext();
|
|
1048
|
+
const el = (0, vue.ref)(null);
|
|
1049
|
+
let instance = null;
|
|
1050
|
+
const enabled = (0, vue.computed)(() => field.drag !== null && !field.disabled && !field.readonly);
|
|
1051
|
+
const options = () => ({
|
|
1052
|
+
range: field.rawRange,
|
|
1053
|
+
pixels: field.drag ?? 1,
|
|
1054
|
+
sensitivity: field.dragSensitivity,
|
|
1055
|
+
pointerLock: field.pointerLock
|
|
1056
|
+
});
|
|
1057
|
+
(0, vue.watch)([el, enabled], ([element, on], _, onCleanup) => {
|
|
1058
|
+
if (!element || !on) return;
|
|
1059
|
+
const current = (0, _tremolo_ui_dom.createStepperDrag)(element, {
|
|
1060
|
+
...options(),
|
|
1061
|
+
getValue: () => field.value,
|
|
1062
|
+
onChange: (next) => field.changeValue(next)
|
|
1063
|
+
});
|
|
1064
|
+
instance = current;
|
|
1065
|
+
onCleanup(() => {
|
|
1066
|
+
current.destroy();
|
|
1067
|
+
if (instance === current) instance = null;
|
|
1068
|
+
});
|
|
1069
|
+
}, {
|
|
1070
|
+
immediate: true,
|
|
1071
|
+
flush: "post"
|
|
1072
|
+
});
|
|
1073
|
+
(0, vue.watch)(options, (next) => instance?.update(next));
|
|
1074
|
+
(0, vue.provide)(StepperKey, { moved: () => instance?.moved() ?? false });
|
|
1075
|
+
return () => (0, vue.h)("div", { ref: el }, slots.default?.());
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/components/Piano/Piano.ts
|
|
1080
|
+
/**
|
|
1081
|
+
* Customizable piano. The keys are drawn by the component; what goes inside
|
|
1082
|
+
* one is the `label` slot, which receives the note and its state.
|
|
1083
|
+
*/
|
|
1084
|
+
const Piano = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1085
|
+
name: "Piano",
|
|
1086
|
+
props: {
|
|
1087
|
+
/** The notes drawn, from `first` to `last`. */
|
|
1088
|
+
noteRange: {
|
|
1089
|
+
type: Object,
|
|
1090
|
+
required: true
|
|
1091
|
+
},
|
|
1092
|
+
/** Let a pointer slide from one key to the next. @default true */
|
|
1093
|
+
glissando: {
|
|
1094
|
+
type: Boolean,
|
|
1095
|
+
default: true
|
|
1096
|
+
},
|
|
1097
|
+
/** Highest note that can sound. @default 127 */
|
|
1098
|
+
midiMax: {
|
|
1099
|
+
type: Number,
|
|
1100
|
+
default: 127
|
|
1101
|
+
},
|
|
1102
|
+
/** Play notes from the computer keyboard. See `SHORTCUTS` in `@tremolo-ui/dom`. */
|
|
1103
|
+
keyboardShortcuts: Object,
|
|
1104
|
+
/** Where keyboard shortcuts listen. @default 'root' */
|
|
1105
|
+
keyboardShortcutsScope: {
|
|
1106
|
+
type: String,
|
|
1107
|
+
default: "root"
|
|
1108
|
+
},
|
|
1109
|
+
/** Follow the width of the parent element. */
|
|
1110
|
+
resizable: Boolean,
|
|
1111
|
+
/** Width of a white key in pixels. @default 40 */
|
|
1112
|
+
whiteKeyWidth: {
|
|
1113
|
+
type: Number,
|
|
1114
|
+
default: 40
|
|
1115
|
+
},
|
|
1116
|
+
/** Space between two white keys, in pixels. @default 1 */
|
|
1117
|
+
keyGap: {
|
|
1118
|
+
type: Number,
|
|
1119
|
+
default: 1
|
|
1120
|
+
},
|
|
1121
|
+
/** Width of a black key, as a fraction of a white one. @default 0.65 */
|
|
1122
|
+
blackKeyWidthRatio: {
|
|
1123
|
+
type: Number,
|
|
1124
|
+
default: .65
|
|
1125
|
+
},
|
|
1126
|
+
/** Height of a black key, as a fraction of the keyboard. @default 0.6 */
|
|
1127
|
+
blackKeyHeightRatio: {
|
|
1128
|
+
type: Number,
|
|
1129
|
+
default: .6
|
|
1130
|
+
},
|
|
1131
|
+
/** Classes for the label inside each key. */
|
|
1132
|
+
classes: Object,
|
|
1133
|
+
/**
|
|
1134
|
+
* Extra attributes for one key, by note. The geometry of the key is
|
|
1135
|
+
* applied after the returned style and cannot be overridden.
|
|
1136
|
+
*/
|
|
1137
|
+
keyProps: Function
|
|
1138
|
+
},
|
|
1139
|
+
emits: {
|
|
1140
|
+
/** A note started sounding. */
|
|
1141
|
+
playNote: (note, _velocity) => typeof note === "number",
|
|
1142
|
+
/** Everything holding a note let go of it. */
|
|
1143
|
+
stopNote: (note) => typeof note === "number"
|
|
1144
|
+
},
|
|
1145
|
+
slots: Object,
|
|
1146
|
+
setup(props, { slots, emit, expose }) {
|
|
1147
|
+
const root = (0, vue.ref)(null);
|
|
1148
|
+
const activeNotes = (0, vue.ref)([]);
|
|
1149
|
+
/** Set while `resizable` is on, measured from the parent. */
|
|
1150
|
+
const resizedKeyWidth = (0, vue.ref)(null);
|
|
1151
|
+
const notes = (0, vue.computed)(() => (0, _tremolo_ui_dom.getNoteRangeArray)(props.noteRange));
|
|
1152
|
+
const layout = (0, vue.computed)(() => ({
|
|
1153
|
+
noteRange: props.noteRange,
|
|
1154
|
+
whiteKeyWidth: props.resizable ? resizedKeyWidth.value ?? props.whiteKeyWidth : props.whiteKeyWidth,
|
|
1155
|
+
keyGap: props.keyGap,
|
|
1156
|
+
blackKeyWidthRatio: props.blackKeyWidthRatio,
|
|
1157
|
+
blackKeyHeightRatio: props.blackKeyHeightRatio
|
|
1158
|
+
}));
|
|
1159
|
+
const settings = () => ({
|
|
1160
|
+
layout: layout.value,
|
|
1161
|
+
glissando: props.glissando,
|
|
1162
|
+
midiMax: props.midiMax,
|
|
1163
|
+
keyboardShortcuts: props.keyboardShortcuts,
|
|
1164
|
+
keyboardShortcutsScope: props.keyboardShortcutsScope
|
|
1165
|
+
});
|
|
1166
|
+
let instance = null;
|
|
1167
|
+
(0, vue.watch)(root, (element, _, onCleanup) => {
|
|
1168
|
+
if (!element) return;
|
|
1169
|
+
const current = (0, _tremolo_ui_dom.createPianoInput)(element, {
|
|
1170
|
+
...settings(),
|
|
1171
|
+
onPlayNote: (note, velocity) => emit("playNote", note, velocity),
|
|
1172
|
+
onStopNote: (note) => emit("stopNote", note),
|
|
1173
|
+
onActiveNotesChange: (next) => {
|
|
1174
|
+
activeNotes.value = next;
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
instance = current;
|
|
1178
|
+
onCleanup(() => {
|
|
1179
|
+
instance = null;
|
|
1180
|
+
current.destroy();
|
|
1181
|
+
});
|
|
1182
|
+
}, {
|
|
1183
|
+
immediate: true,
|
|
1184
|
+
flush: "post"
|
|
1185
|
+
});
|
|
1186
|
+
(0, vue.watch)(settings, (next) => instance?.update(next));
|
|
1187
|
+
(0, vue.watch)(() => [
|
|
1188
|
+
root.value,
|
|
1189
|
+
props.resizable,
|
|
1190
|
+
props.noteRange.first,
|
|
1191
|
+
props.noteRange.last,
|
|
1192
|
+
props.keyGap,
|
|
1193
|
+
props.blackKeyWidthRatio
|
|
1194
|
+
], ([element, resizable, first, last, keyGap, blackKeyWidthRatio], _, onCleanup) => {
|
|
1195
|
+
if (!resizable || !element) return;
|
|
1196
|
+
const parent = element.parentElement;
|
|
1197
|
+
if (!parent) throw new Error("doesn't have a parent element.");
|
|
1198
|
+
const observer = new ResizeObserver(() => {
|
|
1199
|
+
resizedKeyWidth.value = (0, _tremolo_ui_dom.fitWhiteKeyWidth)(element.clientWidth, {
|
|
1200
|
+
noteRange: {
|
|
1201
|
+
first,
|
|
1202
|
+
last
|
|
1203
|
+
},
|
|
1204
|
+
keyGap,
|
|
1205
|
+
blackKeyWidthRatio
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
observer.observe(parent);
|
|
1209
|
+
onCleanup(() => observer.disconnect());
|
|
1210
|
+
}, {
|
|
1211
|
+
immediate: true,
|
|
1212
|
+
flush: "post"
|
|
1213
|
+
});
|
|
1214
|
+
expose({
|
|
1215
|
+
/** Start a note, as a key would. */
|
|
1216
|
+
playNote: (note, velocity) => instance?.noteOn(note, {
|
|
1217
|
+
source: "api",
|
|
1218
|
+
velocity
|
|
1219
|
+
}),
|
|
1220
|
+
/** Release a note started with `playNote`. */
|
|
1221
|
+
stopNote: (note) => instance?.noteOff(note, { source: "api" })
|
|
1222
|
+
});
|
|
1223
|
+
return () => (0, vue.h)("div", {
|
|
1224
|
+
ref: root,
|
|
1225
|
+
role: "group",
|
|
1226
|
+
tabindex: 0,
|
|
1227
|
+
"data-resizable": props.resizable ? "" : void 0,
|
|
1228
|
+
style: {
|
|
1229
|
+
position: "relative",
|
|
1230
|
+
width: props.resizable ? "100%" : `${(0, _tremolo_ui_dom.pianoWidth)(layout.value)}px`
|
|
1231
|
+
}
|
|
1232
|
+
}, notes.value.map((note, index) => {
|
|
1233
|
+
const state = {
|
|
1234
|
+
index,
|
|
1235
|
+
keyType: (0, _tremolo_ui_functions.isWhiteKey)(note) ? "white" : "black",
|
|
1236
|
+
active: activeNotes.value.includes(note),
|
|
1237
|
+
disabled: note > props.midiMax
|
|
1238
|
+
};
|
|
1239
|
+
const { style: keyStyle, ...attributes } = props.keyProps?.(note, state) ?? {};
|
|
1240
|
+
const white = state.keyType === "white";
|
|
1241
|
+
const label = slots.label?.({
|
|
1242
|
+
note,
|
|
1243
|
+
state
|
|
1244
|
+
});
|
|
1245
|
+
return (0, vue.h)("div", {
|
|
1246
|
+
key: note,
|
|
1247
|
+
"data-note": note,
|
|
1248
|
+
"data-note-key": (0, _tremolo_ui_functions.noteKey)(note),
|
|
1249
|
+
"data-active": state.active ? "" : void 0,
|
|
1250
|
+
"data-disabled": state.disabled ? "" : void 0,
|
|
1251
|
+
...attributes,
|
|
1252
|
+
style: [
|
|
1253
|
+
{
|
|
1254
|
+
position: "absolute",
|
|
1255
|
+
zIndex: white ? 1 : 2
|
|
1256
|
+
},
|
|
1257
|
+
keyStyle,
|
|
1258
|
+
{
|
|
1259
|
+
left: `${(0, _tremolo_ui_dom.notePosition)(note, layout.value)}px`,
|
|
1260
|
+
width: `${white ? layout.value.whiteKeyWidth : (0, _tremolo_ui_dom.blackKeyWidth)(layout.value)}px`,
|
|
1261
|
+
height: white ? "100%" : `${props.blackKeyHeightRatio * 100}%`
|
|
1262
|
+
}
|
|
1263
|
+
]
|
|
1264
|
+
}, label ? (0, vue.h)("div", { class: props.classes?.keyLabelWrapper }, [(0, vue.h)("div", { class: props.classes?.keyLabel }, label)]) : void 0);
|
|
1265
|
+
}));
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region src/components/PointsEditor/context.ts
|
|
1270
|
+
const PointsEditorKey = Symbol("PointsEditor");
|
|
1271
|
+
/** The context of the enclosing `PointsEditor`, for a part of your own. */
|
|
1272
|
+
function usePointsEditorContext() {
|
|
1273
|
+
return injectContext(PointsEditorKey, "PointsEditor");
|
|
1274
|
+
}
|
|
1275
|
+
//#endregion
|
|
1276
|
+
//#region src/components/PointsEditor/PointsEditor.ts
|
|
1277
|
+
const EMPTY = [];
|
|
1278
|
+
/**
|
|
1279
|
+
* Multiple point controller. Bind the selection with `v-model:selection` to
|
|
1280
|
+
* hold it yourself; the editor keeps its own otherwise.
|
|
1281
|
+
*/
|
|
1282
|
+
const PointsEditor = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1283
|
+
name: "PointsEditor",
|
|
1284
|
+
props: {
|
|
1285
|
+
/** Make the points unchangeable and remove them from the tab order. */
|
|
1286
|
+
disabled: Boolean,
|
|
1287
|
+
/** Make the points unmovable. */
|
|
1288
|
+
readonly: Boolean,
|
|
1289
|
+
/** The cursor to show while dragging a point. @default { cursor: 'grabbing' } */
|
|
1290
|
+
externalStyles: Object,
|
|
1291
|
+
/** How much one wheel notch moves the focused point. */
|
|
1292
|
+
wheel: {
|
|
1293
|
+
type: [Array, Object],
|
|
1294
|
+
default: () => _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_WHEEL
|
|
1295
|
+
},
|
|
1296
|
+
/** How much one arrow key press moves a point. */
|
|
1297
|
+
keyboard: {
|
|
1298
|
+
type: [Array, Object],
|
|
1299
|
+
default: () => _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_KEYBOARD
|
|
1300
|
+
},
|
|
1301
|
+
/** How much a drag moves a point, per modifier key. */
|
|
1302
|
+
dragSensitivity: {
|
|
1303
|
+
type: [Number, Object],
|
|
1304
|
+
default: () => _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY
|
|
1305
|
+
},
|
|
1306
|
+
/**
|
|
1307
|
+
* Let points be selected, and a selection be moved as one. A selection
|
|
1308
|
+
* updates several points in the same tick.
|
|
1309
|
+
*/
|
|
1310
|
+
selectable: Boolean,
|
|
1311
|
+
/** Ids of the selected points. Bind it with `v-model:selection`. */
|
|
1312
|
+
selection: Array
|
|
1313
|
+
},
|
|
1314
|
+
emits: { "update:selection": (selection) => Array.isArray(selection) },
|
|
1315
|
+
setup(props, { slots, emit }) {
|
|
1316
|
+
const ownSelection = (0, vue.shallowRef)(props.selection ?? []);
|
|
1317
|
+
const selectionBox = (0, vue.shallowRef)(null);
|
|
1318
|
+
const container = (0, vue.shallowRef)(null);
|
|
1319
|
+
const current = (0, vue.computed)(() => props.selectable ? props.selection ?? ownSelection.value : EMPTY);
|
|
1320
|
+
const editor = (0, _tremolo_ui_dom.createPointsEditor)({
|
|
1321
|
+
onSelectionBoxChange: (rect) => {
|
|
1322
|
+
selectionBox.value = rect;
|
|
1323
|
+
},
|
|
1324
|
+
onSelectionChange: (next) => {
|
|
1325
|
+
ownSelection.value = next;
|
|
1326
|
+
emit("update:selection", next);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
(0, vue.watchEffect)(() => editor.update({
|
|
1330
|
+
selectable: props.selectable,
|
|
1331
|
+
selection: current.value
|
|
1332
|
+
}), { flush: "sync" });
|
|
1333
|
+
(0, vue.onScopeDispose)(() => editor.destroy());
|
|
1334
|
+
(0, vue.provide)(PointsEditorKey, {
|
|
1335
|
+
get disabled() {
|
|
1336
|
+
return props.disabled;
|
|
1337
|
+
},
|
|
1338
|
+
get readonly() {
|
|
1339
|
+
return props.readonly;
|
|
1340
|
+
},
|
|
1341
|
+
get wheel() {
|
|
1342
|
+
return props.wheel;
|
|
1343
|
+
},
|
|
1344
|
+
get keyboard() {
|
|
1345
|
+
return props.keyboard;
|
|
1346
|
+
},
|
|
1347
|
+
get dragSensitivity() {
|
|
1348
|
+
return props.dragSensitivity;
|
|
1349
|
+
},
|
|
1350
|
+
get cursor() {
|
|
1351
|
+
return props.externalStyles?.cursor ?? "grabbing";
|
|
1352
|
+
},
|
|
1353
|
+
get selectable() {
|
|
1354
|
+
return props.selectable;
|
|
1355
|
+
},
|
|
1356
|
+
get selection() {
|
|
1357
|
+
return current.value;
|
|
1358
|
+
},
|
|
1359
|
+
get selectionBox() {
|
|
1360
|
+
return selectionBox.value;
|
|
1361
|
+
},
|
|
1362
|
+
get container() {
|
|
1363
|
+
return container.value;
|
|
1364
|
+
},
|
|
1365
|
+
editor,
|
|
1366
|
+
setContainer: (element) => {
|
|
1367
|
+
container.value = element;
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
return () => (0, vue.h)("div", {
|
|
1371
|
+
"data-disabled": props.disabled ? "" : void 0,
|
|
1372
|
+
"data-readonly": props.readonly ? "" : void 0,
|
|
1373
|
+
style: { position: "relative" }
|
|
1374
|
+
}, slots.default?.());
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/components/PointsEditor/PointsEditorBackground.ts
|
|
1379
|
+
/**
|
|
1380
|
+
* Behind the points, filling the editor. The order of the layers holds
|
|
1381
|
+
* whichever way they are written, so the z-index is part of the mechanics.
|
|
1382
|
+
*/
|
|
1383
|
+
const PointsEditorBackground = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1384
|
+
name: "PointsEditorBackground",
|
|
1385
|
+
setup(_, { slots }) {
|
|
1386
|
+
return () => (0, vue.h)("div", { style: {
|
|
1387
|
+
position: "absolute",
|
|
1388
|
+
inset: 0,
|
|
1389
|
+
zIndex: 0
|
|
1390
|
+
} }, slots.default?.());
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
//#endregion
|
|
1394
|
+
//#region src/components/PointsEditor/PointsEditorContainer.ts
|
|
1395
|
+
/** The layer the points are placed in, over the background. */
|
|
1396
|
+
const PointsEditorContainer = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1397
|
+
name: "PointsEditorContainer",
|
|
1398
|
+
setup(_, { slots }) {
|
|
1399
|
+
const points = usePointsEditorContext();
|
|
1400
|
+
providePlacement("PointsEditorContainer");
|
|
1401
|
+
const el = (0, vue.ref)(null);
|
|
1402
|
+
(0, vue.watchEffect)(() => points.setContainer(el.value));
|
|
1403
|
+
const selecting = (0, vue.computed)(() => points.selectable && !points.disabled);
|
|
1404
|
+
(0, vue.watch)([el, selecting], ([element, on], _, onCleanup) => {
|
|
1405
|
+
if (!element || !on) return;
|
|
1406
|
+
const drag = (0, _tremolo_ui_dom.createDragValue)(element, {
|
|
1407
|
+
axis: _tremolo_ui_dom.POINT_AXIS,
|
|
1408
|
+
mapping: (0, _tremolo_ui_dom.elementMapping)(() => element),
|
|
1409
|
+
shouldStart: (event) => !points.editor.isPointElement(event.target),
|
|
1410
|
+
onDragStart: ([x, y], state) => points.editor.beginSelectionBox({
|
|
1411
|
+
x,
|
|
1412
|
+
y
|
|
1413
|
+
}, state.event),
|
|
1414
|
+
onChange: ([x, y]) => points.editor.moveSelectionBox({
|
|
1415
|
+
x,
|
|
1416
|
+
y
|
|
1417
|
+
}),
|
|
1418
|
+
onDragEnd: () => points.editor.endSelectionBox()
|
|
1419
|
+
});
|
|
1420
|
+
onCleanup(() => drag.destroy());
|
|
1421
|
+
}, {
|
|
1422
|
+
immediate: true,
|
|
1423
|
+
flush: "post"
|
|
1424
|
+
});
|
|
1425
|
+
useWheel(el, (event) => {
|
|
1426
|
+
const move = (0, _tremolo_ui_dom.wheelMove)(event);
|
|
1427
|
+
if (!move) return;
|
|
1428
|
+
const axis = move.axis === 0 ? "x" : "y";
|
|
1429
|
+
if (points.editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
|
|
1430
|
+
});
|
|
1431
|
+
return () => (0, vue.h)("div", {
|
|
1432
|
+
ref: el,
|
|
1433
|
+
style: {
|
|
1434
|
+
position: "absolute",
|
|
1435
|
+
inset: 0,
|
|
1436
|
+
zIndex: 10
|
|
1437
|
+
}
|
|
1438
|
+
}, slots.default?.());
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region src/components/_util/visually-hidden-range-input.ts
|
|
1443
|
+
/**
|
|
1444
|
+
* The control underneath a headless part: out of sight, but in the
|
|
1445
|
+
* accessibility tree and the tab order, carrying the ARIA and the keyboard.
|
|
1446
|
+
*
|
|
1447
|
+
* A plain element rather than a component, so that a `ref` on it reaches the
|
|
1448
|
+
* `<input>` itself.
|
|
1449
|
+
*/
|
|
1450
|
+
function visuallyHiddenRangeInput(props) {
|
|
1451
|
+
return (0, vue.h)("input", {
|
|
1452
|
+
...props,
|
|
1453
|
+
type: "range",
|
|
1454
|
+
style: _tremolo_ui_dom.visuallyHiddenStyle
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
//#endregion
|
|
1458
|
+
//#region src/components/PointsEditor/PointsEditorPoint.ts
|
|
1459
|
+
const perAxis = (setting, axis) => typeof setting === "string" ? setting : setting?.[axis];
|
|
1460
|
+
/** One point, bound with `v-model` as `{ x, y }` from 0 to 1. */
|
|
1461
|
+
const PointsEditorPoint = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1462
|
+
name: "PointsEditorPoint",
|
|
1463
|
+
inheritAttrs: false,
|
|
1464
|
+
props: {
|
|
1465
|
+
/** Where the point is, with `y` growing downwards. Bind it with `v-model`. */
|
|
1466
|
+
modelValue: {
|
|
1467
|
+
type: Object,
|
|
1468
|
+
required: true
|
|
1469
|
+
},
|
|
1470
|
+
/** How the selection refers to this point. One is generated when left out. */
|
|
1471
|
+
id: String,
|
|
1472
|
+
/** The lowest position the point can take, per axis. */
|
|
1473
|
+
min: Object,
|
|
1474
|
+
/** The highest position the point can take, per axis. */
|
|
1475
|
+
max: Object,
|
|
1476
|
+
/** Sets `--color`, for the theme to colour the point with. */
|
|
1477
|
+
color: String,
|
|
1478
|
+
/** Overrides the `disabled` of `PointsEditor`. */
|
|
1479
|
+
disabled: {
|
|
1480
|
+
type: Boolean,
|
|
1481
|
+
default: void 0
|
|
1482
|
+
},
|
|
1483
|
+
/** Overrides the `readonly` of `PointsEditor`. */
|
|
1484
|
+
readonly: {
|
|
1485
|
+
type: Boolean,
|
|
1486
|
+
default: void 0
|
|
1487
|
+
},
|
|
1488
|
+
/** Overrides the `wheel` of `PointsEditor`; `null` turns it off. */
|
|
1489
|
+
wheel: {
|
|
1490
|
+
type: [Array, Object],
|
|
1491
|
+
default: void 0
|
|
1492
|
+
},
|
|
1493
|
+
/** Overrides the `keyboard` of `PointsEditor`; `null` turns it off. */
|
|
1494
|
+
keyboard: {
|
|
1495
|
+
type: [Array, Object],
|
|
1496
|
+
default: void 0
|
|
1497
|
+
},
|
|
1498
|
+
/** The accessible name of each axis, or one for both. */
|
|
1499
|
+
ariaLabel: [String, Object],
|
|
1500
|
+
/** What the value of each axis means, or one for both. */
|
|
1501
|
+
ariaValuetext: [String, Object]
|
|
1502
|
+
},
|
|
1503
|
+
emits: {
|
|
1504
|
+
"update:modelValue": (value) => typeof value === "object",
|
|
1505
|
+
dragStart: (value) => typeof value === "object",
|
|
1506
|
+
dragEnd: (value) => typeof value === "object"
|
|
1507
|
+
},
|
|
1508
|
+
setup(props, { slots, attrs, emit }) {
|
|
1509
|
+
const points = usePointsEditorContext();
|
|
1510
|
+
checkPlacement("PointsEditorPoint", "PointsEditorContainer");
|
|
1511
|
+
const generatedId = (0, vue.useId)();
|
|
1512
|
+
const id = (0, vue.computed)(() => props.id ?? generatedId);
|
|
1513
|
+
const disabled = (0, vue.computed)(() => props.disabled ?? points.disabled);
|
|
1514
|
+
const readonly = (0, vue.computed)(() => props.readonly ?? points.readonly);
|
|
1515
|
+
const inactive = (0, vue.computed)(() => disabled.value || readonly.value);
|
|
1516
|
+
const wheel = (0, vue.computed)(() => props.wheel === void 0 ? points.wheel : props.wheel);
|
|
1517
|
+
const keyboard = (0, vue.computed)(() => props.keyboard === void 0 ? points.keyboard : props.keyboard);
|
|
1518
|
+
const current = (0, vue.computed)(() => (0, _tremolo_ui_dom.clampPoint)(props.modelValue, props.min, props.max));
|
|
1519
|
+
const el = (0, vue.ref)(null);
|
|
1520
|
+
const x = (0, vue.ref)(null);
|
|
1521
|
+
const dragging = (0, vue.ref)(false);
|
|
1522
|
+
const read = () => ({
|
|
1523
|
+
value: props.modelValue,
|
|
1524
|
+
min: props.min,
|
|
1525
|
+
max: props.max,
|
|
1526
|
+
readonly: inactive.value,
|
|
1527
|
+
onChange: (next) => emit("update:modelValue", next),
|
|
1528
|
+
element: el.value,
|
|
1529
|
+
wheel: wheel.value
|
|
1530
|
+
});
|
|
1531
|
+
(0, vue.watch)(id, (key, _, onCleanup) => onCleanup(points.editor.registerPoint(key, read)), { immediate: true });
|
|
1532
|
+
/** Where the pointer was when the drag started. */
|
|
1533
|
+
let origin = null;
|
|
1534
|
+
(0, vue.watch)(el, (element, _, onCleanup) => {
|
|
1535
|
+
if (!element) return;
|
|
1536
|
+
const drag = (0, _tremolo_ui_dom.createDragValue)(element, {
|
|
1537
|
+
axis: _tremolo_ui_dom.POINT_AXIS,
|
|
1538
|
+
mapping: (0, _tremolo_ui_dom.elementMapping)(() => points.container, { sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(points.dragSensitivity, state.event).value }),
|
|
1539
|
+
cursor: inactive.value ? void 0 : points.cursor,
|
|
1540
|
+
shouldStart: () => !disabled.value,
|
|
1541
|
+
onChange: ([px, py]) => {
|
|
1542
|
+
if (origin) points.editor.movePointDrag({
|
|
1543
|
+
x: px - origin.x,
|
|
1544
|
+
y: py - origin.y
|
|
1545
|
+
});
|
|
1546
|
+
},
|
|
1547
|
+
onDragStart: ([px, py], state) => {
|
|
1548
|
+
points.editor.beginPointDrag(id.value, state.event);
|
|
1549
|
+
origin = {
|
|
1550
|
+
x: px,
|
|
1551
|
+
y: py
|
|
1552
|
+
};
|
|
1553
|
+
dragging.value = true;
|
|
1554
|
+
x.value?.focus();
|
|
1555
|
+
if (!inactive.value) emit("dragStart", current.value);
|
|
1556
|
+
},
|
|
1557
|
+
onDragEnd: () => {
|
|
1558
|
+
origin = null;
|
|
1559
|
+
dragging.value = false;
|
|
1560
|
+
if (!inactive.value) emit("dragEnd", current.value);
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
const stop = (0, vue.watch)(() => inactive.value ? void 0 : points.cursor, (cursor) => drag.update({ cursor }));
|
|
1564
|
+
onCleanup(() => {
|
|
1565
|
+
stop();
|
|
1566
|
+
drag.destroy();
|
|
1567
|
+
});
|
|
1568
|
+
}, {
|
|
1569
|
+
immediate: true,
|
|
1570
|
+
flush: "post"
|
|
1571
|
+
});
|
|
1572
|
+
function onInput(axis, target) {
|
|
1573
|
+
if (readonly.value) {
|
|
1574
|
+
target.value = String(current.value[axis]);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const delta = target.valueAsNumber - props.modelValue[axis];
|
|
1578
|
+
points.editor.nudgeSelection(id.value, {
|
|
1579
|
+
x: axis === "x" ? delta : 0,
|
|
1580
|
+
y: axis === "y" ? delta : 0
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
const input = (axis) => visuallyHiddenRangeInput({
|
|
1584
|
+
ref: axis === "x" ? x : void 0,
|
|
1585
|
+
"data-axis": axis,
|
|
1586
|
+
value: current.value[axis],
|
|
1587
|
+
min: props.min?.[axis] ?? 0,
|
|
1588
|
+
max: props.max?.[axis] ?? 1,
|
|
1589
|
+
step: "any",
|
|
1590
|
+
disabled: disabled.value,
|
|
1591
|
+
"aria-readonly": readonly.value,
|
|
1592
|
+
"aria-orientation": axis === "x" ? "horizontal" : "vertical",
|
|
1593
|
+
"aria-label": perAxis(props.ariaLabel, axis) ?? axis,
|
|
1594
|
+
"aria-valuetext": perAxis(props.ariaValuetext, axis),
|
|
1595
|
+
onInput: (event) => onInput(axis, event.target)
|
|
1596
|
+
});
|
|
1597
|
+
return () => {
|
|
1598
|
+
const { style, onFocus, onKeydown, ...rest } = attrs;
|
|
1599
|
+
return (0, vue.h)("div", {
|
|
1600
|
+
ref: el,
|
|
1601
|
+
tabindex: -1,
|
|
1602
|
+
"data-disabled": disabled.value ? "" : void 0,
|
|
1603
|
+
"data-readonly": readonly.value ? "" : void 0,
|
|
1604
|
+
"data-dragging": dragging.value ? "" : void 0,
|
|
1605
|
+
"data-selected": points.selection.includes(id.value) ? "" : void 0,
|
|
1606
|
+
...rest,
|
|
1607
|
+
style: [
|
|
1608
|
+
{
|
|
1609
|
+
position: "absolute",
|
|
1610
|
+
translate: "var(--translate, -50% -50%)",
|
|
1611
|
+
"--color": props.color
|
|
1612
|
+
},
|
|
1613
|
+
style,
|
|
1614
|
+
{
|
|
1615
|
+
left: `${props.modelValue.x * 100}%`,
|
|
1616
|
+
top: `${props.modelValue.y * 100}%`
|
|
1617
|
+
}
|
|
1618
|
+
],
|
|
1619
|
+
...(0, vue.mergeProps)({
|
|
1620
|
+
onFocus: (event) => {
|
|
1621
|
+
if (!disabled.value && event.target === event.currentTarget) x.value?.focus();
|
|
1622
|
+
},
|
|
1623
|
+
onKeydown: (event) => {
|
|
1624
|
+
const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
|
|
1625
|
+
if (!move) return;
|
|
1626
|
+
event.preventDefault();
|
|
1627
|
+
if (inactive.value || !keyboard.value) return;
|
|
1628
|
+
points.editor.nudgePoint(id.value, move.axis === 0 ? "x" : "y", move.direction, keyboard.value, event);
|
|
1629
|
+
}
|
|
1630
|
+
}, {
|
|
1631
|
+
onFocus,
|
|
1632
|
+
onKeydown
|
|
1633
|
+
})
|
|
1634
|
+
}, [
|
|
1635
|
+
input("x"),
|
|
1636
|
+
input("y"),
|
|
1637
|
+
slots.default?.()
|
|
1638
|
+
]);
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/components/PointsEditor/PointsEditorSelectionBox.ts
|
|
1644
|
+
/**
|
|
1645
|
+
* The box a drag on empty space draws. Nothing is rendered while no drag is
|
|
1646
|
+
* running, and it takes none of the pointer.
|
|
1647
|
+
*/
|
|
1648
|
+
const PointsEditorSelectionBox = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1649
|
+
name: "PointsEditorSelectionBox",
|
|
1650
|
+
setup(_, { slots }) {
|
|
1651
|
+
const points = usePointsEditorContext();
|
|
1652
|
+
checkPlacement("PointsEditorSelectionBox", "PointsEditorContainer");
|
|
1653
|
+
return () => {
|
|
1654
|
+
const box = points.selectionBox;
|
|
1655
|
+
if (!box) return null;
|
|
1656
|
+
return (0, vue.h)("div", { style: {
|
|
1657
|
+
position: "absolute",
|
|
1658
|
+
zIndex: 20,
|
|
1659
|
+
pointerEvents: "none",
|
|
1660
|
+
left: `${box.x * 100}%`,
|
|
1661
|
+
top: `${box.y * 100}%`,
|
|
1662
|
+
width: `${box.width * 100}%`,
|
|
1663
|
+
height: `${box.height * 100}%`
|
|
1664
|
+
} }, slots.default?.());
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
//#endregion
|
|
1669
|
+
//#region src/components/Slider/context.ts
|
|
1670
|
+
const SliderKey = Symbol("Slider");
|
|
1671
|
+
/** The context of the enclosing `Slider`, for a part of your own. */
|
|
1672
|
+
function useSliderContext() {
|
|
1673
|
+
return injectContext(SliderKey, "Slider");
|
|
1674
|
+
}
|
|
1675
|
+
//#endregion
|
|
1676
|
+
//#region src/components/Slider/Slider.ts
|
|
1677
|
+
/** Customizable slider. Bind the value with `v-model`. */
|
|
1678
|
+
const Slider = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1679
|
+
name: "Slider",
|
|
1680
|
+
props: {
|
|
1681
|
+
/** The current value. Bind it with `v-model`. */
|
|
1682
|
+
modelValue: {
|
|
1683
|
+
type: Number,
|
|
1684
|
+
required: true
|
|
1685
|
+
},
|
|
1686
|
+
/** The value at the start of the travel. */
|
|
1687
|
+
min: {
|
|
1688
|
+
type: Number,
|
|
1689
|
+
required: true
|
|
1690
|
+
},
|
|
1691
|
+
/** The value at the end of the travel. */
|
|
1692
|
+
max: {
|
|
1693
|
+
type: Number,
|
|
1694
|
+
required: true
|
|
1695
|
+
},
|
|
1696
|
+
/** Granularity of the value. @default 1 */
|
|
1697
|
+
step: {
|
|
1698
|
+
type: Number,
|
|
1699
|
+
default: 1
|
|
1700
|
+
},
|
|
1701
|
+
/** How the value is distributed across the travel. @default linearScale */
|
|
1702
|
+
scale: {
|
|
1703
|
+
type: Object,
|
|
1704
|
+
default: () => _tremolo_ui_functions.linearScale
|
|
1705
|
+
},
|
|
1706
|
+
/** Run the slider vertically, with the value growing upwards. */
|
|
1707
|
+
vertical: Boolean,
|
|
1708
|
+
/** Grow the value the other way. */
|
|
1709
|
+
reverse: Boolean,
|
|
1710
|
+
/** The cursor to show while dragging. @default { cursor: 'pointer' } */
|
|
1711
|
+
externalStyles: Object,
|
|
1712
|
+
wheel: wheelProp,
|
|
1713
|
+
keyboard: keyboardProp,
|
|
1714
|
+
dragSensitivity: dragSensitivityProp,
|
|
1715
|
+
/** Make the slider unchangeable and remove it from the tab order. */
|
|
1716
|
+
disabled: Boolean,
|
|
1717
|
+
/** Make the value unchangeable. */
|
|
1718
|
+
readonly: Boolean
|
|
1719
|
+
},
|
|
1720
|
+
emits: {
|
|
1721
|
+
"update:modelValue": (value) => typeof value === "number",
|
|
1722
|
+
/** A drag started, with the value where the track was pressed. */
|
|
1723
|
+
dragStart: (value) => typeof value === "number",
|
|
1724
|
+
/** The drag ended, with the value it ended on. */
|
|
1725
|
+
dragEnd: (value) => typeof value === "number"
|
|
1726
|
+
},
|
|
1727
|
+
setup(props, { slots, emit, expose }) {
|
|
1728
|
+
const root = (0, vue.ref)(null);
|
|
1729
|
+
let track = null;
|
|
1730
|
+
let thumb = null;
|
|
1731
|
+
const inactive = (0, vue.computed)(() => props.disabled || props.readonly);
|
|
1732
|
+
const displayReversed = (0, vue.computed)(() => props.vertical !== props.reverse);
|
|
1733
|
+
const percent = (0, vue.computed)(() => (0, _tremolo_ui_dom.valuePercent)(props.modelValue, {
|
|
1734
|
+
min: props.min,
|
|
1735
|
+
max: props.max,
|
|
1736
|
+
scale: props.scale
|
|
1737
|
+
}, displayReversed.value));
|
|
1738
|
+
const axis = (0, vue.computed)(() => ({
|
|
1739
|
+
min: props.min,
|
|
1740
|
+
max: props.max,
|
|
1741
|
+
step: props.step,
|
|
1742
|
+
scale: props.scale,
|
|
1743
|
+
reverse: displayReversed.value
|
|
1744
|
+
}));
|
|
1745
|
+
useCheckSteps(() => ({
|
|
1746
|
+
component: "Slider",
|
|
1747
|
+
range: axis.value,
|
|
1748
|
+
keyboard: props.keyboard,
|
|
1749
|
+
wheel: props.wheel
|
|
1750
|
+
}));
|
|
1751
|
+
const change = (next) => emit("update:modelValue", next);
|
|
1752
|
+
(0, vue.provide)(SliderKey, {
|
|
1753
|
+
get value() {
|
|
1754
|
+
return props.modelValue;
|
|
1755
|
+
},
|
|
1756
|
+
get min() {
|
|
1757
|
+
return props.min;
|
|
1758
|
+
},
|
|
1759
|
+
get max() {
|
|
1760
|
+
return props.max;
|
|
1761
|
+
},
|
|
1762
|
+
get step() {
|
|
1763
|
+
return props.step;
|
|
1764
|
+
},
|
|
1765
|
+
get scale() {
|
|
1766
|
+
return props.scale;
|
|
1767
|
+
},
|
|
1768
|
+
get vertical() {
|
|
1769
|
+
return props.vertical;
|
|
1770
|
+
},
|
|
1771
|
+
get reverse() {
|
|
1772
|
+
return props.reverse;
|
|
1773
|
+
},
|
|
1774
|
+
get disabled() {
|
|
1775
|
+
return props.disabled;
|
|
1776
|
+
},
|
|
1777
|
+
get readonly() {
|
|
1778
|
+
return props.readonly;
|
|
1779
|
+
},
|
|
1780
|
+
get percent() {
|
|
1781
|
+
return percent.value;
|
|
1782
|
+
},
|
|
1783
|
+
change,
|
|
1784
|
+
setTrack: (element) => {
|
|
1785
|
+
track = element;
|
|
1786
|
+
},
|
|
1787
|
+
setThumb: (input) => {
|
|
1788
|
+
thumb = input;
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
const valueOf = (v) => v[props.vertical ? 1 : 0];
|
|
1792
|
+
useDragValue(root, () => ({
|
|
1793
|
+
axis: axis.value,
|
|
1794
|
+
mapping: (0, _tremolo_ui_dom.elementMapping)(() => track, { sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(props.dragSensitivity, state.event).value }),
|
|
1795
|
+
cursor: inactive.value ? void 0 : props.externalStyles?.cursor ?? "pointer",
|
|
1796
|
+
shouldStart: () => !inactive.value,
|
|
1797
|
+
updateOnPointerDown: true,
|
|
1798
|
+
onChange: (v) => {
|
|
1799
|
+
if (!inactive.value) change(valueOf(v));
|
|
1800
|
+
},
|
|
1801
|
+
onDragStart: (v) => {
|
|
1802
|
+
if (inactive.value) return;
|
|
1803
|
+
thumb?.focus();
|
|
1804
|
+
emit("dragStart", valueOf(v));
|
|
1805
|
+
},
|
|
1806
|
+
onDragEnd: (v) => {
|
|
1807
|
+
if (!inactive.value) emit("dragEnd", valueOf(v));
|
|
1808
|
+
}
|
|
1809
|
+
}));
|
|
1810
|
+
useWheel(root, (event) => {
|
|
1811
|
+
if (!props.wheel || inactive.value) return;
|
|
1812
|
+
const direction = (0, _tremolo_ui_dom.wheelDirection)(event, { horizontal: !props.vertical });
|
|
1813
|
+
if (direction === null) return;
|
|
1814
|
+
event.preventDefault();
|
|
1815
|
+
change((0, _tremolo_ui_dom.applyDelta)(props.modelValue, props.reverse ? -direction : direction, props.wheel, axis.value, event));
|
|
1816
|
+
}, { requireFocus: true });
|
|
1817
|
+
expose({
|
|
1818
|
+
/** Move the focus to the thumb. */
|
|
1819
|
+
focus: () => {
|
|
1820
|
+
if (!props.disabled) thumb?.focus();
|
|
1821
|
+
},
|
|
1822
|
+
/** Take the focus away from the thumb. */
|
|
1823
|
+
blur: () => thumb?.blur()
|
|
1824
|
+
});
|
|
1825
|
+
return () => (0, vue.h)("div", {
|
|
1826
|
+
ref: root,
|
|
1827
|
+
role: "group",
|
|
1828
|
+
tabindex: -1,
|
|
1829
|
+
"data-orientation": props.vertical ? "vertical" : "horizontal",
|
|
1830
|
+
"data-disabled": props.disabled ? "" : void 0,
|
|
1831
|
+
"data-readonly": props.readonly ? "" : void 0,
|
|
1832
|
+
onKeydown: (event) => {
|
|
1833
|
+
const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
|
|
1834
|
+
if (direction === null) return;
|
|
1835
|
+
event.preventDefault();
|
|
1836
|
+
if (!props.keyboard || inactive.value) return;
|
|
1837
|
+
change((0, _tremolo_ui_dom.applyDelta)(props.modelValue, props.reverse ? -direction : direction, props.keyboard, axis.value, event));
|
|
1838
|
+
},
|
|
1839
|
+
onFocus: () => {
|
|
1840
|
+
if (!props.disabled) thumb?.focus();
|
|
1841
|
+
},
|
|
1842
|
+
onBlur: () => thumb?.blur()
|
|
1843
|
+
}, slots.default?.());
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
//#endregion
|
|
1847
|
+
//#region src/components/Slider/SliderMarksOption.ts
|
|
1848
|
+
/** One mark along the slider, and its label. */
|
|
1849
|
+
const SliderMarksOption = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1850
|
+
name: "SliderMarksOption",
|
|
1851
|
+
props: {
|
|
1852
|
+
/** Where the mark sits, on the same scale as the thumb. */
|
|
1853
|
+
value: {
|
|
1854
|
+
type: Number,
|
|
1855
|
+
required: true
|
|
1856
|
+
},
|
|
1857
|
+
/** Draw the mark itself. @default true */
|
|
1858
|
+
mark: {
|
|
1859
|
+
type: Boolean,
|
|
1860
|
+
default: true
|
|
1861
|
+
},
|
|
1862
|
+
/**
|
|
1863
|
+
* Text shown next to the mark. Leave it out to show the value; `null`
|
|
1864
|
+
* leaves the label out.
|
|
1865
|
+
*/
|
|
1866
|
+
label: {
|
|
1867
|
+
type: [
|
|
1868
|
+
Number,
|
|
1869
|
+
String,
|
|
1870
|
+
null
|
|
1871
|
+
],
|
|
1872
|
+
default: void 0
|
|
1873
|
+
},
|
|
1874
|
+
/** Thickness of the mark. Sets `--thickness`. */
|
|
1875
|
+
thickness: [Number, String],
|
|
1876
|
+
/** Length of the mark. Sets `--length`. */
|
|
1877
|
+
length: [Number, String],
|
|
1878
|
+
/** Space between the mark and the label. Sets `--gap`. */
|
|
1879
|
+
gap: [Number, String],
|
|
1880
|
+
/** Classes for the mark and the label inside the option. */
|
|
1881
|
+
classes: Object,
|
|
1882
|
+
/** Styles for the mark and the label inside the option. */
|
|
1883
|
+
styles: Object
|
|
1884
|
+
},
|
|
1885
|
+
setup(props) {
|
|
1886
|
+
const slider = useSliderContext();
|
|
1887
|
+
checkPlacement("SliderMarksOption", "SliderMarks");
|
|
1888
|
+
const percent = (0, vue.computed)(() => (0, _tremolo_ui_dom.valuePercent)(props.value, {
|
|
1889
|
+
min: slider.min,
|
|
1890
|
+
max: slider.max,
|
|
1891
|
+
scale: slider.scale
|
|
1892
|
+
}, slider.vertical !== slider.reverse));
|
|
1893
|
+
return () => {
|
|
1894
|
+
const orientation = slider.vertical ? "vertical" : "horizontal";
|
|
1895
|
+
return (0, vue.h)("div", {
|
|
1896
|
+
"data-orientation": orientation,
|
|
1897
|
+
style: {
|
|
1898
|
+
"--thickness": (0, _tremolo_ui_dom.cssLength)(props.thickness),
|
|
1899
|
+
"--length": (0, _tremolo_ui_dom.cssLength)(props.length),
|
|
1900
|
+
"--gap": (0, _tremolo_ui_dom.cssLength)(props.gap),
|
|
1901
|
+
position: "absolute",
|
|
1902
|
+
translate: slider.vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
|
|
1903
|
+
zIndex: 10,
|
|
1904
|
+
left: slider.vertical ? void 0 : `${percent.value}%`,
|
|
1905
|
+
top: slider.vertical ? `${percent.value}%` : void 0
|
|
1906
|
+
}
|
|
1907
|
+
}, [props.mark ? (0, vue.h)("div", {
|
|
1908
|
+
class: props.classes?.mark,
|
|
1909
|
+
style: props.styles?.mark,
|
|
1910
|
+
"data-orientation": orientation
|
|
1911
|
+
}) : null, props.label !== null ? (0, vue.h)("div", {
|
|
1912
|
+
class: props.classes?.label,
|
|
1913
|
+
style: props.styles?.label
|
|
1914
|
+
}, String(props.label ?? props.value)) : null]);
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/components/Slider/SliderMarks.ts
|
|
1920
|
+
/** The marks along the slider: written out as options, or built from `options`. */
|
|
1921
|
+
const SliderMarks = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1922
|
+
name: "SliderMarks",
|
|
1923
|
+
props: {
|
|
1924
|
+
/** Space between the marks and the track. Sets `--gap`. */
|
|
1925
|
+
gap: [Number, String],
|
|
1926
|
+
/**
|
|
1927
|
+
* Build the marks instead of writing `SliderMarksOption` out: a number
|
|
1928
|
+
* puts one every that many, `'step'` one every `step`. The default slot
|
|
1929
|
+
* is ignored while it is set.
|
|
1930
|
+
*/
|
|
1931
|
+
options: [
|
|
1932
|
+
String,
|
|
1933
|
+
Number,
|
|
1934
|
+
Object
|
|
1935
|
+
]
|
|
1936
|
+
},
|
|
1937
|
+
setup(props, { slots }) {
|
|
1938
|
+
const slider = useSliderContext();
|
|
1939
|
+
providePlacement("SliderMarks");
|
|
1940
|
+
const marks = (0, vue.computed)(() => {
|
|
1941
|
+
if (props.options === void 0) return [];
|
|
1942
|
+
const list = (0, _tremolo_ui_dom.sliderMarks)(props.options, slider.min, slider.max, slider.step);
|
|
1943
|
+
return slider.vertical !== slider.reverse ? list.reverse() : list;
|
|
1944
|
+
});
|
|
1945
|
+
return () => (0, vue.h)("div", {
|
|
1946
|
+
"data-orientation": slider.vertical ? "vertical" : "horizontal",
|
|
1947
|
+
style: {
|
|
1948
|
+
"--gap": (0, _tremolo_ui_dom.cssLength)(props.gap),
|
|
1949
|
+
position: "relative"
|
|
1950
|
+
}
|
|
1951
|
+
}, props.options !== void 0 ? marks.value.map(({ value, mark, label }) => (0, vue.h)(SliderMarksOption, {
|
|
1952
|
+
key: value,
|
|
1953
|
+
value,
|
|
1954
|
+
mark,
|
|
1955
|
+
label: label ? void 0 : null
|
|
1956
|
+
})) : slots.default?.());
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
//#endregion
|
|
1960
|
+
//#region src/components/Slider/SliderThumb.ts
|
|
1961
|
+
/**
|
|
1962
|
+
* The thumb, placed at the value. The ARIA (`aria-label` and friends) lands
|
|
1963
|
+
* on the range input inside it, which is the control and takes the focus.
|
|
1964
|
+
*/
|
|
1965
|
+
const SliderThumb = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
1966
|
+
name: "SliderThumb",
|
|
1967
|
+
inheritAttrs: false,
|
|
1968
|
+
props: {
|
|
1969
|
+
/** Sets `--color`, for the theme to colour the thumb with. */
|
|
1970
|
+
color: String },
|
|
1971
|
+
setup(props, { slots, attrs, expose }) {
|
|
1972
|
+
const slider = useSliderContext();
|
|
1973
|
+
checkPlacement("SliderThumb", "SliderTrack");
|
|
1974
|
+
const input = (0, vue.ref)(null);
|
|
1975
|
+
(0, vue.watchEffect)(() => slider.setThumb(input.value));
|
|
1976
|
+
(0, vue.onBeforeUnmount)(() => slider.setThumb(null));
|
|
1977
|
+
expose({
|
|
1978
|
+
focus: () => {
|
|
1979
|
+
if (!slider.disabled) input.value?.focus();
|
|
1980
|
+
},
|
|
1981
|
+
blur: () => input.value?.blur()
|
|
1982
|
+
});
|
|
1983
|
+
return () => {
|
|
1984
|
+
const { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, style, ...rest } = attrs;
|
|
1985
|
+
return (0, vue.h)("div", {
|
|
1986
|
+
"data-disabled": slider.disabled ? "" : void 0,
|
|
1987
|
+
"data-readonly": slider.readonly ? "" : void 0,
|
|
1988
|
+
...rest,
|
|
1989
|
+
style: [
|
|
1990
|
+
{
|
|
1991
|
+
position: "absolute",
|
|
1992
|
+
translate: "var(--translate, -50% -50%)",
|
|
1993
|
+
zIndex: 100,
|
|
1994
|
+
"--color": props.color
|
|
1995
|
+
},
|
|
1996
|
+
style,
|
|
1997
|
+
{
|
|
1998
|
+
top: slider.vertical ? `${slider.percent}%` : "50%",
|
|
1999
|
+
left: slider.vertical ? "50%" : `${slider.percent}%`
|
|
2000
|
+
}
|
|
2001
|
+
]
|
|
2002
|
+
}, [visuallyHiddenRangeInput({
|
|
2003
|
+
ref: input,
|
|
2004
|
+
value: slider.value,
|
|
2005
|
+
min: slider.min,
|
|
2006
|
+
max: slider.max,
|
|
2007
|
+
step: slider.step,
|
|
2008
|
+
disabled: slider.disabled,
|
|
2009
|
+
"aria-readonly": slider.readonly,
|
|
2010
|
+
"aria-orientation": slider.vertical ? "vertical" : "horizontal",
|
|
2011
|
+
"aria-label": ariaLabel,
|
|
2012
|
+
"aria-labelledby": ariaLabelledby,
|
|
2013
|
+
"aria-describedby": ariaDescribedby,
|
|
2014
|
+
"aria-valuetext": ariaValuetext,
|
|
2015
|
+
onInput: (event) => {
|
|
2016
|
+
const target = event.target;
|
|
2017
|
+
if (slider.readonly) {
|
|
2018
|
+
target.value = String(slider.value);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
slider.change(target.valueAsNumber);
|
|
2022
|
+
}
|
|
2023
|
+
}), slots.default?.()]);
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
});
|
|
2027
|
+
//#endregion
|
|
2028
|
+
//#region src/components/Slider/SliderTrack.ts
|
|
2029
|
+
/** The track the thumb runs along, and what the pointer is measured against. */
|
|
2030
|
+
const SliderTrack = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
2031
|
+
name: "SliderTrack",
|
|
2032
|
+
props: {
|
|
2033
|
+
/** How long the track is along the axis the slider runs. Sets `--length`. */
|
|
2034
|
+
length: [Number, String],
|
|
2035
|
+
/** How thick the track is across that axis. Sets `--thickness`. */
|
|
2036
|
+
thickness: [Number, String],
|
|
2037
|
+
/** Colour of the part from `min` to the value. Sets `--active`. */
|
|
2038
|
+
active: String,
|
|
2039
|
+
/** Colour of the rest of the track. Sets `--inactive`. */
|
|
2040
|
+
inactive: String
|
|
2041
|
+
},
|
|
2042
|
+
setup(props, { slots }) {
|
|
2043
|
+
const slider = useSliderContext();
|
|
2044
|
+
providePlacement("SliderTrack");
|
|
2045
|
+
const el = (0, vue.ref)(null);
|
|
2046
|
+
(0, vue.watchEffect)(() => slider.setTrack(el.value));
|
|
2047
|
+
(0, vue.onBeforeUnmount)(() => slider.setTrack(null));
|
|
2048
|
+
return () => (0, vue.h)("div", {
|
|
2049
|
+
ref: el,
|
|
2050
|
+
"data-disabled": slider.disabled ? "" : void 0,
|
|
2051
|
+
"data-orientation": slider.vertical ? "vertical" : "horizontal",
|
|
2052
|
+
"data-flipped": slider.vertical !== slider.reverse ? "" : void 0,
|
|
2053
|
+
style: {
|
|
2054
|
+
"--active": props.active,
|
|
2055
|
+
"--inactive": props.inactive,
|
|
2056
|
+
"--length": (0, _tremolo_ui_dom.cssLength)(props.length),
|
|
2057
|
+
"--thickness": (0, _tremolo_ui_dom.cssLength)(props.thickness),
|
|
2058
|
+
"--percent": `${slider.percent}%`,
|
|
2059
|
+
position: "relative"
|
|
2060
|
+
}
|
|
2061
|
+
}, slots.default?.());
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
//#endregion
|
|
2065
|
+
//#region src/components/XYPad/context.ts
|
|
2066
|
+
const XYPadKey = Symbol("XYPad");
|
|
2067
|
+
/** The context of the enclosing `XYPad`, for a part of your own. */
|
|
2068
|
+
function useXYPadContext() {
|
|
2069
|
+
return injectContext(XYPadKey, "XYPad");
|
|
2070
|
+
}
|
|
2071
|
+
//#endregion
|
|
2072
|
+
//#region src/components/XYPad/XYPad.ts
|
|
2073
|
+
/**
|
|
2074
|
+
* Two-dimensional slider. The per-axis settings are `[x, y]` tuples, and a
|
|
2075
|
+
* plain value applies to both axes. Bind the value with `v-model`.
|
|
2076
|
+
*/
|
|
2077
|
+
const XYPad = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
2078
|
+
name: "XYPad",
|
|
2079
|
+
props: {
|
|
2080
|
+
/** The current value as `[x, y]`. Bind it with `v-model`. */
|
|
2081
|
+
modelValue: {
|
|
2082
|
+
type: Array,
|
|
2083
|
+
required: true
|
|
2084
|
+
},
|
|
2085
|
+
/** The value at the start of the travel, per axis. */
|
|
2086
|
+
min: {
|
|
2087
|
+
type: [Number, Array],
|
|
2088
|
+
required: true
|
|
2089
|
+
},
|
|
2090
|
+
/** The value at the end of the travel, per axis. */
|
|
2091
|
+
max: {
|
|
2092
|
+
type: [Number, Array],
|
|
2093
|
+
required: true
|
|
2094
|
+
},
|
|
2095
|
+
/** Granularity of the value, per axis. @default 1 */
|
|
2096
|
+
step: {
|
|
2097
|
+
type: [Number, Array],
|
|
2098
|
+
default: 1
|
|
2099
|
+
},
|
|
2100
|
+
/** How the value is distributed, per axis. @default linearScale */
|
|
2101
|
+
scale: {
|
|
2102
|
+
type: [Object, Array],
|
|
2103
|
+
default: () => _tremolo_ui_functions.linearScale
|
|
2104
|
+
},
|
|
2105
|
+
/** Grow the value the other way, per axis. `y` grows downwards unless reversed. */
|
|
2106
|
+
reverse: {
|
|
2107
|
+
type: [Boolean, Array],
|
|
2108
|
+
default: false
|
|
2109
|
+
},
|
|
2110
|
+
/** The cursor to show while dragging. @default { cursor: 'pointer' } */
|
|
2111
|
+
externalStyles: Object,
|
|
2112
|
+
wheel: wheelProp,
|
|
2113
|
+
keyboard: keyboardProp,
|
|
2114
|
+
dragSensitivity: dragSensitivityProp,
|
|
2115
|
+
/** Make the pad unchangeable and remove it from the tab order. */
|
|
2116
|
+
disabled: Boolean,
|
|
2117
|
+
/** Make the value unchangeable. */
|
|
2118
|
+
readonly: Boolean
|
|
2119
|
+
},
|
|
2120
|
+
emits: {
|
|
2121
|
+
"update:modelValue": (value) => Array.isArray(value),
|
|
2122
|
+
dragStart: (value) => Array.isArray(value),
|
|
2123
|
+
dragEnd: (value) => Array.isArray(value)
|
|
2124
|
+
},
|
|
2125
|
+
setup(props, { slots, emit, expose }) {
|
|
2126
|
+
const root = (0, vue.ref)(null);
|
|
2127
|
+
let area = null;
|
|
2128
|
+
let thumb = null;
|
|
2129
|
+
const inactive = (0, vue.computed)(() => props.disabled || props.readonly);
|
|
2130
|
+
const min = (0, vue.computed)(() => (0, _tremolo_ui_dom.toXY)(props.min));
|
|
2131
|
+
const max = (0, vue.computed)(() => (0, _tremolo_ui_dom.toXY)(props.max));
|
|
2132
|
+
const step = (0, vue.computed)(() => (0, _tremolo_ui_dom.toXY)(props.step));
|
|
2133
|
+
const scale = (0, vue.computed)(() => (0, _tremolo_ui_dom.toXY)(props.scale));
|
|
2134
|
+
const reverse = (0, vue.computed)(() => (0, _tremolo_ui_dom.toXY)(props.reverse));
|
|
2135
|
+
const percent = (0, vue.computed)(() => [0, 1].map((i) => (0, _tremolo_ui_dom.valuePercent)(props.modelValue[i], {
|
|
2136
|
+
min: min.value[i],
|
|
2137
|
+
max: max.value[i],
|
|
2138
|
+
scale: scale.value[i]
|
|
2139
|
+
}, reverse.value[i])));
|
|
2140
|
+
const axis = (0, vue.computed)(() => [0, 1].map((i) => ({
|
|
2141
|
+
min: min.value[i],
|
|
2142
|
+
max: max.value[i],
|
|
2143
|
+
step: step.value[i],
|
|
2144
|
+
scale: scale.value[i],
|
|
2145
|
+
reverse: reverse.value[i]
|
|
2146
|
+
})));
|
|
2147
|
+
for (const [i, name] of [[0, "x"], [1, "y"]]) useCheckSteps(() => ({
|
|
2148
|
+
component: "XYPad",
|
|
2149
|
+
axis: name,
|
|
2150
|
+
range: axis.value[i],
|
|
2151
|
+
keyboard: props.keyboard,
|
|
2152
|
+
wheel: props.wheel
|
|
2153
|
+
}));
|
|
2154
|
+
const change = (next) => emit("update:modelValue", next);
|
|
2155
|
+
/** Move one axis by one press of `option`, in screen coordinates. */
|
|
2156
|
+
function nudge({ axis: i, direction }, option, modifiers) {
|
|
2157
|
+
const value = props.modelValue;
|
|
2158
|
+
const next = (0, _tremolo_ui_dom.applyDelta)(value[i], reverse.value[i] ? -direction : direction, option, axis.value[i], modifiers);
|
|
2159
|
+
change(i === 0 ? [next, value[1]] : [value[0], next]);
|
|
2160
|
+
}
|
|
2161
|
+
(0, vue.provide)(XYPadKey, {
|
|
2162
|
+
get value() {
|
|
2163
|
+
return props.modelValue;
|
|
2164
|
+
},
|
|
2165
|
+
get min() {
|
|
2166
|
+
return min.value;
|
|
2167
|
+
},
|
|
2168
|
+
get max() {
|
|
2169
|
+
return max.value;
|
|
2170
|
+
},
|
|
2171
|
+
get step() {
|
|
2172
|
+
return step.value;
|
|
2173
|
+
},
|
|
2174
|
+
get scale() {
|
|
2175
|
+
return scale.value;
|
|
2176
|
+
},
|
|
2177
|
+
get reverse() {
|
|
2178
|
+
return reverse.value;
|
|
2179
|
+
},
|
|
2180
|
+
get disabled() {
|
|
2181
|
+
return props.disabled;
|
|
2182
|
+
},
|
|
2183
|
+
get readonly() {
|
|
2184
|
+
return props.readonly;
|
|
2185
|
+
},
|
|
2186
|
+
get percent() {
|
|
2187
|
+
return percent.value;
|
|
2188
|
+
},
|
|
2189
|
+
change,
|
|
2190
|
+
setArea: (element) => {
|
|
2191
|
+
area = element;
|
|
2192
|
+
},
|
|
2193
|
+
setThumb: (input) => {
|
|
2194
|
+
thumb = input;
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
useDragValue(root, () => ({
|
|
2198
|
+
axis: axis.value,
|
|
2199
|
+
mapping: (0, _tremolo_ui_dom.elementMapping)(() => area, { sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(props.dragSensitivity, state.event).value }),
|
|
2200
|
+
updateOnPointerDown: true,
|
|
2201
|
+
cursor: inactive.value ? void 0 : props.externalStyles?.cursor ?? "pointer",
|
|
2202
|
+
shouldStart: () => !inactive.value,
|
|
2203
|
+
onChange: (v) => {
|
|
2204
|
+
if (!inactive.value) change(v);
|
|
2205
|
+
},
|
|
2206
|
+
onDragStart: (v) => {
|
|
2207
|
+
if (inactive.value) return;
|
|
2208
|
+
thumb?.focus();
|
|
2209
|
+
emit("dragStart", v);
|
|
2210
|
+
},
|
|
2211
|
+
onDragEnd: (v) => {
|
|
2212
|
+
if (!inactive.value) emit("dragEnd", v);
|
|
2213
|
+
}
|
|
2214
|
+
}));
|
|
2215
|
+
useWheel(root, (event) => {
|
|
2216
|
+
if (!props.wheel || inactive.value) return;
|
|
2217
|
+
const move = (0, _tremolo_ui_dom.wheelMove)(event);
|
|
2218
|
+
if (!move) return;
|
|
2219
|
+
event.preventDefault();
|
|
2220
|
+
nudge(move, props.wheel, event);
|
|
2221
|
+
}, { requireFocus: true });
|
|
2222
|
+
expose({
|
|
2223
|
+
focus: () => {
|
|
2224
|
+
if (!props.disabled) thumb?.focus();
|
|
2225
|
+
},
|
|
2226
|
+
blur: () => thumb?.blur()
|
|
2227
|
+
});
|
|
2228
|
+
return () => (0, vue.h)("div", {
|
|
2229
|
+
ref: root,
|
|
2230
|
+
role: "group",
|
|
2231
|
+
tabindex: -1,
|
|
2232
|
+
"data-disabled": props.disabled ? "" : void 0,
|
|
2233
|
+
"data-readonly": props.readonly ? "" : void 0,
|
|
2234
|
+
onKeydown: (event) => {
|
|
2235
|
+
const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
|
|
2236
|
+
if (!move) return;
|
|
2237
|
+
event.preventDefault();
|
|
2238
|
+
if (props.keyboard && !inactive.value) nudge(move, props.keyboard, event);
|
|
2239
|
+
},
|
|
2240
|
+
onFocus: () => {
|
|
2241
|
+
if (!props.disabled) thumb?.focus();
|
|
2242
|
+
},
|
|
2243
|
+
onBlur: () => thumb?.blur()
|
|
2244
|
+
}, slots.default?.());
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
//#endregion
|
|
2248
|
+
//#region src/components/XYPad/XYPadArea.ts
|
|
2249
|
+
/** The area the thumb moves in, and what the pointer is measured against. */
|
|
2250
|
+
const XYPadArea = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
2251
|
+
name: "XYPadArea",
|
|
2252
|
+
setup(_, { slots }) {
|
|
2253
|
+
const pad = useXYPadContext();
|
|
2254
|
+
providePlacement("XYPadArea");
|
|
2255
|
+
const el = (0, vue.ref)(null);
|
|
2256
|
+
(0, vue.watchEffect)(() => pad.setArea(el.value));
|
|
2257
|
+
(0, vue.onBeforeUnmount)(() => pad.setArea(null));
|
|
2258
|
+
return () => (0, vue.h)("div", {
|
|
2259
|
+
ref: el,
|
|
2260
|
+
style: { position: "relative" }
|
|
2261
|
+
}, slots.default?.());
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
//#endregion
|
|
2265
|
+
//#region src/components/XYPad/XYPadThumb.ts
|
|
2266
|
+
/** The thumb, placed at the value, with one range input per axis inside. */
|
|
2267
|
+
const XYPadThumb = /* @__PURE__ */ (0, vue.defineComponent)({
|
|
2268
|
+
name: "XYPadThumb",
|
|
2269
|
+
inheritAttrs: false,
|
|
2270
|
+
props: {
|
|
2271
|
+
/** Sets `--color`, for the theme to colour the thumb with. */
|
|
2272
|
+
color: String,
|
|
2273
|
+
/** The accessible name of each axis, as `[x, y]`. */
|
|
2274
|
+
ariaLabel: [String, Array],
|
|
2275
|
+
/** The ids of what labels each axis, as `[x, y]` or one for both. */
|
|
2276
|
+
ariaLabelledby: [String, Array],
|
|
2277
|
+
/** The ids of what describes each axis, as `[x, y]` or one for both. */
|
|
2278
|
+
ariaDescribedby: [String, Array],
|
|
2279
|
+
/** What the value of each axis means, as `[x, y]` or one for both. */
|
|
2280
|
+
ariaValuetext: [String, Array]
|
|
2281
|
+
},
|
|
2282
|
+
setup(props, { slots, attrs, expose }) {
|
|
2283
|
+
const pad = useXYPadContext();
|
|
2284
|
+
checkPlacement("XYPadThumb", "XYPadArea");
|
|
2285
|
+
const x = (0, vue.ref)(null);
|
|
2286
|
+
const y = (0, vue.ref)(null);
|
|
2287
|
+
(0, vue.watchEffect)(() => pad.setThumb(x.value));
|
|
2288
|
+
(0, vue.onBeforeUnmount)(() => pad.setThumb(null));
|
|
2289
|
+
expose({
|
|
2290
|
+
focus: () => {
|
|
2291
|
+
if (!pad.disabled) x.value?.focus();
|
|
2292
|
+
},
|
|
2293
|
+
blur: () => {
|
|
2294
|
+
x.value?.blur();
|
|
2295
|
+
y.value?.blur();
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
function input(axis) {
|
|
2299
|
+
const labels = (0, _tremolo_ui_dom.toXY)(props.ariaLabel);
|
|
2300
|
+
return visuallyHiddenRangeInput({
|
|
2301
|
+
ref: axis === 0 ? x : y,
|
|
2302
|
+
"data-axis": axis === 0 ? "x" : "y",
|
|
2303
|
+
value: pad.value[axis],
|
|
2304
|
+
min: pad.min[axis],
|
|
2305
|
+
max: pad.max[axis],
|
|
2306
|
+
step: pad.step[axis],
|
|
2307
|
+
disabled: pad.disabled,
|
|
2308
|
+
"aria-readonly": pad.readonly,
|
|
2309
|
+
"aria-orientation": axis === 0 ? "horizontal" : "vertical",
|
|
2310
|
+
"aria-label": labels[axis] ?? (axis === 0 ? "x" : "y"),
|
|
2311
|
+
"aria-labelledby": (0, _tremolo_ui_dom.toXY)(props.ariaLabelledby)[axis],
|
|
2312
|
+
"aria-describedby": (0, _tremolo_ui_dom.toXY)(props.ariaDescribedby)[axis],
|
|
2313
|
+
"aria-valuetext": (0, _tremolo_ui_dom.toXY)(props.ariaValuetext)[axis],
|
|
2314
|
+
onInput: (event) => {
|
|
2315
|
+
const target = event.target;
|
|
2316
|
+
if (pad.readonly) {
|
|
2317
|
+
target.value = String(pad.value[axis]);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
const next = [...pad.value];
|
|
2321
|
+
next[axis] = target.valueAsNumber;
|
|
2322
|
+
pad.change(next);
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
return () => {
|
|
2327
|
+
const { style, ...rest } = attrs;
|
|
2328
|
+
return (0, vue.h)("div", {
|
|
2329
|
+
"data-disabled": pad.disabled ? "" : void 0,
|
|
2330
|
+
"data-readonly": pad.readonly ? "" : void 0,
|
|
2331
|
+
...rest,
|
|
2332
|
+
style: [
|
|
2333
|
+
{
|
|
2334
|
+
position: "absolute",
|
|
2335
|
+
translate: "var(--translate, -50% -50%)",
|
|
2336
|
+
zIndex: 100,
|
|
2337
|
+
"--color": props.color
|
|
2338
|
+
},
|
|
2339
|
+
style,
|
|
2340
|
+
{
|
|
2341
|
+
left: `${pad.percent[0]}%`,
|
|
2342
|
+
top: `${pad.percent[1]}%`
|
|
2343
|
+
}
|
|
2344
|
+
]
|
|
2345
|
+
}, [
|
|
2346
|
+
input(0),
|
|
2347
|
+
input(1),
|
|
2348
|
+
slots.default?.()
|
|
2349
|
+
]);
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
//#endregion
|
|
2354
|
+
//#region src/composables/useDrag.ts
|
|
2355
|
+
/**
|
|
2356
|
+
* Track a pointer drag on an element. See `createDrag` in `@tremolo-ui/dom`
|
|
2357
|
+
* for the options.
|
|
2358
|
+
*
|
|
2359
|
+
* @example
|
|
2360
|
+
* const el = useTemplateRef('el')
|
|
2361
|
+
* useDrag(el, { onDrag: (state) => (x.value += state.deltaX) })
|
|
2362
|
+
*/
|
|
2363
|
+
function useDrag(target, options = {}) {
|
|
2364
|
+
useInstance(target, options, _tremolo_ui_dom.createDrag, (instance, next) => instance.update(next));
|
|
2365
|
+
}
|
|
2366
|
+
//#endregion
|
|
2367
|
+
//#region src/composables/useMIDIAccess.ts
|
|
2368
|
+
/**
|
|
2369
|
+
* Request MIDI access in the browser. Call it during `setup`.
|
|
2370
|
+
*
|
|
2371
|
+
* `state` holds the access, the error and the connected inputs, and follows
|
|
2372
|
+
* devices as they are plugged in and unplugged.
|
|
2373
|
+
*
|
|
2374
|
+
* @param requestOnMount ask for access once the component is mounted — never
|
|
2375
|
+
* during server rendering, where there is no MIDI to ask for. Leave it off to
|
|
2376
|
+
* ask from a click instead, so that opening a page does not bring up a
|
|
2377
|
+
* permission prompt. A ref or a getter asks once it turns true.
|
|
2378
|
+
*/
|
|
2379
|
+
function useMIDIAccess(requestOnMount = true) {
|
|
2380
|
+
const instance = (0, _tremolo_ui_dom.createMIDIAccess)();
|
|
2381
|
+
const state = (0, vue.shallowRef)(instance.getState());
|
|
2382
|
+
const unsubscribe = instance.subscribe(() => {
|
|
2383
|
+
state.value = instance.getState();
|
|
2384
|
+
});
|
|
2385
|
+
(0, vue.onMounted)(() => {
|
|
2386
|
+
(0, vue.watch)(() => (0, vue.toValue)(requestOnMount), (request) => {
|
|
2387
|
+
if (request) instance.request();
|
|
2388
|
+
}, { immediate: true });
|
|
2389
|
+
});
|
|
2390
|
+
(0, vue.onScopeDispose)(() => {
|
|
2391
|
+
unsubscribe();
|
|
2392
|
+
instance.destroy();
|
|
2393
|
+
});
|
|
2394
|
+
return {
|
|
2395
|
+
state: (0, vue.computed)(() => state.value),
|
|
2396
|
+
/**
|
|
2397
|
+
* Ask for access. Takes `{ sysex: true }` when the app needs system
|
|
2398
|
+
* exclusive messages, which browsers treat as a separate permission.
|
|
2399
|
+
*/
|
|
2400
|
+
request: (options) => instance.request(options)
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
//#endregion
|
|
2404
|
+
//#region src/composables/useMIDIInput.ts
|
|
2405
|
+
/**
|
|
2406
|
+
* Handle the channel voice messages of every connected input. Call it during
|
|
2407
|
+
* `setup`. See `createMIDIInput` in `@tremolo-ui/dom` for the handlers.
|
|
2408
|
+
*
|
|
2409
|
+
* The listeners follow `midiAccess` as it is granted, and new handlers
|
|
2410
|
+
* replace the old ones without re-subscribing.
|
|
2411
|
+
*/
|
|
2412
|
+
function useMIDIInput(midiAccess, handlers) {
|
|
2413
|
+
let instance = null;
|
|
2414
|
+
(0, vue.watch)(() => (0, vue.toValue)(midiAccess), (access, _, onCleanup) => {
|
|
2415
|
+
const current = (0, _tremolo_ui_dom.createMIDIInput)(access, (0, vue.toValue)(handlers));
|
|
2416
|
+
instance = current;
|
|
2417
|
+
onCleanup(() => {
|
|
2418
|
+
current.destroy();
|
|
2419
|
+
if (instance === current) instance = null;
|
|
2420
|
+
});
|
|
2421
|
+
}, { immediate: true });
|
|
2422
|
+
(0, vue.watch)(() => (0, vue.toValue)(handlers), (next) => instance?.update(next));
|
|
2423
|
+
}
|
|
2424
|
+
//#endregion
|
|
2425
|
+
//#region src/composables/useMIDIMessage.ts
|
|
2426
|
+
/**
|
|
2427
|
+
* Listen to raw `midimessage` events on every input. Call it during `setup`.
|
|
2428
|
+
* For note and controller events, `useMIDIInput` decodes the message for you.
|
|
2429
|
+
*/
|
|
2430
|
+
function useMIDIMessage(midiAccess, onMIDIMessage) {
|
|
2431
|
+
let instance = null;
|
|
2432
|
+
(0, vue.watch)(() => (0, vue.toValue)(midiAccess), (access, _, onCleanup) => {
|
|
2433
|
+
const current = (0, _tremolo_ui_dom.createMIDIMessage)(access, (0, vue.toValue)(onMIDIMessage));
|
|
2434
|
+
instance = current;
|
|
2435
|
+
onCleanup(() => {
|
|
2436
|
+
current.destroy();
|
|
2437
|
+
if (instance === current) instance = null;
|
|
2438
|
+
});
|
|
2439
|
+
}, { immediate: true });
|
|
2440
|
+
(0, vue.watch)(() => (0, vue.toValue)(onMIDIMessage), (next) => instance?.update(next));
|
|
2441
|
+
}
|
|
2442
|
+
//#endregion
|
|
2443
|
+
exports.AnimationCanvas = AnimationCanvas;
|
|
2444
|
+
exports.DropZone = DropZone;
|
|
2445
|
+
exports.FileInput = FileInput;
|
|
2446
|
+
exports.FileInputTrigger = FileInputTrigger;
|
|
2447
|
+
exports.Knob = Knob;
|
|
2448
|
+
exports.KnobActiveLine = KnobActiveLine;
|
|
2449
|
+
exports.KnobInactiveLine = KnobInactiveLine;
|
|
2450
|
+
exports.KnobSVGRoot = KnobSVGRoot;
|
|
2451
|
+
exports.KnobThumb = KnobThumb;
|
|
2452
|
+
exports.NumberInput = NumberInput;
|
|
2453
|
+
exports.NumberInputDecrementStepper = NumberInputDecrementStepper;
|
|
2454
|
+
exports.NumberInputField = NumberInputField;
|
|
2455
|
+
exports.NumberInputIncrementStepper = NumberInputIncrementStepper;
|
|
2456
|
+
exports.NumberInputStepper = NumberInputStepper;
|
|
2457
|
+
exports.Piano = Piano;
|
|
2458
|
+
exports.PointsEditor = PointsEditor;
|
|
2459
|
+
exports.PointsEditorBackground = PointsEditorBackground;
|
|
2460
|
+
exports.PointsEditorContainer = PointsEditorContainer;
|
|
2461
|
+
exports.PointsEditorPoint = PointsEditorPoint;
|
|
2462
|
+
exports.PointsEditorSelectionBox = PointsEditorSelectionBox;
|
|
2463
|
+
exports.Slider = Slider;
|
|
2464
|
+
exports.SliderMarks = SliderMarks;
|
|
2465
|
+
exports.SliderMarksOption = SliderMarksOption;
|
|
2466
|
+
exports.SliderThumb = SliderThumb;
|
|
2467
|
+
exports.SliderTrack = SliderTrack;
|
|
2468
|
+
exports.XYPad = XYPad;
|
|
2469
|
+
exports.XYPadArea = XYPadArea;
|
|
2470
|
+
exports.XYPadThumb = XYPadThumb;
|
|
2471
|
+
exports.useDrag = useDrag;
|
|
2472
|
+
exports.useDragValue = useDragValue;
|
|
2473
|
+
exports.useDropZone = useDropZone;
|
|
2474
|
+
exports.useFileInputContext = useFileInputContext;
|
|
2475
|
+
exports.useKnobContext = useKnobContext;
|
|
2476
|
+
exports.useLongPress = useLongPress;
|
|
2477
|
+
exports.useMIDIAccess = useMIDIAccess;
|
|
2478
|
+
exports.useMIDIInput = useMIDIInput;
|
|
2479
|
+
exports.useMIDIMessage = useMIDIMessage;
|
|
2480
|
+
exports.useNumberInputContext = useNumberInputContext;
|
|
2481
|
+
exports.usePointsEditorContext = usePointsEditorContext;
|
|
2482
|
+
exports.useSliderContext = useSliderContext;
|
|
2483
|
+
exports.useWheel = useWheel;
|
|
2484
|
+
exports.useXYPadContext = useXYPadContext;
|
|
2485
|
+
|
|
2486
|
+
//# sourceMappingURL=index.cjs.map
|