@tremolo-ui/dom 0.5.0 → 0.6.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.
@@ -0,0 +1,136 @@
1
+ import type { XY } from '../xy'
2
+
3
+ /**
4
+ * A rectangle in the 0..1 space of whatever the box is drawn over, with `y`
5
+ * growing downwards — the same space the items are given in.
6
+ */
7
+ export interface SelectionBoxRect {
8
+ x: number
9
+ y: number
10
+ width: number
11
+ height: number
12
+ }
13
+
14
+ export interface SelectionBoxOptions<Id> {
15
+ /**
16
+ * Everything the box can pick up, read on every move rather than taken as a
17
+ * snapshot: what is under the box changes while it is dragged, and an item
18
+ * may have moved since the last one.
19
+ */
20
+ items: () => Iterable<readonly [Id, XY<number>]>
21
+ /** The box as it is dragged, and `null` once it is gone. */
22
+ onBoxChange?: (box: SelectionBoxRect | null) => void
23
+ /** What the box covers, added to whatever it started from. */
24
+ onSelectionChange?: (ids: Id[]) => void
25
+ }
26
+
27
+ export interface SelectionBoxBeginOptions<Id> {
28
+ /**
29
+ * Add to `selection` rather than replacing it. Ctrl / meta rather than
30
+ * shift, for the controls where shift is the fine-adjustment key.
31
+ */
32
+ additive?: boolean
33
+ /** What was selected before the box started. Only read when `additive`. */
34
+ selection?: readonly Id[]
35
+ }
36
+
37
+ export interface SelectionBoxInstance<Id> {
38
+ /** Replace the given options, without interrupting a box in progress. */
39
+ update: (options: Partial<SelectionBoxOptions<Id>>) => void
40
+
41
+ /** Start a box at `at`, which is one of its corners. */
42
+ begin: (at: XY<number>, options?: SelectionBoxBeginOptions<Id>) => void
43
+ /** Drag the opposite corner to `to`. */
44
+ move: (to: XY<number>) => void
45
+ /**
46
+ * Finish the box. The selection stays as it is.
47
+ *
48
+ * @returns whether a box was running, so that a caller can tell a drag from
49
+ * a press that selected nothing.
50
+ */
51
+ end: () => boolean
52
+
53
+ /** The box being dragged, or `null` when there is none. */
54
+ box: () => SelectionBoxRect | null
55
+
56
+ destroy: () => void
57
+ }
58
+
59
+ function rectOf(from: XY<number>, to: XY<number>): SelectionBoxRect {
60
+ return {
61
+ x: Math.min(from[0], to[0]),
62
+ y: Math.min(from[1], to[1]),
63
+ width: Math.abs(to[0] - from[0]),
64
+ height: Math.abs(to[1] - from[1]),
65
+ }
66
+ }
67
+
68
+ /** Whether the box covers the point, edges included. */
69
+ export function selectionBoxCovers(
70
+ box: SelectionBoxRect,
71
+ [x, y]: XY<number>,
72
+ ): boolean {
73
+ return (
74
+ x >= box.x &&
75
+ x <= box.x + box.width &&
76
+ y >= box.y &&
77
+ y <= box.y + box.height
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Selecting by dragging a box over a set of items.
83
+ *
84
+ * The drag itself is not here: the caller owns the pointer, and reports where
85
+ * it went in the same 0..1 space the items are given in.
86
+ */
87
+ export function createSelectionBox<Id>(
88
+ options: SelectionBoxOptions<Id>,
89
+ ): SelectionBoxInstance<Id> {
90
+ let opts = options
91
+ let drag: { from: XY<number>; base: readonly Id[] } | null = null
92
+ let box: SelectionBoxRect | null = null
93
+
94
+ function setBox(next: SelectionBoxRect | null) {
95
+ box = next
96
+ opts.onBoxChange?.(next)
97
+ }
98
+
99
+ function apply(rect: SelectionBoxRect, base: readonly Id[]) {
100
+ const inside: Id[] = []
101
+ for (const [id, at] of opts.items()) {
102
+ if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id)
103
+ }
104
+ opts.onSelectionChange?.([...base, ...inside])
105
+ }
106
+
107
+ return {
108
+ update: (next) => {
109
+ opts = { ...opts, ...next }
110
+ },
111
+ begin: (at, { additive = false, selection = [] } = {}) => {
112
+ drag = { from: at, base: additive ? [...selection] : [] }
113
+ setBox(rectOf(at, at))
114
+ // A plain press is a deselect of its own, so that a drag that picks
115
+ // nothing up leaves nothing selected.
116
+ if (!additive) opts.onSelectionChange?.([])
117
+ },
118
+ move: (to) => {
119
+ if (!drag) return
120
+ const rect = rectOf(drag.from, to)
121
+ setBox(rect)
122
+ apply(rect, drag.base)
123
+ },
124
+ end: () => {
125
+ const running = drag !== null
126
+ drag = null
127
+ if (box !== null) setBox(null)
128
+ return running
129
+ },
130
+ box: () => box,
131
+ destroy: () => {
132
+ drag = null
133
+ box = null
134
+ },
135
+ }
136
+ }