Skip to content

Commit 6b85982

Browse files
asynclizardniklub
andauthored
feat: BROS-202: Add interactive resize handles for waveform and spect… (#8116)
Co-authored-by: niklub <[email protected]>
1 parent 42b5f1e commit 6b85982

File tree

5 files changed

+1009
-27
lines changed

5 files changed

+1009
-27
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import type { Interactive } from "./Interactive";
2+
3+
export interface InteractionManagerOptions {
4+
container: HTMLElement;
5+
pixelRatio?: number;
6+
getLayerInfo?: (interactive: Interactive) => LayerInfo | null;
7+
}
8+
9+
export interface LayerInfo {
10+
offsetX: number;
11+
offsetY: number;
12+
width: number;
13+
height: number;
14+
}
15+
16+
/**
17+
* Manages interactions between mouse events and Interactive objects
18+
*/
19+
export class InteractionManager {
20+
private container: HTMLElement;
21+
private pixelRatio: number;
22+
private getLayerInfo?: (interactive: Interactive) => LayerInfo | null;
23+
private interactiveObjects: Interactive[] = [];
24+
private hoveredObject: Interactive | null = null;
25+
private draggedObject: Interactive | null = null;
26+
private isDestroyed = false;
27+
28+
constructor({ container, pixelRatio = 1, getLayerInfo }: InteractionManagerOptions) {
29+
this.container = container;
30+
this.pixelRatio = pixelRatio;
31+
this.getLayerInfo = getLayerInfo;
32+
this.attachEvents();
33+
}
34+
35+
/**
36+
* Register an interactive object
37+
*/
38+
register(interactive: Interactive): void {
39+
if (!this.interactiveObjects.includes(interactive)) {
40+
this.interactiveObjects.push(interactive);
41+
// Sort by z-index (highest first for proper hit testing)
42+
this.interactiveObjects.sort((a, b) => {
43+
const aZ = a.getZIndex?.() ?? 0;
44+
const bZ = b.getZIndex?.() ?? 0;
45+
return bZ - aZ;
46+
});
47+
}
48+
}
49+
50+
/**
51+
* Unregister an interactive object
52+
*/
53+
unregister(interactive: Interactive): void {
54+
const index = this.interactiveObjects.indexOf(interactive);
55+
if (index !== -1) {
56+
this.interactiveObjects.splice(index, 1);
57+
}
58+
59+
// Clear references if this object was active
60+
if (this.hoveredObject === interactive) {
61+
this.hoveredObject = null;
62+
}
63+
if (this.draggedObject === interactive) {
64+
this.draggedObject = null;
65+
}
66+
}
67+
68+
/**
69+
* Find the interactive object under the given coordinates
70+
*/
71+
private findInteractiveUnderPoint(x: number, y: number): Interactive | null {
72+
for (const interactive of this.interactiveObjects) {
73+
if (interactive.isEnabled?.() !== false) {
74+
// Get layer-specific coordinates
75+
const layerCoords = this.getLayerCoordinates(interactive, x, y);
76+
if (layerCoords && interactive.hitTest(layerCoords.x, layerCoords.y)) {
77+
return interactive;
78+
}
79+
}
80+
}
81+
return null;
82+
}
83+
84+
/**
85+
* Transform global coordinates to layer-specific coordinates
86+
*/
87+
private getLayerCoordinates(
88+
interactive: Interactive,
89+
globalX: number,
90+
globalY: number,
91+
): { x: number; y: number } | null {
92+
if (!this.getLayerInfo) {
93+
// Fallback to global coordinates if no layer info provider
94+
return { x: globalX, y: globalY };
95+
}
96+
97+
const layerInfo = this.getLayerInfo(interactive);
98+
if (!layerInfo) {
99+
return null;
100+
}
101+
102+
// Transform global coordinates to layer-relative coordinates
103+
const layerX = globalX - layerInfo.offsetX;
104+
const layerY = globalY - layerInfo.offsetY;
105+
106+
// Check if coordinates are within layer bounds
107+
if (layerX < 0 || layerX > layerInfo.width || layerY < 0 || layerY > layerInfo.height) {
108+
return null;
109+
}
110+
111+
return { x: layerX, y: layerY };
112+
}
113+
114+
/**
115+
* Get coordinates relative to the container
116+
*/
117+
private getRelativeCoordinates(event: MouseEvent): { x: number; y: number } {
118+
const rect = this.container.getBoundingClientRect();
119+
return {
120+
x: event.clientX - rect.left,
121+
y: event.clientY - rect.top,
122+
};
123+
}
124+
125+
/**
126+
* Update cursor based on hovered object
127+
*/
128+
private updateCursor(interactive: Interactive | null): void {
129+
if (interactive?.getCursor) {
130+
const cursor = interactive.getCursor();
131+
this.container.style.cursor = cursor;
132+
} else {
133+
this.container.style.cursor = "default";
134+
}
135+
}
136+
137+
private handleMouseMove = (event: MouseEvent): void => {
138+
if (this.isDestroyed) return;
139+
140+
const { x, y } = this.getRelativeCoordinates(event);
141+
142+
// If we're dragging, send move events to the dragged object
143+
if (this.draggedObject) {
144+
this.draggedObject.onMouseMove?.(event);
145+
return;
146+
}
147+
148+
const interactive = this.findInteractiveUnderPoint(x, y);
149+
150+
// Handle hover state changes
151+
if (interactive !== this.hoveredObject) {
152+
// Mouse leave previous object
153+
if (this.hoveredObject) {
154+
this.hoveredObject.onMouseLeave?.(event);
155+
}
156+
157+
// Mouse enter new object
158+
if (interactive) {
159+
interactive.onMouseEnter?.(event);
160+
}
161+
162+
this.hoveredObject = interactive;
163+
this.updateCursor(interactive);
164+
}
165+
166+
// Send mouse move to currently hovered object
167+
if (this.hoveredObject) {
168+
this.hoveredObject.onMouseMove?.(event);
169+
}
170+
};
171+
172+
private handleMouseDown = (event: MouseEvent): void => {
173+
if (this.isDestroyed) return;
174+
175+
const { x, y } = this.getRelativeCoordinates(event);
176+
const interactive = this.findInteractiveUnderPoint(x, y);
177+
178+
if (interactive) {
179+
this.draggedObject = interactive;
180+
interactive.onMouseDown?.(event);
181+
// Update cursor to reflect dragging state
182+
this.updateCursor(interactive);
183+
}
184+
};
185+
186+
private handleMouseUp = (event: MouseEvent): void => {
187+
if (this.isDestroyed) return;
188+
189+
const { x, y } = this.getRelativeCoordinates(event);
190+
const interactive = this.findInteractiveUnderPoint(x, y);
191+
192+
// Send mouse up to dragged object if it exists
193+
if (this.draggedObject) {
194+
this.draggedObject.onMouseUp?.(event);
195+
this.draggedObject = null;
196+
}
197+
198+
// Send mouse up to object under cursor
199+
if (interactive) {
200+
interactive.onMouseUp?.(event);
201+
}
202+
203+
// Update cursor based on current hover state
204+
this.updateCursor(interactive);
205+
};
206+
207+
private handleClick = (event: MouseEvent): void => {
208+
if (this.isDestroyed) return;
209+
210+
const { x, y } = this.getRelativeCoordinates(event);
211+
const interactive = this.findInteractiveUnderPoint(x, y);
212+
213+
if (interactive) {
214+
interactive.onClick?.(event);
215+
}
216+
};
217+
218+
private handleDoubleClick = (event: MouseEvent): void => {
219+
if (this.isDestroyed) return;
220+
221+
const { x, y } = this.getRelativeCoordinates(event);
222+
const interactive = this.findInteractiveUnderPoint(x, y);
223+
224+
if (interactive) {
225+
interactive.onDoubleClick?.(event);
226+
}
227+
};
228+
229+
private handleMouseLeave = (event: MouseEvent): void => {
230+
if (this.isDestroyed) return;
231+
232+
// Clear hover state when mouse leaves container
233+
if (this.hoveredObject) {
234+
this.hoveredObject.onMouseLeave?.(event);
235+
this.hoveredObject = null;
236+
this.updateCursor(null);
237+
}
238+
};
239+
240+
private attachEvents(): void {
241+
this.container.addEventListener("mousemove", this.handleMouseMove);
242+
this.container.addEventListener("mousedown", this.handleMouseDown);
243+
this.container.addEventListener("mouseup", this.handleMouseUp);
244+
this.container.addEventListener("click", this.handleClick);
245+
this.container.addEventListener("dblclick", this.handleDoubleClick);
246+
this.container.addEventListener("mouseleave", this.handleMouseLeave);
247+
}
248+
249+
private removeEvents(): void {
250+
this.container.removeEventListener("mousemove", this.handleMouseMove);
251+
this.container.removeEventListener("mousedown", this.handleMouseDown);
252+
this.container.removeEventListener("mouseup", this.handleMouseUp);
253+
this.container.removeEventListener("click", this.handleClick);
254+
this.container.removeEventListener("dblclick", this.handleDoubleClick);
255+
this.container.removeEventListener("mouseleave", this.handleMouseLeave);
256+
}
257+
258+
/**
259+
* Update pixel ratio (e.g., on zoom or device change)
260+
*/
261+
setPixelRatio(pixelRatio: number): void {
262+
this.pixelRatio = pixelRatio;
263+
}
264+
265+
/**
266+
* Get all registered interactive objects
267+
*/
268+
getInteractiveObjects(): readonly Interactive[] {
269+
return this.interactiveObjects;
270+
}
271+
272+
/**
273+
* Clean up the interaction manager
274+
*/
275+
destroy(): void {
276+
this.isDestroyed = true;
277+
this.removeEvents();
278+
this.interactiveObjects.length = 0;
279+
this.hoveredObject = null;
280+
this.draggedObject = null;
281+
}
282+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Interface for objects that can respond to user interactions
3+
*/
4+
export interface Interactive {
5+
/**
6+
* Check if the given coordinates are within the interactive area
7+
*/
8+
hitTest(x: number, y: number): boolean;
9+
10+
/**
11+
* Handle mouse enter events
12+
*/
13+
onMouseEnter?(event: MouseEvent): void;
14+
15+
/**
16+
* Handle mouse leave events
17+
*/
18+
onMouseLeave?(event: MouseEvent): void;
19+
20+
/**
21+
* Handle mouse move events
22+
*/
23+
onMouseMove?(event: MouseEvent): void;
24+
25+
/**
26+
* Handle mouse down events
27+
*/
28+
onMouseDown?(event: MouseEvent): void;
29+
30+
/**
31+
* Handle mouse up events
32+
*/
33+
onMouseUp?(event: MouseEvent): void;
34+
35+
/**
36+
* Handle click events
37+
*/
38+
onClick?(event: MouseEvent): void;
39+
40+
/**
41+
* Handle double click events
42+
*/
43+
onDoubleClick?(event: MouseEvent): void;
44+
45+
/**
46+
* Get the cursor type for this interactive element
47+
*/
48+
getCursor?(): string;
49+
50+
/**
51+
* Check if this interactive element is currently enabled
52+
*/
53+
isEnabled?(): boolean;
54+
55+
/**
56+
* Get the z-index for interaction priority
57+
*/
58+
getZIndex?(): number;
59+
}

0 commit comments

Comments
 (0)