@tremolo-ui/dom 0.5.0 → 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/dist/index.cjs +842 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +490 -47
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +490 -47
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +829 -102
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/canvas/animation.ts +49 -31
- package/src/canvas/context.ts +32 -5
- package/src/file/accept.ts +58 -0
- package/src/file/drop-zone.ts +202 -0
- package/src/index.ts +39 -2
- package/src/input/apply-delta.ts +73 -0
- package/src/input/modifiers.ts +121 -0
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/{input.ts → index.ts} +25 -5
- package/src/piano/layout.ts +183 -0
- package/src/pointer/{dragValue.ts → drag-value.ts} +155 -19
- package/src/pointer/drag.ts +215 -20
- package/src/pointer/wheel.ts +4 -2
- package/src/selection/box.ts +136 -0
package/src/canvas/animation.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface AnimationFrame {
|
|
|
17
17
|
deltaTime: number
|
|
18
18
|
/** Milliseconds since the instance was created. */
|
|
19
19
|
elapsedTime: number
|
|
20
|
-
/** Frames per second implied by `deltaTime
|
|
20
|
+
/** Frames per second implied by `deltaTime`, or 0 when no time elapsed. */
|
|
21
21
|
fps: number
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -47,7 +47,7 @@ export interface AnimationCanvasOptions {
|
|
|
47
47
|
animate?: boolean
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Size in CSS pixels. Ignored when `
|
|
50
|
+
* Size in CSS pixels. Ignored when `resizable` is on.
|
|
51
51
|
*
|
|
52
52
|
* @default { width: 100, height: 100 }
|
|
53
53
|
*/
|
|
@@ -61,7 +61,7 @@ export interface AnimationCanvasOptions {
|
|
|
61
61
|
*
|
|
62
62
|
* @default false
|
|
63
63
|
*/
|
|
64
|
-
|
|
64
|
+
resizable?: boolean
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Carry the drawing across a resize, so that the canvas does not blank for a
|
|
@@ -86,7 +86,7 @@ export interface AnimationCanvasInstance {
|
|
|
86
86
|
* render without restarting the animation, so the frame count and the
|
|
87
87
|
* elapsed time keep running.
|
|
88
88
|
*
|
|
89
|
-
* `
|
|
89
|
+
* `resizable` and `contextAttributes` are fixed for the lifetime of the
|
|
90
90
|
* instance and are ignored here.
|
|
91
91
|
*
|
|
92
92
|
* While `animate` is off this also draws a frame, since nothing else would.
|
|
@@ -119,7 +119,7 @@ export function createAnimationCanvas(
|
|
|
119
119
|
}
|
|
120
120
|
const context: CanvasRenderingContext2D = context2d
|
|
121
121
|
|
|
122
|
-
const
|
|
122
|
+
const resizable = opts.resizable ?? false
|
|
123
123
|
|
|
124
124
|
/** Off-document canvas holding the drawing while the real one is resized. */
|
|
125
125
|
let memo: HTMLCanvasElement | null = null
|
|
@@ -127,6 +127,7 @@ export function createAnimationCanvas(
|
|
|
127
127
|
|
|
128
128
|
let width = 0
|
|
129
129
|
let height = 0
|
|
130
|
+
let appliedDpr = 0
|
|
130
131
|
/** Whether a size has been applied, so a frame can be drawn. */
|
|
131
132
|
let sized = false
|
|
132
133
|
let initialized = false
|
|
@@ -146,21 +147,20 @@ export function createAnimationCanvas(
|
|
|
146
147
|
* Assigning to `memo.width` resets the memo's transform to the identity, so
|
|
147
148
|
* the copy neither scales nor resamples.
|
|
148
149
|
*/
|
|
149
|
-
function takeSnapshot()
|
|
150
|
-
if (!(opts.reduceFlickering ?? true)) return
|
|
150
|
+
function takeSnapshot() {
|
|
151
|
+
if (!(opts.reduceFlickering ?? true)) return false
|
|
151
152
|
if (!memo) {
|
|
152
153
|
memo = globalThis.document?.createElement('canvas') ?? null
|
|
153
154
|
memoContext = memo?.getContext('2d', opts.contextAttributes) ?? null
|
|
154
155
|
}
|
|
155
|
-
if (!memo || !memoContext) return
|
|
156
|
+
if (!memo || !memoContext) return false
|
|
156
157
|
|
|
157
|
-
const state = readDrawingState(context)
|
|
158
158
|
memo.width = canvas.width
|
|
159
159
|
memo.height = canvas.height
|
|
160
160
|
if (canvas.width > 0 && canvas.height > 0) {
|
|
161
161
|
memoContext.drawImage(canvas, 0, 0)
|
|
162
162
|
}
|
|
163
|
-
return
|
|
163
|
+
return true
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
@@ -172,13 +172,12 @@ export function createAnimationCanvas(
|
|
|
172
172
|
* snapshot is rescaled once, from its full resolution.
|
|
173
173
|
*/
|
|
174
174
|
function restoreSnapshot(
|
|
175
|
-
|
|
175
|
+
hasSnapshot: boolean,
|
|
176
176
|
previousWidth: number,
|
|
177
177
|
previousHeight: number,
|
|
178
178
|
) {
|
|
179
|
-
if (!
|
|
179
|
+
if (!hasSnapshot || !memo) return
|
|
180
180
|
if (memo.width <= 0 || memo.height <= 0) return
|
|
181
|
-
writeDrawingState(context, state)
|
|
182
181
|
context.drawImage(memo, 0, 0, previousWidth, previousHeight)
|
|
183
182
|
}
|
|
184
183
|
|
|
@@ -186,60 +185,76 @@ export function createAnimationCanvas(
|
|
|
186
185
|
const dpr = devicePixelRatio()
|
|
187
186
|
const previousWidth = width
|
|
188
187
|
const previousHeight = height
|
|
189
|
-
const
|
|
188
|
+
const previousDpr = appliedDpr
|
|
189
|
+
const state: DrawingContext | null = sized
|
|
190
|
+
? readDrawingState(context)
|
|
191
|
+
: null
|
|
192
|
+
const hasSnapshot = sized && takeSnapshot()
|
|
190
193
|
applyDevicePixelRatio(canvas, context, w, h, dpr)
|
|
191
194
|
width = w
|
|
192
195
|
height = h
|
|
196
|
+
appliedDpr = dpr
|
|
193
197
|
sized = true
|
|
194
|
-
|
|
198
|
+
// Restore pixels while the reset context still has neutral alpha,
|
|
199
|
+
// compositing, filter, and shadow settings.
|
|
200
|
+
restoreSnapshot(hasSnapshot, previousWidth, previousHeight)
|
|
201
|
+
if (state) {
|
|
202
|
+
writeDrawingState(context, state, dpr / previousDpr)
|
|
203
|
+
}
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
function
|
|
206
|
+
function applyDevicePixelRatioIfNeeded() {
|
|
207
|
+
if (sized && devicePixelRatio() !== appliedDpr) {
|
|
208
|
+
applySize(width, height)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function drawFrame(timestamp = performance.now()) {
|
|
198
213
|
if (!sized) return
|
|
214
|
+
applyDevicePixelRatioIfNeeded()
|
|
199
215
|
if (!initialized) {
|
|
200
216
|
initialized = true
|
|
201
217
|
opts.init?.(context, { width, height })
|
|
202
218
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
previousTime = now
|
|
219
|
+
const deltaTime = timestamp - previousTime
|
|
220
|
+
previousTime = timestamp
|
|
206
221
|
count += 1
|
|
207
222
|
opts.draw(context, {
|
|
208
223
|
width,
|
|
209
224
|
height,
|
|
210
225
|
count,
|
|
211
226
|
deltaTime,
|
|
212
|
-
elapsedTime:
|
|
213
|
-
fps: 1000 / deltaTime,
|
|
227
|
+
elapsedTime: timestamp - startTime,
|
|
228
|
+
fps: deltaTime > 0 ? 1000 / deltaTime : 0,
|
|
214
229
|
})
|
|
215
230
|
}
|
|
216
231
|
|
|
217
|
-
function tick() {
|
|
232
|
+
function tick(timestamp: DOMHighResTimeStamp) {
|
|
218
233
|
// Scheduled before drawing so that a slow frame does not delay the next
|
|
219
234
|
// request, matching how requestAnimationFrame loops are usually written.
|
|
220
235
|
frameId = requestAnimationFrame(tick)
|
|
221
|
-
drawFrame()
|
|
236
|
+
drawFrame(timestamp)
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
function startLoop() {
|
|
225
|
-
if (frameId
|
|
240
|
+
if (frameId !== null) return
|
|
226
241
|
previousTime = performance.now()
|
|
227
242
|
frameId = requestAnimationFrame(tick)
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
function stopLoop() {
|
|
231
|
-
if (frameId
|
|
246
|
+
if (frameId === null) return
|
|
232
247
|
cancelAnimationFrame(frameId)
|
|
233
248
|
frameId = null
|
|
234
249
|
}
|
|
235
250
|
|
|
236
251
|
let observer: ResizeObserver | null = null
|
|
237
252
|
|
|
238
|
-
if (
|
|
253
|
+
if (resizable) {
|
|
239
254
|
const parent = canvas.parentElement
|
|
240
255
|
if (!parent) {
|
|
241
256
|
throw new Error(
|
|
242
|
-
'createAnimationCanvas:
|
|
257
|
+
'createAnimationCanvas: resizable needs the canvas to have a parent element',
|
|
243
258
|
)
|
|
244
259
|
}
|
|
245
260
|
// The observer reports the current size as soon as it starts, so it is the
|
|
@@ -249,7 +264,9 @@ export function createAnimationCanvas(
|
|
|
249
264
|
observer = new ResizeObserver((entries) => {
|
|
250
265
|
for (const entry of entries) {
|
|
251
266
|
const { width: w, height: h } = entry.contentRect
|
|
252
|
-
|
|
267
|
+
if (w !== width || h !== height || devicePixelRatio() !== appliedDpr) {
|
|
268
|
+
applySize(w, h)
|
|
269
|
+
}
|
|
253
270
|
// With the loop running the next frame covers the new size already.
|
|
254
271
|
if (!(opts.animate ?? true)) drawFrame()
|
|
255
272
|
}
|
|
@@ -272,14 +289,15 @@ export function createAnimationCanvas(
|
|
|
272
289
|
opts = {
|
|
273
290
|
...opts,
|
|
274
291
|
...next,
|
|
275
|
-
|
|
292
|
+
resizable,
|
|
276
293
|
contextAttributes: opts.contextAttributes,
|
|
277
294
|
}
|
|
278
295
|
|
|
279
|
-
if (!
|
|
296
|
+
if (!resizable) {
|
|
280
297
|
const { width: w = 100, height: h = 100 } = opts.size ?? {}
|
|
281
|
-
if (w
|
|
298
|
+
if (w !== width || h !== height) applySize(w, h)
|
|
282
299
|
}
|
|
300
|
+
applyDevicePixelRatioIfNeeded()
|
|
283
301
|
|
|
284
302
|
const animating = opts.animate ?? true
|
|
285
303
|
if (animating && !wasAnimating) startLoop()
|
package/src/canvas/context.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The assignable parts of a 2D context's drawing state. They have to be
|
|
3
|
+
* carried across a resize by hand because setting `canvas.width` resets the
|
|
4
|
+
* context to its defaults. The transform and line dash are handled separately
|
|
5
|
+
* by {@link DrawingContext} because they are exposed through methods.
|
|
5
6
|
*
|
|
6
7
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
|
|
7
8
|
*/
|
|
@@ -19,20 +20,33 @@ export const drawingState = [
|
|
|
19
20
|
'shadowBlur',
|
|
20
21
|
'shadowColor',
|
|
21
22
|
'globalCompositeOperation',
|
|
23
|
+
'filter',
|
|
22
24
|
'font',
|
|
25
|
+
'fontKerning',
|
|
26
|
+
'fontStretch',
|
|
27
|
+
'fontVariantCaps',
|
|
23
28
|
'textAlign',
|
|
24
29
|
'textBaseline',
|
|
25
30
|
'direction',
|
|
31
|
+
'letterSpacing',
|
|
32
|
+
'textRendering',
|
|
33
|
+
'wordSpacing',
|
|
26
34
|
'imageSmoothingEnabled',
|
|
35
|
+
'imageSmoothingQuality',
|
|
27
36
|
] as const
|
|
28
37
|
|
|
29
38
|
export type DrawingState = (typeof drawingState)[number]
|
|
30
39
|
export type DrawingStateValue = CanvasRenderingContext2D[DrawingState]
|
|
31
|
-
export type DrawingContext = Pick<CanvasRenderingContext2D, DrawingState>
|
|
40
|
+
export type DrawingContext = Pick<CanvasRenderingContext2D, DrawingState> & {
|
|
41
|
+
/** The current line dash sequence. */
|
|
42
|
+
lineDash: number[]
|
|
43
|
+
/** The current transformation matrix. */
|
|
44
|
+
transform: DOMMatrix
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
export function isDrawingState(value: unknown): value is DrawingState {
|
|
34
48
|
const names: readonly string[] = drawingState
|
|
35
|
-
return typeof value
|
|
49
|
+
return typeof value === 'string' && names.includes(value)
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** Copy the drawing state off a context, to put back after a resize. */
|
|
@@ -43,6 +57,8 @@ export function readDrawingState(
|
|
|
43
57
|
for (const property of drawingState) {
|
|
44
58
|
;(state[property] as DrawingStateValue) = context[property]
|
|
45
59
|
}
|
|
60
|
+
state.lineDash = context.getLineDash()
|
|
61
|
+
state.transform = context.getTransform()
|
|
46
62
|
return state
|
|
47
63
|
}
|
|
48
64
|
|
|
@@ -50,10 +66,21 @@ export function readDrawingState(
|
|
|
50
66
|
export function writeDrawingState(
|
|
51
67
|
context: CanvasRenderingContext2D,
|
|
52
68
|
state: DrawingContext,
|
|
69
|
+
transformScale = 1,
|
|
53
70
|
) {
|
|
54
71
|
for (const property of drawingState) {
|
|
55
72
|
;(context[property] as DrawingStateValue) = state[property]
|
|
56
73
|
}
|
|
74
|
+
context.setLineDash(state.lineDash)
|
|
75
|
+
const { a, b, c, d, e, f } = state.transform
|
|
76
|
+
context.setTransform(
|
|
77
|
+
a * transformScale,
|
|
78
|
+
b * transformScale,
|
|
79
|
+
c * transformScale,
|
|
80
|
+
d * transformScale,
|
|
81
|
+
e * transformScale,
|
|
82
|
+
f * transformScale,
|
|
83
|
+
)
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
/**
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an `accept` rule is matched against.
|
|
3
|
+
*
|
|
4
|
+
* `File` satisfies it, and so does `DataTransferItem` — which matters while a
|
|
5
|
+
* drag is still in the air, since the browser reports the type of what is
|
|
6
|
+
* being dragged but withholds the name.
|
|
7
|
+
*/
|
|
8
|
+
export interface AcceptCandidate {
|
|
9
|
+
/** The file name, when it is known. */
|
|
10
|
+
name?: string
|
|
11
|
+
/** The MIME type, or `''` when the browser has no type for it. */
|
|
12
|
+
type: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Does a file satisfy an `accept` attribute?
|
|
17
|
+
*
|
|
18
|
+
* `accept` is written the way the HTML attribute is: a comma separated list of
|
|
19
|
+
* extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
|
|
20
|
+
* Anything that matches one entry is accepted, and an empty or missing
|
|
21
|
+
* `accept` takes everything.
|
|
22
|
+
*
|
|
23
|
+
* **The browser's own `accept` is only a hint to the file picker.** A person
|
|
24
|
+
* can switch it to "All Files", drag a file in, or pick one the picker was
|
|
25
|
+
* never asked about, so what arrives still has to be checked.
|
|
26
|
+
*
|
|
27
|
+
* A rule that cannot be decided is not treated as a rejection: with no `name`,
|
|
28
|
+
* an extension rule says nothing either way, and `accept=".wav"` reports a
|
|
29
|
+
* match rather than refusing a file it has not seen the name of. The name is
|
|
30
|
+
* there by the time the file is dropped, which is when the answer counts.
|
|
31
|
+
*/
|
|
32
|
+
export function matchesAccept(
|
|
33
|
+
candidate: AcceptCandidate,
|
|
34
|
+
accept?: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
const rules = (accept ?? '')
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((rule) => rule.trim().toLowerCase())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
if (rules.length === 0) return true
|
|
41
|
+
|
|
42
|
+
const name = candidate.name?.toLowerCase()
|
|
43
|
+
const type = candidate.type.toLowerCase()
|
|
44
|
+
let undecided = false
|
|
45
|
+
|
|
46
|
+
for (const rule of rules) {
|
|
47
|
+
if (rule.startsWith('.')) {
|
|
48
|
+
if (name === undefined) undecided = true
|
|
49
|
+
else if (name.endsWith(rule)) return true
|
|
50
|
+
} else if (rule.endsWith('/*')) {
|
|
51
|
+
if (type.startsWith(rule.slice(0, -1))) return true
|
|
52
|
+
} else if (type === rule) {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undecided
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { matchesAccept } from './accept'
|
|
2
|
+
|
|
3
|
+
/** What is in the air over the element. */
|
|
4
|
+
export interface DropZoneState {
|
|
5
|
+
/** Files are being dragged over the element. */
|
|
6
|
+
over: boolean
|
|
7
|
+
/**
|
|
8
|
+
* None of what is being dragged matches `accept`, as far as can be told
|
|
9
|
+
* before the drop.
|
|
10
|
+
*
|
|
11
|
+
* The browser reports the type of what is being dragged but withholds the
|
|
12
|
+
* name, so a rule written as an extension cannot be decided yet and is not
|
|
13
|
+
* counted against the drag. It is decided on the drop, where the name is.
|
|
14
|
+
*/
|
|
15
|
+
invalid: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DropZoneOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Which files to take, written the way the `accept` attribute of a file
|
|
21
|
+
* input is: a comma separated list of extensions (`.wav`), MIME types
|
|
22
|
+
* (`audio/wav`) and type groups (`audio/*`).
|
|
23
|
+
*/
|
|
24
|
+
accept?: string
|
|
25
|
+
/**
|
|
26
|
+
* Take more than one file from a single drop. With it off, only the first
|
|
27
|
+
* accepted file is reported, as a file input without `multiple` does.
|
|
28
|
+
*
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
multiple?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Refuse the drop. The drag is still swallowed rather than let through: an
|
|
34
|
+
* unhandled drop makes the browser leave the page and open the file.
|
|
35
|
+
*
|
|
36
|
+
* @default false
|
|
37
|
+
*/
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
|
|
40
|
+
/** Called with the dropped files that match `accept`. */
|
|
41
|
+
onDrop?: (files: File[], event: DragEvent) => void
|
|
42
|
+
/**
|
|
43
|
+
* Called with the dropped files that do not match `accept`, so that the
|
|
44
|
+
* reason can be shown.
|
|
45
|
+
*/
|
|
46
|
+
onReject?: (files: File[], event: DragEvent) => void
|
|
47
|
+
/** Called whenever {@link DropZoneInstance.state} would change. */
|
|
48
|
+
onStateChange?: (state: DropZoneState) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DropZoneInstance {
|
|
52
|
+
/** What is in the air over the element, right now. */
|
|
53
|
+
readonly state: DropZoneState
|
|
54
|
+
/** Replace the given options, keeping the listeners in place. */
|
|
55
|
+
update: (options: DropZoneOptions) => void
|
|
56
|
+
destroy: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Take files dropped onto an element.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* const zone = createDropZone(element, {
|
|
64
|
+
* accept: 'audio/*',
|
|
65
|
+
* onDrop: (files) => load(files[0]),
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* The element needs no attribute of its own: a drop target is made by
|
|
70
|
+
* cancelling `dragover`, which this does.
|
|
71
|
+
*/
|
|
72
|
+
export function createDropZone(
|
|
73
|
+
element: Element,
|
|
74
|
+
options: DropZoneOptions = {},
|
|
75
|
+
): DropZoneInstance {
|
|
76
|
+
let opts = options
|
|
77
|
+
let state: DropZoneState = { over: false, invalid: false }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `dragenter` and `dragleave` fire for descendants too, so moving between
|
|
81
|
+
* two children of the zone leaves before it enters. Counting the pairs is
|
|
82
|
+
* what keeps the state from flickering off in the middle of the element.
|
|
83
|
+
*/
|
|
84
|
+
let depth = 0
|
|
85
|
+
|
|
86
|
+
function setState(next: DropZoneState) {
|
|
87
|
+
if (next.over === state.over && next.invalid === state.invalid) return
|
|
88
|
+
state = next
|
|
89
|
+
opts.onStateChange?.(state)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function carriesFiles(transfer: DataTransfer | null) {
|
|
93
|
+
// `types` is the only thing that can be trusted during a drag; `files` is
|
|
94
|
+
// empty until the drop.
|
|
95
|
+
return !!transfer?.types.includes('Files')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether anything being dragged could still be accepted. */
|
|
99
|
+
function anyAcceptable(transfer: DataTransfer | null) {
|
|
100
|
+
const items = Array.from(transfer?.items ?? []).filter(
|
|
101
|
+
(item) => item.kind === 'file',
|
|
102
|
+
)
|
|
103
|
+
// Some browsers hand over no items at all, only the `Files` type. Nothing
|
|
104
|
+
// is known, so nothing is refused.
|
|
105
|
+
if (items.length === 0) return true
|
|
106
|
+
return items.some((item) => matchesAccept({ type: item.type }, opts.accept))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onDragEnter(event: DragEvent) {
|
|
110
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
111
|
+
// Cancelled as well as `dragover`: a target that only cancels one of the
|
|
112
|
+
// two is not a drop target in every browser.
|
|
113
|
+
event.preventDefault()
|
|
114
|
+
depth += 1
|
|
115
|
+
setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onDragOver(event: DragEvent) {
|
|
119
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
if (event.dataTransfer) {
|
|
122
|
+
// Decides the cursor the pointer shows, and whether a drop is offered.
|
|
123
|
+
event.dataTransfer.dropEffect =
|
|
124
|
+
opts.disabled || state.invalid ? 'none' : 'copy'
|
|
125
|
+
}
|
|
126
|
+
// A drag that began outside the document can arrive without a `dragenter`
|
|
127
|
+
// the listener saw, so the state is settled here too.
|
|
128
|
+
if (!state.over) {
|
|
129
|
+
depth = Math.max(depth, 1)
|
|
130
|
+
setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onDragLeave(event: DragEvent) {
|
|
135
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
136
|
+
depth = Math.max(0, depth - 1)
|
|
137
|
+
if (depth === 0) setState({ over: false, invalid: false })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onDrop(event: DragEvent) {
|
|
141
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
142
|
+
// Always cancelled, even while disabled: an unhandled drop makes the
|
|
143
|
+
// browser leave the page and open the file.
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
depth = 0
|
|
146
|
+
setState({ over: false, invalid: false })
|
|
147
|
+
if (opts.disabled) return
|
|
148
|
+
|
|
149
|
+
const dropped = Array.from(event.dataTransfer?.files ?? [])
|
|
150
|
+
const accepted: File[] = []
|
|
151
|
+
const rejected: File[] = []
|
|
152
|
+
for (const file of dropped) {
|
|
153
|
+
if (matchesAccept(file, opts.accept)) accepted.push(file)
|
|
154
|
+
else rejected.push(file)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event)
|
|
158
|
+
const taken = opts.multiple ? accepted : accepted.slice(0, 1)
|
|
159
|
+
if (taken.length > 0) opts.onDrop?.(taken, event)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A drag that ends anywhere else — dropped on another element, cancelled
|
|
164
|
+
* with Esc, taken out of the window — sends no `dragleave` here, and the
|
|
165
|
+
* element would stay marked as a drop target for good.
|
|
166
|
+
*/
|
|
167
|
+
function onDragEndAnywhere() {
|
|
168
|
+
depth = 0
|
|
169
|
+
setState({ over: false, invalid: false })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const handlers = {
|
|
173
|
+
dragenter: onDragEnter,
|
|
174
|
+
dragover: onDragOver,
|
|
175
|
+
dragleave: onDragLeave,
|
|
176
|
+
drop: onDrop,
|
|
177
|
+
} as const
|
|
178
|
+
|
|
179
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
180
|
+
element.addEventListener(type, handler as EventListener)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const doc = element.ownerDocument
|
|
184
|
+
doc?.addEventListener('dragend', onDragEndAnywhere)
|
|
185
|
+
doc?.addEventListener('drop', onDragEndAnywhere)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
get state() {
|
|
189
|
+
return state
|
|
190
|
+
},
|
|
191
|
+
update: (next) => {
|
|
192
|
+
opts = { ...opts, ...next }
|
|
193
|
+
},
|
|
194
|
+
destroy: () => {
|
|
195
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
196
|
+
element.removeEventListener(type, handler as EventListener)
|
|
197
|
+
}
|
|
198
|
+
doc?.removeEventListener('dragend', onDragEndAnywhere)
|
|
199
|
+
doc?.removeEventListener('drop', onDragEndAnywhere)
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,26 +13,55 @@ export {
|
|
|
13
13
|
type DrawingState,
|
|
14
14
|
type DrawingStateValue,
|
|
15
15
|
} from './canvas/context'
|
|
16
|
+
export { matchesAccept, type AcceptCandidate } from './file/accept'
|
|
17
|
+
export {
|
|
18
|
+
createDropZone,
|
|
19
|
+
type DropZoneInstance,
|
|
20
|
+
type DropZoneOptions,
|
|
21
|
+
type DropZoneState,
|
|
22
|
+
} from './file/drop-zone'
|
|
23
|
+
export { applyDelta } from './input/apply-delta'
|
|
24
|
+
export {
|
|
25
|
+
mapModifier,
|
|
26
|
+
selectModifier,
|
|
27
|
+
type InputEventOption,
|
|
28
|
+
type Modifier,
|
|
29
|
+
type ModifierMap,
|
|
30
|
+
type ModifierState,
|
|
31
|
+
type ModifierValue,
|
|
32
|
+
} from './input/modifiers'
|
|
16
33
|
export {
|
|
17
34
|
createMIDIAccess,
|
|
18
35
|
NOT_SUPPORTED,
|
|
19
36
|
PERMISSION_DENIED,
|
|
37
|
+
UNAVAILABLE,
|
|
20
38
|
type MIDIAccessError,
|
|
21
39
|
type MIDIAccessInstance,
|
|
40
|
+
type MIDIAccessOptions,
|
|
22
41
|
type MIDIAccessState,
|
|
23
42
|
} from './midi/access'
|
|
24
43
|
export {
|
|
25
44
|
createMIDIInput,
|
|
45
|
+
PITCH_BEND_CENTER,
|
|
26
46
|
type MIDIInputHandlers,
|
|
27
47
|
type MIDIInputInstance,
|
|
28
48
|
} from './midi/input'
|
|
29
49
|
export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
|
|
50
|
+
export {
|
|
51
|
+
blackKeyWidth,
|
|
52
|
+
getNoteRangeArray,
|
|
53
|
+
noteAt,
|
|
54
|
+
notePosition,
|
|
55
|
+
pianoWidth,
|
|
56
|
+
type NoteRange,
|
|
57
|
+
type PianoLayout,
|
|
58
|
+
} from './piano/layout'
|
|
30
59
|
export {
|
|
31
60
|
createPianoInput,
|
|
32
61
|
type NoteSource,
|
|
33
62
|
type PianoInputInstance,
|
|
34
63
|
type PianoInputOptions,
|
|
35
|
-
} from './piano
|
|
64
|
+
} from './piano'
|
|
36
65
|
export {
|
|
37
66
|
createDrag,
|
|
38
67
|
type DragInstance,
|
|
@@ -48,10 +77,18 @@ export {
|
|
|
48
77
|
type DragValueMapping,
|
|
49
78
|
type DragValueOptions,
|
|
50
79
|
type MappingContext,
|
|
51
|
-
} from './pointer/
|
|
80
|
+
} from './pointer/drag-value'
|
|
52
81
|
export {
|
|
53
82
|
createWheel,
|
|
54
83
|
type WheelInstance,
|
|
55
84
|
type WheelOptions,
|
|
56
85
|
} from './pointer/wheel'
|
|
86
|
+
export {
|
|
87
|
+
createSelectionBox,
|
|
88
|
+
selectionBoxCovers,
|
|
89
|
+
type SelectionBoxBeginOptions,
|
|
90
|
+
type SelectionBoxInstance,
|
|
91
|
+
type SelectionBoxOptions,
|
|
92
|
+
type SelectionBoxRect,
|
|
93
|
+
} from './selection/box'
|
|
57
94
|
export { toXY, type XY, type XYInput } from './xy'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clamp,
|
|
3
|
+
linearScale,
|
|
4
|
+
stepValue,
|
|
5
|
+
toPrecision,
|
|
6
|
+
type ValueRange,
|
|
7
|
+
} from '@tremolo-ui/functions'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
selectModifier,
|
|
11
|
+
type InputEventOption,
|
|
12
|
+
type ModifierState,
|
|
13
|
+
type ModifierValue,
|
|
14
|
+
} from './modifiers'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Move a value by an amount of input, as reported by a wheel or an arrow key.
|
|
18
|
+
*
|
|
19
|
+
* The pipeline matches {@link createDragValue}: scale, then step, then clamp.
|
|
20
|
+
* Which key or which sign of `deltaY` counts as which direction is left to the
|
|
21
|
+
* caller, since it differs per component.
|
|
22
|
+
*
|
|
23
|
+
* @param direction which way, and how many times, to apply the option. The
|
|
24
|
+
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
25
|
+
*
|
|
26
|
+
* @param modifiers the event, for `options` that name a modifier key. See
|
|
27
|
+
* {@link selectModifier}.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
31
|
+
* applyDelta(value, -1, keyboard, { min, max, step, scale })
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
|
|
35
|
+
* applyDelta(value, -1, keyboard, range, event)
|
|
36
|
+
*/
|
|
37
|
+
export function applyDelta(
|
|
38
|
+
value: number,
|
|
39
|
+
direction: number,
|
|
40
|
+
options: ModifierValue<InputEventOption>,
|
|
41
|
+
{ min, max, step, scale = linearScale }: ValueRange,
|
|
42
|
+
modifiers?: ModifierState,
|
|
43
|
+
): number {
|
|
44
|
+
if (min >= max) throw new RangeError('requirements: min < max')
|
|
45
|
+
if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
'applyDelta step: requirements: finite and greater than 0',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
value: [mode, amount],
|
|
53
|
+
modifier,
|
|
54
|
+
} = selectModifier(options, modifiers)
|
|
55
|
+
|
|
56
|
+
const x = direction * amount
|
|
57
|
+
const next =
|
|
58
|
+
mode === 'normalized'
|
|
59
|
+
? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
|
|
60
|
+
: value + x
|
|
61
|
+
|
|
62
|
+
// Naming a modifier is a deliberate request to move off the grid, so `step`
|
|
63
|
+
// does not apply to it. Without this a finer amount would round straight
|
|
64
|
+
// back to where it started: `stepValue(3 + 0.1, 1)` is 3.
|
|
65
|
+
const quantum = modifier === null ? step : undefined
|
|
66
|
+
const stepped = quantum !== undefined ? stepValue(next, quantum) : next
|
|
67
|
+
|
|
68
|
+
// Rounded before the clamp, so that `min` and `max` still have the last
|
|
69
|
+
// word and the value can land on them exactly. Without this the artefact
|
|
70
|
+
// accumulates: with no `step` to round it back, twelve presses of a 0.1
|
|
71
|
+
// modifier amount reach 5.699999999999998 rather than 5.7.
|
|
72
|
+
return clamp(toPrecision(stepped), min, max)
|
|
73
|
+
}
|