@tremolo-ui/dom 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/index.cjs +343 -595
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +230 -284
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +230 -284
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +325 -572
  8. package/dist/index.js.map +1 -1
  9. package/dist/internal.cjs +530 -0
  10. package/dist/internal.cjs.map +1 -0
  11. package/dist/internal.d.cts +365 -0
  12. package/dist/internal.d.cts.map +1 -0
  13. package/dist/internal.d.ts +365 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +500 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/marks-DK7xJmGM.d.cts +305 -0
  18. package/dist/marks-DK7xJmGM.d.cts.map +1 -0
  19. package/dist/marks-DK7xJmGM.d.ts +305 -0
  20. package/dist/marks-DK7xJmGM.d.ts.map +1 -0
  21. package/dist/xy-5Oc6JeJr.cjs +952 -0
  22. package/dist/xy-5Oc6JeJr.cjs.map +1 -0
  23. package/dist/xy-lNFl1CTO.js +827 -0
  24. package/dist/xy-lNFl1CTO.js.map +1 -0
  25. package/package.json +12 -2
  26. package/src/canvas/animation.ts +8 -0
  27. package/src/canvas/context.ts +0 -5
  28. package/src/file/accept.ts +17 -0
  29. package/src/file/drop-zone.ts +8 -10
  30. package/src/index.ts +44 -32
  31. package/src/input/change-gesture.ts +104 -0
  32. package/src/input/check-steps.ts +144 -0
  33. package/src/input/defaults.ts +32 -0
  34. package/src/input/direction.ts +107 -0
  35. package/src/internal.ts +51 -0
  36. package/src/knob/geometry.ts +100 -0
  37. package/src/midi/access.ts +16 -15
  38. package/src/midi/input.ts +2 -9
  39. package/src/number-input/stepper-drag.ts +147 -0
  40. package/src/number-input/text.ts +100 -0
  41. package/src/number-input/value.ts +129 -0
  42. package/src/options/replace.ts +22 -0
  43. package/src/piano/index.ts +128 -2
  44. package/src/piano/layout.ts +19 -0
  45. package/src/piano/shortcuts.ts +86 -0
  46. package/src/pointer/drag-value.ts +3 -1
  47. package/src/pointer/long-press.ts +103 -0
  48. package/src/pointer/wheel.ts +10 -7
  49. package/src/points-editor/index.ts +367 -0
  50. package/src/position.ts +20 -0
  51. package/src/slider/decimal-digits.ts +23 -0
  52. package/src/slider/marks.ts +56 -0
  53. package/src/style.ts +41 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xy-lNFl1CTO.js","names":["pointerEvent"],"sources":["../src/file/accept.ts","../src/input/defaults.ts","../src/piano/layout.ts","../src/pointer/drag.ts","../src/input/modifiers.ts","../src/input/apply-delta.ts","../src/selection/box.ts","../src/points-editor/index.ts","../src/xy.ts"],"sourcesContent":["/**\n * What an `accept` rule is matched against.\n *\n * `File` satisfies it, and so does `DataTransferItem` — which matters while a\n * drag is still in the air, since the browser reports the type of what is\n * being dragged but withholds the name.\n */\nexport interface AcceptCandidate {\n /** The file name, when it is known. */\n name?: string\n /** The MIME type, or `''` when the browser has no type for it. */\n type: string\n}\n\n/**\n * Does a file satisfy an `accept` attribute?\n *\n * `accept` is written the way the HTML attribute is: a comma separated list of\n * extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).\n * Anything that matches one entry is accepted, and an empty or missing\n * `accept` takes everything.\n *\n * **The browser's own `accept` is only a hint to the file picker.** A person\n * can switch it to \"All Files\", drag a file in, or pick one the picker was\n * never asked about, so what arrives still has to be checked.\n *\n * A rule that cannot be decided is not treated as a rejection: with no `name`,\n * an extension rule says nothing either way, and `accept=\".wav\"` reports a\n * match rather than refusing a file it has not seen the name of. The name is\n * there by the time the file is dropped, which is when the answer counts.\n */\nexport function matchesAccept(\n candidate: AcceptCandidate,\n accept?: string,\n): boolean {\n const rules = (accept ?? '')\n .split(',')\n .map((rule) => rule.trim().toLowerCase())\n .filter(Boolean)\n if (rules.length === 0) return true\n\n const name = candidate.name?.toLowerCase()\n const type = candidate.type.toLowerCase()\n let undecided = false\n\n for (const rule of rules) {\n if (rule.startsWith('.')) {\n if (name === undefined) undecided = true\n else if (name.endsWith(rule)) return true\n } else if (rule.endsWith('/*')) {\n if (type.startsWith(rule.slice(0, -1))) return true\n } else if (type === rule) {\n return true\n }\n }\n\n return undecided\n}\n\n/**\n * Split files into those that satisfy `accept` and those that do not, keeping\n * their order. See {@link matchesAccept}.\n */\nexport function partitionByAccept(\n files: Iterable<File>,\n accept?: string,\n): { accepted: File[]; rejected: File[] } {\n const accepted: File[] = []\n const rejected: File[] = []\n for (const file of files) {\n if (matchesAccept(file, accept)) accepted.push(file)\n else rejected.push(file)\n }\n return { accepted, rejected }\n}\n","import { type InputEventOption, type ModifierValue } from './modifiers'\n\n/**\n * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:\n * 1 per press in the units of the value, and 0.1 with shift held. That is one\n * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which\n * `checkSteps` warns about.\n *\n * A modifier entry is not snapped to `step`, which is what lets the finer\n * amount move at all — see `applyDelta`.\n */\nexport const DEFAULT_KEYBOARD_OPTIONS: ModifierValue<InputEventOption> = {\n default: ['raw', 1],\n shift: ['raw', 0.1],\n}\n\n/**\n * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.\n *\n * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`\n * and fills `deltaX`, so no modifier is bound here.\n */\nexport const DEFAULT_WHEEL_OPTIONS: ModifierValue<InputEventOption> = ['raw', 1]\n\n/**\n * The drag sensitivity used by value controls by default. Shift makes the\n * same movement cover a tenth of the range, matching the arrow keys.\n */\nexport const DEFAULT_DRAG_SENSITIVITY: ModifierValue<number> = {\n default: 1,\n shift: 0.1,\n}\n","import {\n isBlackKey,\n isWhiteKey,\n noteKey,\n noteKeys,\n type NoteKey,\n} from '@tremolo-ui/functions'\n\nexport type NoteRange = {\n first: number\n last: number\n}\n\n/**\n * `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`\n */\nexport function getNoteRangeArray(noteRange: NoteRange): number[] {\n return Array.from(\n { length: noteRange.last - noteRange.first + 1 },\n (_, i) => i + noteRange.first,\n )\n}\n\n/**\n * The geometry of a drawn keyboard.\n *\n * One description is shared by the drawing and the hit testing, so a key cannot\n * be drawn somewhere other than where it responds.\n */\nexport interface PianoLayout {\n noteRange: NoteRange\n\n /** Width of a white key, excluding {@link PianoLayout.keyGap}. */\n whiteKeyWidth: number\n\n /**\n * Space between two white keys. Part of the slot a white key occupies, so it\n * still belongs to one of the keys for the purpose of hit testing.\n *\n * @default 1\n */\n keyGap?: number\n\n /**\n * Width of a black key, as a fraction of {@link PianoLayout.whiteKeyWidth}.\n *\n * @default 0.65\n */\n blackKeyWidthRatio?: number\n\n /**\n * Height of a black key, as a fraction of the height of the keyboard.\n *\n * @default 0.6\n */\n blackKeyHeightRatio?: number\n}\n\nconst DEFAULT_KEY_GAP = 1\nconst DEFAULT_BLACK_KEY_WIDTH_RATIO = 0.65\nconst DEFAULT_BLACK_KEY_HEIGHT_RATIO = 0.6\n\n/**\n * How many white keys sit at or before each pitch class, counting from C.\n *\n * A black key shares the number of the white key to its left plus one, which\n * puts it on the boundary between the two; {@link notePosition} then shifts it\n * back by half its width to centre it there.\n */\nconst whiteKeysBefore: Record<NoteKey, number> = {\n C: 0,\n 'C#': 1,\n D: 1,\n 'D#': 2,\n E: 2,\n F: 3,\n 'F#': 4,\n G: 4,\n 'G#': 5,\n A: 5,\n 'A#': 6,\n B: 6,\n}\n\n/** Width of a black key in pixels. */\nexport function blackKeyWidth(layout: PianoLayout): number {\n return (\n layout.whiteKeyWidth *\n (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO)\n )\n}\n\nfunction rawNotePosition(note: number, layout: PianoLayout): number {\n const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)\n const target = noteKey(note)\n const first = noteKey(layout.noteRange.first)\n\n const octave = Math.floor((note - layout.noteRange.first) / 12)\n const octaveOffset =\n noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0\n const whiteKeysIn =\n whiteKeysBefore[target] -\n whiteKeysBefore[first] +\n (octave + octaveOffset) * 7\n\n return isBlackKey(note)\n ? whiteKeysIn * slot - blackKeyWidth(layout) / 2\n : whiteKeysIn * slot\n}\n\nfunction pianoBounds(layout: PianoLayout) {\n const notes = getNoteRangeArray(layout.noteRange)\n if (notes.length === 0) return { left: 0, right: 0 }\n\n let left = Infinity\n let right = -Infinity\n const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)\n for (const note of notes) {\n const noteLeft = rawNotePosition(note, layout)\n const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth\n left = Math.min(left, noteLeft)\n right = Math.max(right, noteLeft + width)\n }\n return { left, right }\n}\n\n/** Width of the whole keyboard in pixels. */\nexport function pianoWidth(layout: PianoLayout): number {\n const { left, right } = pianoBounds(layout)\n return right - left\n}\n\n/**\n * Offset of the left edge of a key from the left edge of the keyboard, in\n * pixels.\n *\n * Notes outside `noteRange` are placed too, so the value is negative below\n * `noteRange.first`.\n */\nexport function notePosition(note: number, layout: PianoLayout): number {\n return rawNotePosition(note, layout) - pianoBounds(layout).left\n}\n\n/**\n * The note drawn at a point, or null where there is none.\n *\n * Black keys are tested first, so they win where they overlap a white one. A\n * white key covers its gap as well as its width, so the whole width of the\n * keyboard belongs to some key and a click cannot fall between two.\n *\n * @param x offset from the left edge of the keyboard, in pixels\n * @param y offset from its top edge, in pixels\n * @param height height of the keyboard, in pixels\n */\nexport function noteAt(\n x: number,\n y: number,\n height: number,\n layout: PianoLayout,\n): number | null {\n if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null\n\n const notes = getNoteRangeArray(layout.noteRange)\n const blackHeight =\n height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)\n\n if (y < blackHeight) {\n for (const note of notes) {\n if (isWhiteKey(note)) continue\n const left = notePosition(note, layout)\n if (left <= x && x < left + blackKeyWidth(layout)) return note\n }\n }\n\n const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)\n for (const note of notes) {\n if (isBlackKey(note)) continue\n const left = notePosition(note, layout)\n if (left <= x && x < left + slot) return note\n }\n\n return null\n}\n\n/**\n * The white key width that makes the keyboard exactly `width` wide, for a\n * keyboard that follows the size of its container.\n *\n * Solved from {@link pianoWidth} rather than by dividing among the white\n * keys, so that a range that starts or ends on a black key — which sticks out\n * by a fraction of a white key — still fills the container. The width grows\n * linearly with the white key width, so two samples pin it down.\n */\nexport function fitWhiteKeyWidth(\n width: number,\n layout: Omit<PianoLayout, 'whiteKeyWidth'>,\n): number {\n const at = (whiteKeyWidth: number) => pianoWidth({ ...layout, whiteKeyWidth })\n const slope = at(2) - at(1)\n if (slope <= 0) return 0\n return (width - (at(1) - slope)) / slope\n}\n","export type DragState = {\n /** Total movement from the drag start, in screen coordinates. */\n x: number\n y: number\n /** Movement since the previous event, in screen coordinates. */\n deltaX: number\n deltaY: number\n /** Pointer position in viewport coordinates. */\n clientX: number\n clientY: number\n /**\n * Which pointer this is. Always the same value for one drag; with\n * {@link DragOptions.multiPointer} it tells concurrent drags apart.\n */\n pointerId: number\n event: PointerEvent\n}\n\nexport type DragOptions = {\n /**\n * Minimum movement in pixels before `onDrag` fires. Movement below it is\n * carried over to the next event rather than discarded, so a slow drag still\n * reports once it adds up.\n *\n * Prevents `onDrag` from firing on, for example, a double click.\n *\n * @default 0\n */\n threshold?: number\n\n /**\n * CSS cursor to show while dragging. Applied to the element itself: pointer\n * capture keeps it in effect even once the pointer leaves the element, so\n * there is no need to touch the document.\n *\n * With {@link DragOptions.multiPointer} it is set for the first pointer and\n * restored once the last one is up.\n */\n cursor?: string\n\n /**\n * Decide whether a pointerdown starts a drag at all.\n *\n * Checked before anything else — **before the pointer is captured** — so\n * declining here leaves the whole gesture to whatever else is listening.\n * Deciding later would be too late: the capture has already been taken from\n * the element that was going to handle it.\n *\n * The use for it is a drag on a container that also holds draggable things\n * of its own, such as a rubber-band selection that must not begin on top of\n * one of the objects it would select.\n */\n shouldStart?: (event: PointerEvent) => boolean\n\n /**\n * Hide the pointer and read its movement directly, instead of following it\n * around the screen.\n *\n * A relative drag — a knob, a stepper — does not care where the pointer is,\n * only how far it moved, and letting it wander has two costs: the cursor\n * ends up far from what it is holding, and **the drag stops at the edge of\n * the screen**, where the operating system pins the pointer and the\n * coordinates stop changing. A fine drag reaches that edge quickly.\n *\n * **Not for a drag whose value is the position pointed at** — anything on\n * `elementMapping`. `clientX` / `clientY` freeze while the pointer is\n * locked, so there is no position left to read.\n *\n * The request needs a user gesture, which a pointerdown is, but it can still\n * be refused; the drag then carries on as an ordinary one. Read on\n * pointerdown, so `update()` reaches the next drag rather than the current.\n *\n * @default false\n */\n pointerLock?: boolean\n\n /**\n * Track every pointer that goes down on the element, rather than only the\n * first. Each one gets its own `onDragStart` / `onDrag` / `onDragEnd` and\n * carries its own totals; {@link DragState.pointerId} says which is which.\n *\n * Fixed for the lifetime of the instance: switching part way through a drag\n * has no meaning, so `update()` ignores it.\n *\n * @default false\n */\n multiPointer?: boolean\n\n onDragStart?: (state: DragState) => void\n onDrag?: (state: DragState) => void\n /**\n * Called exactly once for every drag that starts, whether tracking ends by\n * pointer release, cancellation, capture or lock loss, or destruction.\n */\n onDragEnd?: (state: DragState) => void\n}\n\nexport interface DragInstance {\n /**\n * Replace the given options. Lets a wrapper feed fresh handlers in without\n * tearing down the listeners, which would abort a drag in progress.\n *\n * `multiPointer` is fixed for the lifetime of the instance and is ignored\n * here.\n */\n update: (options: DragOptions) => void\n /** End any active drags before removing the instance. */\n destroy: () => void\n}\n\n/**\n * Applied to the element for the lifetime of the instance.\n *\n * `touch-action` stops touch dragging from scrolling the page. That alone makes\n * the browser treat a long press as the start of a text selection, so selection\n * and the iOS callout are suppressed here too.\n */\nconst MANAGED_STYLES = [\n ['touch-action', 'none'],\n ['user-select', 'none'],\n ['-webkit-user-select', 'none'],\n ['-webkit-touch-callout', 'none'],\n] as const\n\ntype ManagedStyleState = {\n count: number\n previous: Map<string, { value: string; priority: string }>\n}\n\nconst managedStyleStates = new WeakMap<Element, ManagedStyleState>()\n\ntype CaptureTarget = {\n requestPointerLock?: () => unknown\n setPointerCapture?: (pointerId: number) => void\n releasePointerCapture?: (pointerId: number) => void\n hasPointerCapture?: (pointerId: number) => boolean\n}\n\n/** What one pointer needs to report its own movement. */\ntype PointerState = {\n /** Where the listeners for this pointer live. */\n moveTarget: EventTarget\n startX: number\n startY: number\n lastX: number\n lastY: number\n /**\n * Where the travel stood when the pointer lock took effect, and how far it\n * has moved since. Screen coordinates stop changing under the lock, so from\n * that point the movement of each event is added up instead.\n *\n * The base is taken when the lock engages rather than on pointerdown: the\n * request is asynchronous, and whatever movement happened while it was in\n * flight was measured the ordinary way.\n */\n lockBaseX?: number\n lockBaseY?: number\n lockMoveX: number\n lockMoveY: number\n /** The most recent event, to end the drag with when the lock is lost. */\n lastEvent: PointerEvent\n}\n\n/**\n * Track a pointer drag on an element.\n *\n * Uses Pointer Events only, and pointer capture so that the drag keeps working\n * once the pointer leaves the element. For the lifetime of the instance the\n * element gets `touch-action: none` so that touch dragging does not scroll the\n * page, plus `user-select: none` so that a long press does not start a text\n * selection instead.\n *\n * One pointer at a time by default; see {@link DragOptions.multiPointer}.\n */\nexport function createDrag(\n element: Element,\n options: DragOptions = {},\n): DragInstance {\n let opts = options\n const multiPointer = options.multiPointer ?? false\n const capture = element as CaptureTarget\n const style = (element as Partial<HTMLElement>).style\n\n let managedStyles = managedStyleStates.get(element)\n if (style) {\n if (managedStyles) {\n managedStyles.count += 1\n } else {\n const previous = new Map<string, { value: string; priority: string }>()\n for (const [property, value] of MANAGED_STYLES) {\n previous.set(property, {\n value: style.getPropertyValue(property),\n priority: style.getPropertyPriority(property),\n })\n style.setProperty(property, value)\n }\n managedStyles = { count: 1, previous }\n managedStyleStates.set(element, managedStyles)\n }\n }\n\n const pointers = new Map<number, PointerState>()\n /**\n * How many pointers each target carries. Adding the same listener twice is a\n * no-op and removing it once removes it for good, so a target is subscribed\n * to while at least one pointer is on it and no longer.\n */\n const targets = new Map<EventTarget, number>()\n let previousCursor: string | undefined\n /** The pointer that asked for the lock, while it is still down. */\n let lockedPointerId: number | null = null\n let lockRequest = 0\n let destroyed = false\n\n function state(\n event: PointerEvent,\n pointer: PointerState,\n deltaX: number,\n deltaY: number,\n ): DragState {\n return {\n x:\n pointer.lockBaseX !== undefined\n ? pointer.lockBaseX + pointer.lockMoveX\n : event.screenX - pointer.startX,\n y:\n pointer.lockBaseY !== undefined\n ? pointer.lockBaseY + pointer.lockMoveY\n : event.screenY - pointer.startY,\n deltaX,\n deltaY,\n clientX: event.clientX,\n clientY: event.clientY,\n pointerId: event.pointerId,\n event,\n }\n }\n\n /**\n * `user-select: none` only makes the element's own text unselectable; the\n * browser still starts a selection on long press and grabs whatever text it\n * finds nearby. Cancelling the selection outright is what actually stops it.\n */\n function preventSelectStart(event: Event) {\n event.preventDefault()\n }\n\n function retainTarget(target: EventTarget) {\n const count = targets.get(target) ?? 0\n if (count === 0) {\n target.addEventListener('pointermove', handlePointerMove)\n target.addEventListener('pointerup', handlePointerUp)\n target.addEventListener('pointercancel', handlePointerUp)\n if (target === element) {\n target.addEventListener('lostpointercapture', handleLostPointerCapture)\n }\n }\n targets.set(target, count + 1)\n }\n\n function releaseTarget(target: EventTarget) {\n const count = targets.get(target) ?? 0\n if (count > 1) {\n targets.set(target, count - 1)\n return\n }\n target.removeEventListener('pointermove', handlePointerMove)\n target.removeEventListener('pointerup', handlePointerUp)\n target.removeEventListener('pointercancel', handlePointerUp)\n if (target === element) {\n target.removeEventListener('lostpointercapture', handleLostPointerCapture)\n }\n targets.delete(target)\n }\n\n function handlePointerDown(event: Event) {\n const pointerEvent = event as PointerEvent\n if (pointerEvent.button !== 0) return\n const pointerId = pointerEvent.pointerId\n // Without multiPointer only one pointer drives the drag; ignore the rest.\n if (pointers.has(pointerId)) return\n if (!multiPointer && pointers.size > 0) return\n\n if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return\n\n const isFirst = pointers.size === 0\n if (isFirst && opts.cursor && style) {\n previousCursor = style.cursor\n style.cursor = opts.cursor\n }\n\n capture.setPointerCapture?.(pointerId)\n // With pointer capture the element receives the rest of the gesture.\n // Without it (older engines, jsdom) fall back to the window.\n const moveTarget =\n capture.hasPointerCapture?.(pointerId) === true\n ? element\n : (globalThis.window ?? element)\n\n const pointer: PointerState = {\n moveTarget,\n startX: pointerEvent.screenX,\n startY: pointerEvent.screenY,\n lastX: pointerEvent.screenX,\n lastY: pointerEvent.screenY,\n lockMoveX: 0,\n lockMoveY: 0,\n lastEvent: pointerEvent,\n }\n pointers.set(pointerId, pointer)\n\n retainTarget(moveTarget)\n if (isFirst) {\n globalThis.document?.addEventListener('selectstart', preventSelectStart)\n }\n\n // One pointer can be locked, so the first one takes it.\n if (isFirst && opts.pointerLock) {\n const requestId = ++lockRequest\n lockedPointerId = pointerId\n globalThis.document?.addEventListener(\n 'pointerlockchange',\n handleLockChange,\n )\n try {\n // Newer engines return a promise that rejects; older ones fire\n // `pointerlockerror` instead. Either way a refusal only means the drag\n // stays an ordinary one, so nothing here has to act on it.\n const request = capture.requestPointerLock?.() as\n | Promise<void>\n | undefined\n request?.then?.(\n () => {\n if (\n requestId === lockRequest &&\n (!pointers.has(pointerId) || destroyed) &&\n globalThis.document?.pointerLockElement === element\n ) {\n globalThis.document.exitPointerLock?.()\n }\n },\n () => {},\n )\n } catch {\n // requestPointerLock threw synchronously; same story.\n }\n }\n\n opts.onDragStart?.(state(pointerEvent, pointer, 0, 0))\n }\n\n function handlePointerMove(event: Event) {\n const pointerEvent = event as PointerEvent\n const pointer = pointers.get(pointerEvent.pointerId)\n if (!pointer) return\n\n // Under the lock the screen position no longer moves, so the movement the\n // event reports is the only thing left to read.\n const locked = pointer.lockBaseX !== undefined\n const deltaX = locked\n ? (pointerEvent.movementX ?? 0)\n : pointerEvent.screenX - pointer.lastX\n const deltaY = locked\n ? (pointerEvent.movementY ?? 0)\n : pointerEvent.screenY - pointer.lastY\n\n // Movement below the threshold accumulates until it crosses it. Dropping it\n // instead would swallow a slow drag entirely: pointer coordinates are\n // fractional, so each event can move less than a pixel and never report.\n const threshold = opts.threshold ?? 0\n if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return\n\n pointer.lastX = pointerEvent.screenX\n pointer.lastY = pointerEvent.screenY\n pointer.lastEvent = pointerEvent\n if (locked) {\n pointer.lockMoveX += deltaX\n pointer.lockMoveY += deltaY\n }\n\n opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY))\n }\n\n function handleLockChange() {\n if (lockedPointerId === null) return\n const pointer = pointers.get(lockedPointerId)\n if (!pointer) return\n\n if (globalThis.document?.pointerLockElement === element) {\n // Engaged. Whatever moved while the request was in flight was measured\n // the ordinary way, so the travel so far becomes the base and the\n // per-event movement is added to it from here.\n pointer.lockBaseX = pointer.lastX - pointer.startX\n pointer.lockBaseY = pointer.lastY - pointer.startY\n pointer.lockMoveX = 0\n pointer.lockMoveY = 0\n return\n }\n\n // Lost without the pointer coming up: Esc, a tab switch, leaving\n // fullscreen. No pointerup is coming, so the drag ends here rather than\n // hanging on with a pointer nobody can see.\n if (pointer.lockBaseX === undefined) return\n finishDrag(lockedPointerId)\n }\n\n function handlePointerUp(event: Event) {\n const pointerEvent = event as PointerEvent\n finishDrag(pointerEvent.pointerId, pointerEvent)\n }\n\n function handleLostPointerCapture(event: Event) {\n const pointerEvent = event as PointerEvent\n finishDrag(pointerEvent.pointerId)\n }\n\n function finishDrag(pointerId: number, event?: PointerEvent) {\n const pointer = pointers.get(pointerId)\n if (!pointer) return\n\n const finalState = event\n ? state(\n event,\n pointer,\n event.screenX - pointer.lastX,\n event.screenY - pointer.lastY,\n )\n : state(pointer.lastEvent, pointer, 0, 0)\n\n stopTracking(pointerId)\n opts.onDragEnd?.(finalState)\n }\n\n function stopTracking(pointerId: number) {\n const pointer = pointers.get(pointerId)\n if (!pointer) return\n\n pointers.delete(pointerId)\n releaseTarget(pointer.moveTarget)\n try {\n if (capture.hasPointerCapture?.(pointerId) === true) {\n capture.releasePointerCapture?.(pointerId)\n }\n } catch {\n // The capture may disappear between checking and releasing it. Tracking\n // is already cleared, so the drag still ends normally.\n }\n\n if (lockedPointerId === pointerId) {\n lockedPointerId = null\n const document = globalThis.document\n document?.removeEventListener('pointerlockchange', handleLockChange)\n // Already gone when the lock is what ended the drag.\n if (document?.pointerLockElement === element) document.exitPointerLock?.()\n }\n\n if (pointers.size > 0) return\n\n globalThis.document?.removeEventListener('selectstart', preventSelectStart)\n if (previousCursor !== undefined && style) {\n style.cursor = previousCursor\n previousCursor = undefined\n }\n }\n\n element.addEventListener('pointerdown', handlePointerDown)\n\n return {\n update: (next) => {\n opts = { ...opts, ...next, multiPointer }\n },\n destroy: () => {\n if (destroyed) return\n destroyed = true\n element.removeEventListener('pointerdown', handlePointerDown)\n for (const pointerId of [...pointers.keys()]) finishDrag(pointerId)\n if (style && managedStyles && --managedStyles.count === 0) {\n for (const [property] of MANAGED_STYLES) {\n const previous = managedStyles.previous.get(property)\n if (previous?.value) {\n style.setProperty(property, previous.value, previous.priority)\n } else {\n style.removeProperty(property)\n }\n }\n managedStyleStates.delete(element)\n }\n },\n }\n}\n","/**\n * Options for setting the amount of keyboard and mouse wheel changes.\n */\nexport type InputEventOption = readonly ['normalized' | 'raw', number]\n\n/**\n * A modifier key that can carry an amount of its own.\n *\n * `ctrl` and `meta` are kept apart rather than folded into one \"command\" key:\n * a plugin UI that mirrors a desktop host usually wants the same physical key\n * on every platform, not the platform's own convention.\n */\nexport type Modifier = 'shift' | 'alt' | 'ctrl' | 'meta'\n\n/** The modifier flags of a `WheelEvent` or a `KeyboardEvent`. */\nexport interface ModifierState {\n shiftKey: boolean\n altKey: boolean\n ctrlKey: boolean\n metaKey: boolean\n}\n\n/** One setting per modifier key, with `default` for none of them. */\ntype ModifierSetting = number | InputEventOption\n\nexport type ModifierMap<T extends ModifierSetting> = { default: T } & Partial<\n Record<Modifier, T>\n>\n\n/**\n * A single setting, or one per modifier key.\n *\n * @example\n * ['raw', 1]\n * { default: ['raw', 1], shift: ['raw', 0.1] }\n */\nexport type ModifierValue<T extends ModifierSetting> = T | ModifierMap<T>\n\n/**\n * Checked in this order, and the first one that is both held and configured\n * wins. Fixing an order is what keeps two modifiers held at once from\n * behaving differently between browsers.\n */\nconst MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const\n\nconst MODIFIER_FLAG = {\n meta: 'metaKey',\n ctrl: 'ctrlKey',\n alt: 'altKey',\n shift: 'shiftKey',\n} as const satisfies Record<Modifier, keyof ModifierState>\n\n/**\n * A map is the only form with a `default` key, which is what tells it apart\n * from a bare setting. Tuples are arrays, so they never match.\n */\nfunction isModifierMap<T extends ModifierSetting>(\n value: ModifierValue<T>,\n): value is ModifierMap<T> {\n return (\n typeof value === 'object' &&\n value !== null &&\n !Array.isArray(value) &&\n 'default' in value\n )\n}\n\n/**\n * Pick the setting that applies, given the modifier keys being held.\n *\n * @example\n * selectModifier({ default: 1, shift: 0.1 }, event)\n */\nexport function selectModifier<T extends ModifierSetting>(\n options: ModifierValue<T>,\n modifiers?: ModifierState,\n): { value: T; modifier: Modifier | null } {\n if (!isModifierMap(options)) {\n // TypeScript cannot subtract the map from `ModifierValue<T>` while `T` is\n // still a type parameter, so the other half has to be spelled out.\n return { value: options as T, modifier: null }\n }\n if (modifiers) {\n for (const modifier of MODIFIER_ORDER) {\n const value = options[modifier]\n // Compared against undefined rather than checked for truthiness: 0 is a\n // legitimate setting.\n if (value !== undefined && modifiers[MODIFIER_FLAG[modifier]]) {\n return { value, modifier }\n }\n }\n }\n return { value: options.default, modifier: null }\n}\n\n/**\n * Turn every entry of a setting into another kind of setting, keeping which\n * modifier each belongs to.\n *\n * A drag sensitivity is a number and a keyboard amount is a tuple, but the two\n * describe the same thing from the caller's side. This carries one over to the\n * other so that a component can hand a sensitivity to {@link applyDelta}\n * without unpicking the modifier map itself — which matters, since naming a\n * modifier is also what takes `step` out of the pipeline.\n *\n * @example\n * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])\n * // { default: ['raw', 1], shift: ['raw', 0.1] }\n */\nexport function mapModifier<\n T extends ModifierSetting,\n U extends ModifierSetting,\n>(options: ModifierValue<T>, fn: (value: T) => U): ModifierValue<U> {\n if (!isModifierMap(options)) return fn(options as T)\n const mapped = { default: fn(options.default) } as ModifierMap<U>\n for (const modifier of MODIFIER_ORDER) {\n const value = options[modifier]\n if (value !== undefined) mapped[modifier] = fn(value)\n }\n return mapped\n}\n","import {\n clamp,\n linearScale,\n stepValue,\n toPrecision,\n type ValueRange,\n} from '@tremolo-ui/functions'\n\nimport {\n selectModifier,\n type InputEventOption,\n type ModifierState,\n type ModifierValue,\n} from './modifiers'\n\n/**\n * Move a value by an amount of input, as reported by a wheel or an arrow key.\n *\n * The pipeline matches {@link createDragValue}: scale, then step, then clamp.\n * Which key or which sign of `deltaY` counts as which direction is left to the\n * caller, since it differs per component.\n *\n * @param direction which way, and how many times, to apply the option. The\n * size of one step is `option[1]`, so this is normally `1` or `-1`.\n *\n * @param modifiers the event, for `options` that name a modifier key. See\n * {@link selectModifier}.\n *\n * @example\n * // ArrowDown on a slider whose keyboard option is ['raw', 1]\n * applyDelta(value, -1, keyboard, { min, max, step, scale })\n *\n * @example\n * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }\n * applyDelta(value, -1, keyboard, range, event)\n */\nexport function applyDelta(\n value: number,\n direction: number,\n options: ModifierValue<InputEventOption>,\n { min, max, step, scale = linearScale }: ValueRange,\n modifiers?: ModifierState,\n): number {\n if (min >= max) throw new RangeError('requirements: min < max')\n if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {\n throw new RangeError(\n 'applyDelta step: requirements: finite and greater than 0',\n )\n }\n\n const {\n value: [mode, amount],\n modifier,\n } = selectModifier(options, modifiers)\n\n const x = direction * amount\n const next =\n mode === 'normalized'\n ? scale.denormalize(scale.normalize(value, min, max) + x, min, max)\n : value + x\n\n // Naming a modifier is a deliberate request to move off the grid, so `step`\n // does not apply to it. Without this a finer amount would round straight\n // back to where it started: `stepValue(3 + 0.1, 1)` is 3.\n const quantum = modifier === null ? step : undefined\n const stepped = quantum !== undefined ? stepValue(next, quantum) : next\n\n // Rounded before the clamp, so that `min` and `max` still have the last\n // word and the value can land on them exactly. Without this the artefact\n // accumulates: with no `step` to round it back, twelve presses of a 0.1\n // modifier amount reach 5.699999999999998 rather than 5.7.\n return clamp(toPrecision(stepped), min, max)\n}\n","import type { XY } from '../xy'\n\n/**\n * A rectangle in the 0..1 space of whatever the box is drawn over, with `y`\n * growing downwards — the same space the items are given in.\n */\nexport interface SelectionBoxRect {\n x: number\n y: number\n width: number\n height: number\n}\n\nexport interface SelectionBoxOptions<Id> {\n /**\n * Everything the box can pick up, read on every move rather than taken as a\n * snapshot: what is under the box changes while it is dragged, and an item\n * may have moved since the last one.\n */\n items: () => Iterable<readonly [Id, XY<number>]>\n /** The box as it is dragged, and `null` once it is gone. */\n onBoxChange?: (box: SelectionBoxRect | null) => void\n /** What the box covers, added to whatever it started from. */\n onSelectionChange?: (ids: Id[]) => void\n}\n\nexport interface SelectionBoxBeginOptions<Id> {\n /**\n * Add to `selection` rather than replacing it. Ctrl / meta rather than\n * shift, for the controls where shift is the fine-adjustment key.\n */\n additive?: boolean\n /** What was selected before the box started. Only read when `additive`. */\n selection?: readonly Id[]\n}\n\nexport interface SelectionBoxInstance<Id> {\n /** Replace the given options, without interrupting a box in progress. */\n update: (options: Partial<SelectionBoxOptions<Id>>) => void\n\n /** Start a box at `at`, which is one of its corners. */\n begin: (at: XY<number>, options?: SelectionBoxBeginOptions<Id>) => void\n /** Drag the opposite corner to `to`. */\n move: (to: XY<number>) => void\n /**\n * Finish the box. The selection stays as it is.\n *\n * @returns whether a box was running, so that a caller can tell a drag from\n * a press that selected nothing.\n */\n end: () => boolean\n\n /** The box being dragged, or `null` when there is none. */\n box: () => SelectionBoxRect | null\n\n destroy: () => void\n}\n\nfunction rectOf(from: XY<number>, to: XY<number>): SelectionBoxRect {\n return {\n x: Math.min(from[0], to[0]),\n y: Math.min(from[1], to[1]),\n width: Math.abs(to[0] - from[0]),\n height: Math.abs(to[1] - from[1]),\n }\n}\n\n/** Whether the box covers the point, edges included. */\nexport function selectionBoxCovers(\n box: SelectionBoxRect,\n [x, y]: XY<number>,\n): boolean {\n return (\n x >= box.x &&\n x <= box.x + box.width &&\n y >= box.y &&\n y <= box.y + box.height\n )\n}\n\n/**\n * Selecting by dragging a box over a set of items.\n *\n * The drag itself is not here: the caller owns the pointer, and reports where\n * it went in the same 0..1 space the items are given in.\n */\nexport function createSelectionBox<Id>(\n options: SelectionBoxOptions<Id>,\n): SelectionBoxInstance<Id> {\n let opts = options\n let drag: { from: XY<number>; base: readonly Id[] } | null = null\n let box: SelectionBoxRect | null = null\n\n function setBox(next: SelectionBoxRect | null) {\n box = next\n opts.onBoxChange?.(next)\n }\n\n function apply(rect: SelectionBoxRect, base: readonly Id[]) {\n const inside: Id[] = []\n for (const [id, at] of opts.items()) {\n if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id)\n }\n opts.onSelectionChange?.([...base, ...inside])\n }\n\n return {\n update: (next) => {\n opts = { ...opts, ...next }\n },\n begin: (at, { additive = false, selection = [] } = {}) => {\n drag = { from: at, base: additive ? [...selection] : [] }\n setBox(rectOf(at, at))\n // A plain press is a deselect of its own, so that a drag that picks\n // nothing up leaves nothing selected.\n if (!additive) opts.onSelectionChange?.([])\n },\n move: (to) => {\n if (!drag) return\n const rect = rectOf(drag.from, to)\n setBox(rect)\n apply(rect, drag.base)\n },\n end: () => {\n const running = drag !== null\n drag = null\n if (box !== null) setBox(null)\n return running\n },\n box: () => box,\n destroy: () => {\n drag = null\n box = null\n },\n }\n}\n","import { clamp, toPrecision } from '@tremolo-ui/functions'\n\nimport { applyDelta } from '../input/apply-delta'\nimport {\n type InputEventOption,\n type ModifierState,\n type ModifierValue,\n} from '../input/modifiers'\nimport { createSelectionBox, type SelectionBoxRect } from '../selection/box'\nimport { type XY } from '../xy'\n\n/** Where a point is: 0..1 on each axis, with y growing downwards. */\nexport interface PointPosition {\n x: number\n y: number\n}\n\n/**\n * A point's value is its position in the editor, so its range is the editor:\n * no scaling, and no rounding to a step.\n */\nexport const POINT_AXIS = { min: 0, max: 1 }\n\n/**\n * How much one wheel notch moves a point by default: a hundredth of the\n * editor, whatever its pixel size.\n */\nexport const POINTS_EDITOR_DEFAULT_WHEEL: ModifierValue<InputEventOption> = [\n 'normalized',\n 0.01,\n]\n\n/**\n * How much one arrow key press moves a point by default. Shift is the\n * fine-adjustment key everywhere else, so it is bound here too — but only on\n * the keyboard. On the wheel it already means the x axis, and browsers hand\n * shift+wheel over as horizontal scrolling anyway.\n */\nexport const POINTS_EDITOR_DEFAULT_KEYBOARD: ModifierValue<InputEventOption> = {\n default: ['normalized', 0.01],\n shift: ['normalized', 0.001],\n}\n\n/** Keep a point within its range. An axis left out runs from 0 to 1. */\nexport function clampPoint(\n point: PointPosition,\n min?: Partial<PointPosition>,\n max?: Partial<PointPosition>,\n): PointPosition {\n return {\n x: clamp(point.x, min?.x ?? 0, max?.x ?? 1),\n y: clamp(point.y, min?.y ?? 0, max?.y ?? 1),\n }\n}\n\n/**\n * What a point tells the editor about itself, so that a selection can be\n * moved without the editor knowing how the points are stored.\n */\nexport interface PointsEditorPoint {\n value: PointPosition\n min?: Partial<PointPosition>\n max?: Partial<PointPosition>\n /** A point that cannot move stays put while the rest of a selection moves. */\n readonly?: boolean\n onChange?: (value: PointPosition) => void\n /** The point's own element, to match the focus and a press against. */\n element?: Element | null\n /** The wheel option the point resolved, `null` for no wheel. */\n wheel?: ModifierValue<InputEventOption> | null\n /**\n * Called just before the wheel moves this point as the focused one, so that\n * it can report its change as started before the value arrives.\n */\n beforeWheel?: () => void\n}\n\nexport interface PointsEditorOptions {\n /**\n * Let points be selected, and a selection be moved as one. While it is off\n * nothing is selected, and a drag moves only the point it started on.\n *\n * @default false\n */\n selectable?: boolean\n /**\n * The ids of the selected points. Whoever holds the selection pushes it\n * back with `update()` after {@link PointsEditorOptions.onSelectionChange}.\n */\n selection?: readonly string[]\n /** Called whenever a press or a selection box changes the selection. */\n onSelectionChange?: (selection: string[]) => void\n /** Called whenever the selection box changes, with `null` once it is gone. */\n onSelectionBoxChange?: (rect: SelectionBoxRect | null) => void\n}\n\nexport interface PointsEditorInstance {\n /** Replace the given options. */\n update: (options: Partial<PointsEditorOptions>) => void\n /**\n * Register a point under `id`. `read` is called whenever the editor needs\n * the point, so it can return what the point is now rather than what it\n * was when it registered. Returns the function that unregisters it.\n */\n registerPoint: (id: string, read: () => PointsEditorPoint) => () => void\n /** Whether the element is a point, or inside one. */\n isPointElement: (element: Element | null | undefined) => boolean\n /**\n * A pointer went down on a point: work out the new selection and remember\n * where everything the drag picked up started.\n *\n * Ctrl / meta add the point to the selection or take it out: shift is the\n * fine-adjustment key on every control here, and it cannot be both. A\n * press on a point already selected keeps the selection, so the group can\n * be dragged.\n */\n beginPointDrag: (id: string, modifiers: ModifierState) => void\n /**\n * Move everything the drag picked up by `delta` from where it started,\n * stopping the whole group together at the edge.\n */\n movePointDrag: (delta: PointPosition) => void\n /**\n * Move `id` — and the selection, when it is part of one — by `delta` from\n * where the points are now: an input that is not a drag has no earlier\n * position to measure against.\n */\n nudgeSelection: (id: string, delta: PointPosition) => void\n /**\n * Move `id` by one press of `option` along one axis, taking the selection\n * along as {@link PointsEditorInstance.nudgeSelection} does.\n */\n nudgePoint: (\n id: string,\n axis: 'x' | 'y',\n direction: number,\n option: ModifierValue<InputEventOption>,\n modifiers?: ModifierState,\n ) => void\n /**\n * Move the point holding the focus by one wheel notch, with its own wheel\n * option. The wheel is listened to once for the whole editor, since a wheel\n * event only reaches what the cursor is over. Returns whether a point took\n * it, so the caller knows whether to consume the event.\n */\n nudgeFocusedPoint: (\n axis: 'x' | 'y',\n direction: number,\n modifiers: ModifierState,\n ) => boolean\n /** A drag on empty space started at `at`: start a selection box there. */\n beginSelectionBox: (at: PointPosition, modifiers: ModifierState) => void\n moveSelectionBox: (to: PointPosition) => void\n /**\n * End the selection box. The press that started it left the focus on\n * nothing, and the arrow keys and the wheel reach a point only through the\n * focus, so it is handed to one of the points the box selected.\n */\n endSelectionBox: () => void\n destroy: () => void\n}\n\nconst EMPTY: readonly string[] = []\n\n/**\n * How far a group may move before something in it leaves its range.\n *\n * Clamping each point on its own would break the shape of the selection: the\n * one that reached the edge would stop while the rest carried on. One amount\n * for all of them means the whole selection stops together.\n */\nfunction allowedDelta(\n delta: PointPosition,\n entries: { start: PointPosition; point: PointsEditorPoint }[],\n): PointPosition {\n let loX = -Infinity\n let hiX = Infinity\n let loY = -Infinity\n let hiY = Infinity\n for (const { start, point } of entries) {\n // A point that cannot move stays where it is, so its range says nothing\n // about how far the rest may go.\n if (point.readonly) continue\n loX = Math.max(loX, (point.min?.x ?? 0) - start.x)\n hiX = Math.min(hiX, (point.max?.x ?? 1) - start.x)\n loY = Math.max(loY, (point.min?.y ?? 0) - start.y)\n hiY = Math.min(hiY, (point.max?.y ?? 1) - start.y)\n }\n // A point that started outside its own range leaves nothing to move within.\n return {\n x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),\n y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),\n }\n}\n\n/**\n * The selection and the moves of a points editor: which points a press or a\n * box selects, and how a selection moves as one.\n *\n * The points stay with the wrapper, which registers each one; the editor only\n * reads them when it needs to, so a point's value can change on every frame\n * of a drag without anything being re-registered.\n */\nexport function createPointsEditor(\n options: PointsEditorOptions = {},\n): PointsEditorInstance {\n let opts = options\n const points = new Map<string, () => PointsEditorPoint>()\n /** What the current drag picked up, and where those points started. */\n let dragged: { id: string; start: PointPosition }[] = []\n\n const selection = () => (opts.selectable ? (opts.selection ?? EMPTY) : EMPTY)\n\n function changeSelection(next: string[]) {\n // Read back at once by the drag that follows, before the owner has had a\n // chance to push it with update().\n opts = { ...opts, selection: next }\n opts.onSelectionChange?.(next)\n }\n\n function snapshot(ids: readonly string[]) {\n return ids.flatMap((id) => {\n const point = points.get(id)?.()\n return point ? [{ id, start: { ...point.value } }] : []\n })\n }\n\n function moveFrom(\n entries: { id: string; start: PointPosition }[],\n delta: PointPosition,\n ) {\n const withPoint = entries.flatMap((entry) => {\n const point = points.get(entry.id)?.()\n return point ? [{ ...entry, point }] : []\n })\n const allowed = allowedDelta(delta, withPoint)\n for (const { start, point } of withPoint) {\n if (point.readonly) continue\n // Rounded here as well as in the pipeline: a move is a subtraction and\n // an addition of its own, and that is enough to put the binary artefact\n // back (0.2 + 0.1 lands on 0.30000000000000004).\n point.onChange?.({\n x: toPrecision(start.x + allowed.x),\n y: toPrecision(start.y + allowed.y),\n })\n }\n }\n\n function nudgeSelection(id: string, delta: PointPosition) {\n const current = selection()\n moveFrom(snapshot(current.includes(id) ? current : [id]), delta)\n }\n\n function nudgePoint(\n id: string,\n axis: 'x' | 'y',\n direction: number,\n option: ModifierValue<InputEventOption>,\n modifiers?: ModifierState,\n ) {\n const point = points.get(id)?.()\n if (!point) return\n const { value } = point\n const next = applyDelta(\n value[axis],\n direction,\n option,\n POINT_AXIS,\n modifiers,\n )\n // As a move, so that the rest of the selection comes along and the whole\n // group stops together at the edge.\n nudgeSelection(id, {\n x: axis === 'x' ? next - value.x : 0,\n y: axis === 'y' ? next - value.y : 0,\n })\n }\n\n const box = createSelectionBox<string>({\n *items(): Generator<readonly [string, XY<number>]> {\n for (const [id, read] of points) {\n const { x, y } = read().value\n yield [id, [x, y]]\n }\n },\n onBoxChange: (rect) => opts.onSelectionBoxChange?.(rect),\n onSelectionChange: changeSelection,\n })\n\n return {\n update: (next) => {\n opts = { ...opts, ...next }\n },\n registerPoint: (id, read) => {\n points.set(id, read)\n return () => {\n if (points.get(id) === read) points.delete(id)\n }\n },\n isPointElement: (element) => {\n if (!element) return false\n for (const read of points.values()) {\n if (read().element?.contains(element)) return true\n }\n return false\n },\n beginPointDrag: (id, modifiers) => {\n if (!opts.selectable) {\n dragged = snapshot([id])\n return\n }\n const current = selection()\n const additive = modifiers.ctrlKey || modifiers.metaKey\n let next: readonly string[]\n if (additive) {\n next = current.includes(id)\n ? current.filter((x) => x !== id)\n : [...current, id]\n } else if (current.includes(id)) {\n next = current\n } else {\n next = [id]\n }\n changeSelection([...next])\n // A press that took the point out of the selection was a deselect, not\n // the start of a move, so there is nothing to drag.\n dragged = next.includes(id) ? snapshot(next) : []\n },\n movePointDrag: (delta) => moveFrom(dragged, delta),\n nudgeSelection,\n nudgePoint,\n nudgeFocusedPoint: (axis, direction, modifiers) => {\n for (const [id, read] of points) {\n const { element, wheel, readonly, onChange, beforeWheel } = read()\n // The document the point is in, which need not be the one this script\n // runs in — an editor rendered into an iframe has its own focus.\n const active = element?.ownerDocument.activeElement\n // A point answers only for the focus inside its own inputs, so both\n // axes stay part of the same interaction.\n if (!active || !element?.contains(active)) continue\n if (!wheel || readonly || !onChange) return false\n beforeWheel?.()\n nudgePoint(id, axis, direction, wheel, modifiers)\n return true\n }\n return false\n },\n beginSelectionBox: (at, modifiers) => {\n if (!opts.selectable) return\n box.begin([at.x, at.y], {\n additive: modifiers.ctrlKey || modifiers.metaKey,\n selection: selection(),\n })\n },\n moveSelectionBox: (to) => box.move([to.x, to.y]),\n endSelectionBox: () => {\n if (!box.end()) return\n const [first] = selection()\n const element = first ? points.get(first)?.().element : null\n if (element && 'focus' in element) (element as HTMLElement).focus()\n },\n destroy: () => {\n box.destroy()\n dragged = []\n },\n }\n}\n","/**\n * A pair of per-axis values. The tuple elements are labelled, so editors show\n * `[x: number, y: number]` rather than a bare pair.\n */\nexport type XY<T> = [x: T, y: T]\n\n/**\n * A setting that may be given once for both axes, or per axis.\n *\n * A single value is told from a pair with `Array.isArray`, so the single form\n * is only offered while `T` cannot itself be an array. Where it can, the pair\n * is the only way to write it, since a lone array would be read as a pair.\n */\nexport type XYInput<T> = [T] extends [readonly unknown[]]\n ? readonly [x: T, y: T]\n : T | readonly [x: T, y: T]\n\n// `Array.isArray` narrows a mutable tuple on its own, but not a readonly one,\n// hence the explicit predicate.\nfunction isPair<T>(\n value: T | readonly [x: T, y: T],\n): value is readonly [x: T, y: T] {\n return Array.isArray(value)\n}\n\n/**\n * Spread a setting that may have been given as a single value.\n *\n * The parameter is written out rather than taken as `XYInput<T>`: a\n * conditional type cannot be narrowed, so the constraint stays where it is\n * declared and this takes both forms.\n *\n * The pair is copied rather than passed along, so that the result is a tuple\n * the caller owns even when a readonly one was given.\n */\nexport function toXY<T>(value: T | readonly [x: T, y: T]): XY<T> {\n return isPair(value) ? [value[0], value[1]] : [value, value]\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/dom",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "framework-agnostic DOM layer used in @tremolo-ui/*",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,7 +19,7 @@
19
19
  "format": "prettier --write ."
20
20
  },
21
21
  "dependencies": {
22
- "@tremolo-ui/functions": "^0.7.0"
22
+ "@tremolo-ui/functions": "^0.9.0"
23
23
  },
24
24
  "files": [
25
25
  "dist",
@@ -54,6 +54,16 @@
54
54
  "types": "./dist/index.d.ts",
55
55
  "default": "./dist/index.js"
56
56
  }
57
+ },
58
+ "./internal": {
59
+ "require": {
60
+ "types": "./dist/internal.d.cts",
61
+ "default": "./dist/internal.cjs"
62
+ },
63
+ "import": {
64
+ "types": "./dist/internal.d.ts",
65
+ "default": "./dist/internal.js"
66
+ }
57
67
  }
