@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.
@@ -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 `relativeSize` is on.
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
- relativeSize?: boolean
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
- * `relativeSize` and `contextAttributes` are fixed for the lifetime of the
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 relativeSize = opts.relativeSize ?? false
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(): DrawingContext | null {
150
- if (!(opts.reduceFlickering ?? true)) return null
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 null
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 state
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
- state: DrawingContext | null,
175
+ hasSnapshot: boolean,
176
176
  previousWidth: number,
177
177
  previousHeight: number,
178
178
  ) {
179
- if (!state || !memo || !memoContext) return
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 state = takeSnapshot()
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
- restoreSnapshot(state, previousWidth, previousHeight)
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 drawFrame() {
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 now = performance.now()
204
- const deltaTime = now - previousTime
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: now - startTime,
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 != null) return
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 == null) return
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 (relativeSize) {
253
+ if (resizable) {
239
254
  const parent = canvas.parentElement
240
255
  if (!parent) {
241
256
  throw new Error(
242
- 'createAnimationCanvas: relativeSize needs the canvas to have a parent element',
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
- applySize(w, h)
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
- relativeSize,
292
+ resizable,
276
293
  contextAttributes: opts.contextAttributes,
277
294
  }
278
295
 
279
- if (!relativeSize) {
296
+ if (!resizable) {
280
297
  const { width: w = 100, height: h = 100 } = opts.size ?? {}
281
- if (w != width || h != height) applySize(w, h)
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()
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The properties of a 2D context that survive `save()` / `restore()`, and so
3
- * are what has to be carried across a resize by hand: setting `canvas.width`
4
- * resets the context to its defaults.
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 == 'string' && names.includes(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/input'
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/dragValue'
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
+ }