58
68
  }
59
69
  }
@@ -21,11 +21,19 @@ export interface AnimationFrame {
21
21
  fps: number
22
22
  }
23
23
 
24
+ /**
25
+ * Draws one frame, given the 2D context and the frame: the size in CSS pixels,
26
+ * `count`, `deltaTime`, `elapsedTime` and `fps`.
27
+ */
24
28
  export type CanvasDrawFunction = (
25
29
  context: CanvasRenderingContext2D,
26
30
  frame: AnimationFrame,
27
31
  ) => void
28
32
 
33
+ /**
34
+ * Sets up what every frame shares. Called once, with the context and the size
35
+ * in CSS pixels, before the first frame; not again on a resize.
36
+ */
29
37
  export type CanvasInitFunction = (
30
38
  context: CanvasRenderingContext2D,
31
39
  size: { width: number; height: number },
@@ -44,11 +44,6 @@ export type DrawingContext = Pick<CanvasRenderingContext2D, DrawingState> & {
44
44
  transform: DOMMatrix
45
45
  }
46
46
 
47
- export function isDrawingState(value: unknown): value is DrawingState {
48
- const names: readonly string[] = drawingState
49
- return typeof value === 'string' && names.includes(value)
50
- }
51
-
52
47
  /** Copy the drawing state off a context, to put back after a resize. */
53
48
  export function readDrawingState(
54
49
  context: CanvasRenderingContext2D,
@@ -56,3 +56,20 @@ export function matchesAccept(
56
56
 
57
57
  return undecided
58
58
  }
59
+
60
+ /**
61
+ * Split files into those that satisfy `accept` and those that do not, keeping
62
+ * their order. See {@link matchesAccept}.
63
+ */
64
+ export function partitionByAccept(
65
+ files: Iterable<File>,
66
+ accept?: string,
67
+ ): { accepted: File[]; rejected: File[] } {
68
+ const accepted: File[] = []
69
+ const rejected: File[] = []
70
+ for (const file of files) {
71
+ if (matchesAccept(file, accept)) accepted.push(file)
72
+ else rejected.push(file)
73
+ }
74
+ return { accepted, rejected }
75
+ }
@@ -1,4 +1,4 @@
1
- import { matchesAccept } from './accept'
1
+ import { matchesAccept, partitionByAccept } from './accept'
2
2
 
3
3
  /** What is in the air over the element. */
4
4
  export interface DropZoneState {
@@ -41,7 +41,8 @@ export interface DropZoneOptions {
41
41
  onDrop?: (files: File[], event: DragEvent) => void
42
42
  /**
43
43
  * Called with the dropped files that do not match `accept`, so that the
44
- * reason can be shown.
44
+ * reason can be shown. It comes after `onDrop` for the same drop, so a list
45
+ * of rejected files can be cleared in `onDrop` and filled here.
45
46
  */
46
47
  onReject?: (files: File[], event: DragEvent) => void
47
48
  /** Called whenever {@link DropZoneInstance.state} would change. */
@@ -146,17 +147,14 @@ export function createDropZone(
146
147
  setState({ over: false, invalid: false })
147
148
  if (opts.disabled) return
148
149
 
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
- }
150
+ const { accepted, rejected } = partitionByAccept(
151
+ event.dataTransfer?.files ?? [],
152
+ opts.accept,
153
+ )
156
154
 
157
- if (rejected.length > 0) opts.onReject?.(rejected, event)
158
155
  const taken = opts.multiple ? accepted : accepted.slice(0, 1)
159
156
  if (taken.length > 0) opts.onDrop?.(taken, event)
157
+ if (rejected.length > 0) opts.onReject?.(rejected, event)
160
158
  }
161
159
 
162
160
  /**
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // What users of the components and of this package write: the `create*`
2
+ // interactions, the values and types that show up in props, and the geometry a
3
+ // part of your own needs to draw. Everything here follows semver. What only
4
+ // the wrappers need is in `./internal`, which does not.
1
5
  export {
2
6
  createAnimationCanvas,
3
7
  type AnimationCanvasInstance,
@@ -6,35 +10,40 @@ export {
6
10
  type CanvasDrawFunction,
7
11
  type CanvasInitFunction,
8
12
  } from './canvas/animation'
9
- export {
10
- drawingState,
11
- isDrawingState,
12
- type DrawingContext,
13
- type DrawingState,
14
- type DrawingStateValue,
15
- } from './canvas/context'
16
- export { matchesAccept, type AcceptCandidate } from './file/accept'
17
13
  export {
18
14
  createDropZone,
19
15
  type DropZoneInstance,
20
16
  type DropZoneOptions,
21
17
  type DropZoneState,
22
18
  } from './file/drop-zone'
23
- export { applyDelta } from './input/apply-delta'
24
19
  export {
25
- mapModifier,
26
- selectModifier,
20
+ createChangeGesture,
21
+ type ChangeGestureInstance,
22
+ type ChangeGestureOptions,
23
+ type ChangeSource,
24
+ } from './input/change-gesture'
25
+ export {
26
+ DEFAULT_DRAG_SENSITIVITY,
27
+ DEFAULT_KEYBOARD_OPTIONS,
28
+ DEFAULT_WHEEL_OPTIONS,
29
+ } from './input/defaults'
30
+ export {
27
31
  type InputEventOption,
28
32
  type Modifier,
29
33
  type ModifierMap,
30
34
  type ModifierState,
31
35
  type ModifierValue,
32
36
  } from './input/modifiers'
37
+ export {
38
+ KNOB_VIEWBOX_SIZE,
39
+ knobAngles,
40
+ knobArcPath,
41
+ knobArcRadius,
42
+ type KnobAngleOptions,
43
+ type KnobAngles,
44
+ } from './knob/geometry'
33
45
  export {
34
46
  createMIDIAccess,
35
- NOT_SUPPORTED,
36
- PERMISSION_DENIED,
37
- UNAVAILABLE,
38
47
  type MIDIAccessError,
39
48
  type MIDIAccessInstance,
40
49
  type MIDIAccessOptions,
@@ -42,26 +51,29 @@ export {
42
51
  } from './midi/access'
43
52
  export {
44
53
  createMIDIInput,
45
- PITCH_BEND_CENTER,
46
54
  type MIDIInputHandlers,
47
55
  type MIDIInputInstance,
48
56
  } from './midi/input'
49
57
  export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
58
+ export { notePosition, type NoteRange, type PianoLayout } from './piano/layout'
50
59
  export {
51
- blackKeyWidth,
52
- getNoteRangeArray,
53
- noteAt,
54
- notePosition,
55
- pianoWidth,
56
- type NoteRange,
57
- type PianoLayout,
58
- } from './piano/layout'
60
+ SHORTCUTS,
61
+ type KeyboardShortcuts,
62
+ type KeyboardShortcutsScope,
63
+ } from './piano/shortcuts'
59
64
  export {
60
65
  createPianoInput,
61
66
  type NoteSource,
62
67
  type PianoInputInstance,
63
68
  type PianoInputOptions,
64
69
  } from './piano'
70
+ export {
71
+ createPointsEditor,
72
+ type PointPosition,
73
+ type PointsEditorInstance,
74
+ type PointsEditorOptions,
75
+ type PointsEditorPoint,
76
+ } from './points-editor'
65
77
  export {
66
78
  createDrag,
67
79
  type DragInstance,
@@ -78,17 +90,17 @@ export {
78
90
  type DragValueOptions,
79
91
  type MappingContext,
80
92
  } from './pointer/drag-value'
93
+ export {
94
+ createLongPress,
95
+ type LongPressInstance,
96
+ type LongPressOptions,
97
+ } from './pointer/long-press'
81
98
  export {
82
99
  createWheel,
83
100
  type WheelInstance,
84
101
  type WheelOptions,
85
102
  } 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'
94
- export { toXY, type XY, type XYInput } from './xy'
103
+ export { valuePercent } from './position'
104
+ export { type SelectionBoxRect } from './selection/box'
105
+ export { type MarksOptions } from './slider/marks'
106
+ export { type XY, type XYInput } from './xy'
@@ -0,0 +1,104 @@
1
+ /** What a change of value was made with. */
2
+ export type ChangeSource = 'pointer' | 'wheel' | 'keyboard' | 'doubleClick'
3
+
4
+ export interface ChangeGestureOptions {
5
+ /** Called before the first change of a gesture. */
6
+ onStart?: (source: ChangeSource) => void
7
+ /** Called after the last change of a gesture. */
8
+ onEnd?: (source: ChangeSource) => void
9
+ /**
10
+ * How long after the last wheel notch or key press a gesture counts as
11
+ * over, in milliseconds. Neither has an event that says it is done, so the
12
+ * gesture ends when nothing more arrives for this long.
13
+ *
14
+ * @default 500
15
+ */
16
+ endDelay?: number
17
+ }
18
+
19
+ export interface ChangeGestureInstance {
20
+ /**
21
+ * Start a gesture that lasts until {@link end}, such as a drag. A gesture of
22
+ * another kind in progress ends first.
23
+ */
24
+ hold: (source: ChangeSource) => void
25
+ /**
26
+ * One step of a gesture with no end of its own, such as a wheel notch:
27
+ * starts one if none is in progress, and ends it `endDelay` after the last
28
+ * step. While a held gesture is in progress the step belongs to it.
29
+ */
30
+ pulse: (source: ChangeSource) => void
31
+ /**
32
+ * A gesture that starts and ends around a single change, such as a reset:
33
+ * `change` runs between the two.
34
+ */
35
+ instant: (source: ChangeSource, change: () => void) => void
36
+ /** End the gesture in progress, if any. */
37
+ end: () => void
38
+ /** Whether a gesture is in progress. */
39
+ active: () => boolean
40
+ /** Replace the given options. A gesture in progress picks them up. */
41
+ update: (options: Partial<ChangeGestureOptions>) => void
42
+ /** End a gesture in progress, so that nothing is left touched. */
43
+ destroy: () => void
44
+ }
45
+
46
+ /**
47
+ * Track when a change of value starts and ends, whatever made it: a drag, the
48
+ * wheel, the keys or a reset. A host that records automation needs both ends
49
+ * to know when the control is touched.
50
+ */
51
+ export function createChangeGesture(
52
+ options: ChangeGestureOptions = {},
53
+ ): ChangeGestureInstance {
54
+ let opts = options
55
+ let current: { source: ChangeSource; held: boolean } | null = null
56
+ let timer: ReturnType<typeof setTimeout> | null = null
57
+
58
+ function clearTimer() {
59
+ if (timer !== null) clearTimeout(timer)
60
+ timer = null
61
+ }
62
+
63
+ function end() {
64
+ clearTimer()
65
+ if (!current) return
66
+ const { source } = current
67
+ current = null
68
+ opts.onEnd?.(source)
69
+ }
70
+
71
+ function start(source: ChangeSource, held: boolean) {
72
+ current = { source, held }
73
+ opts.onStart?.(source)
74
+ }
75
+
76
+ return {
77
+ hold: (source) => {
78
+ if (current?.held && current.source === source) return
79
+ end()
80
+ start(source, true)
81
+ },
82
+ pulse: (source) => {
83
+ if (current?.held) return
84
+ if (!current || current.source !== source) {
85
+ end()
86
+ start(source, false)
87
+ }
88
+ clearTimer()
89
+ timer = setTimeout(end, opts.endDelay ?? 500)
90
+ },
91
+ instant: (source, change) => {
92
+ end()
93
+ start(source, false)
94
+ change()
95
+ end()
96
+ },
97
+ end,
98
+ active: () => current !== null,
99
+ update: (next) => {
100
+ opts = { ...opts, ...next }
101
+ },
102
+ destroy: end,
103
+ }
104
+ }
@@ -0,0 +1,144 @@
1
+ import { linearScale, type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from './apply-delta'
4
+ import {
5
+ type InputEventOption,
6
+ type ModifierState,
7
+ type ModifierValue,
8
+ } from './modifiers'
9
+
10
+ /** Positions probed across the travel. The ends are left out so that the
11
+ * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
12
+ const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
13
+
14
+ const NONE: ModifierState = {
15
+ shiftKey: false,
16
+ altKey: false,
17
+ ctrlKey: false,
18
+ metaKey: false,
19
+ }
20
+
21
+ const MODIFIER_STATE: Record<string, ModifierState> = {
22
+ shift: { ...NONE, shiftKey: true },
23
+ alt: { ...NONE, altKey: true },
24
+ ctrl: { ...NONE, ctrlKey: true },
25
+ meta: { ...NONE, metaKey: true },
26
+ }
27
+
28
+ /** The held keys that select each entry of a modifier-aware input option. */
29
+ function entries(options: ModifierValue<InputEventOption>): ModifierState[] {
30
+ if (Array.isArray(options)) return [NONE]
31
+ return Object.keys(options).map((key) => MODIFIER_STATE[key] ?? NONE)
32
+ }
33
+
34
+ interface Outcome {
35
+ /** The press changed the value at least once across the travel. */
36
+ moved: boolean
37
+ /** The change reached the displayed text at least once. */
38
+ visible: boolean
39
+ }
40
+
41
+ /**
42
+ * Press every entry of `options` at nine points along the travel and report
43
+ * whether anything came of it.
44
+ *
45
+ * Run against `applyDelta` itself rather than against a reading of `step`:
46
+ * the whole point is that the amount, the step and the scale interact, and
47
+ * the pipeline is the only thing that knows how.
48
+ */
49
+ function probe(
50
+ options: ModifierValue<InputEventOption>,
51
+ range: ValueRange,
52
+ format?: (value: number) => string,
53
+ ): Outcome {
54
+ const { min, max, scale = linearScale } = range
55
+ const outcome: Outcome = { moved: false, visible: false }
56
+ for (const modifiers of entries(options)) {
57
+ for (const position of PROBES) {
58
+ const value = scale.denormalize(position, min, max)
59
+ const up = applyDelta(value, 1, options, range, modifiers)
60
+ const down = applyDelta(value, -1, options, range, modifiers)
61
+ if (up !== value || down !== value) outcome.moved = true
62
+ if (!format) continue
63
+ const shown = format(value)
64
+ if (format(up) !== shown || format(down) !== shown) outcome.visible = true
65
+ }
66
+ }
67
+ return outcome
68
+ }
69
+
70
+ export interface CheckStepsOptions {
71
+ /** The component, for the message. */
72
+ component: string
73
+ /** The axis, for a component that has more than one. */
74
+ axis?: string
75
+ /**
76
+ * The range to probe, or `null` to check nothing. An unbounded input has no
77
+ * travel to sample, so `NumberInput` passes `null` when `min` and `max` are
78
+ * not both there.
79
+ */
80
+ range: ValueRange | null
81
+ keyboard?: ModifierValue<InputEventOption> | null
82
+ wheel?: ModifierValue<InputEventOption> | null
83
+ /**
84
+ * How the value is displayed, where the component shows one. Called with
85
+ * probe values only.
86
+ */
87
+ format?: (value: number) => string
88
+ }
89
+
90
+ /**
91
+ * The warnings to show when a key press or a wheel notch cannot produce a
92
+ * change the user can see. Empty when there is nothing to say.
93
+ *
94
+ * Two settings that are each fine on their own can cancel out, and nothing
95
+ * fails when they do — the control simply sits there:
96
+ *
97
+ * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
98
+ * `step={1}` rounds every press straight back to where it started
99
+ * - **the display coarser than the amount.** A `format` showing two decimals
100
+ * of a kHz value cannot show a press worth 1 Hz
101
+ *
102
+ * The second is only reported when the press is invisible at *every* point
103
+ * along the travel. A display that rounds is a deliberate choice and is
104
+ * normally right — it is being too coarse everywhere that makes it a mistake.
105
+ *
106
+ * **Meant for development builds only.** It probes the whole travel, so call
107
+ * it behind an inline `process.env.NODE_ENV` check: the bundler then drops
108
+ * the call, and this function and its messages with it.
109
+ */
110
+ export function checkSteps({
111
+ component,
112
+ axis,
113
+ range,
114
+ keyboard,
115
+ wheel,
116
+ format,
117
+ }: CheckStepsOptions): string[] {
118
+ if (!range || !(range.min < range.max)) return []
119
+ const where = axis ? `${component} (${axis})` : component
120
+ const warnings: string[] = []
121
+ for (const [name, options] of [
122
+ ['keyboard', keyboard],
123
+ ['wheel', wheel],
124
+ ] as const) {
125
+ if (!options) continue
126
+ const { moved, visible } = probe(options, range, format)
127
+ if (!moved) {
128
+ warnings.push(
129
+ `[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
130
+ (range.step !== undefined
131
+ ? ` Each press is smaller than \`step\` (${range.step}), so it rounds`
132
+ : ' Each press rounds') +
133
+ ' straight back to where it started.',
134
+ )
135
+ } else if (format && !visible) {
136
+ warnings.push(
137
+ `[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
138
+ ' shows the same text before and after, everywhere in the range.' +
139
+ ' The display is too coarse for it to be seen.',
140
+ )
141
+ }
142
+ }
143
+ return warnings
144
+ }