Skip to content

Commit 730a2bf

Browse files
committed
feat: add dragOutside option to continue dragging when mouse leaves canvas
- Add dragOutside option to drag plugin (default: false) - Implement document-level event handlers for tracking mouse outside canvas - Fix coordinate system handling between PIXI and DOM coordinates - Add comprehensive test coverage and demo integration - Maintain full backward compatibility Useful for applications where users drag over browser UI elements or across multiple windows.
1 parent d0b4667 commit 730a2bf

File tree

6 files changed

+300
-10
lines changed

6 files changed

+300
-10
lines changed

DRAG_OUTSIDE_EXAMPLE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# dragOutside Feature Example
2+
3+
The `dragOutside` option allows dragging to continue even when the mouse/pointer leaves the canvas or browser window.
4+
5+
## Basic Usage
6+
7+
```javascript
8+
import { Viewport } from 'pixi-viewport';
9+
10+
const viewport = new Viewport({
11+
screenWidth: window.innerWidth,
12+
screenHeight: window.innerHeight,
13+
worldWidth: 2000,
14+
worldHeight: 2000,
15+
events: app.renderer.events
16+
});
17+
18+
// Enable dragOutside
19+
viewport.drag({
20+
dragOutside: true
21+
});
22+
```
23+
24+
## Options
25+
26+
```javascript
27+
viewport.drag({
28+
dragOutside: true, // Enable dragging outside canvas (default: false)
29+
clampWheel: false, // Other options work as usual
30+
mouseButtons: 'all' // Still respects button configuration
31+
});
32+
```
33+
34+
## How it Works
35+
36+
When `dragOutside: true` is enabled:
37+
38+
1. **Normal behavior inside canvas**: Works exactly like before
39+
2. **Outside canvas**: Uses document-level event listeners to track mouse movement
40+
3. **Cross-window dragging**: Continues panning even when mouse leaves browser window
41+
4. **Proper cleanup**: Automatically removes document listeners when drag ends
42+
43+
## Benefits
44+
45+
- **Better UX**: No more frustrating drag interruptions
46+
- **Small canvases**: Enables large mouse movements for fine control
47+
- **Interactive overlays**: Doesn't stop when mouse moves over UI elements
48+
- **Backward compatible**: Default `false` maintains existing behavior
49+
50+
## Demo
51+
52+
The development server demo at `http://localhost:5175` shows `dragOutside` in action.
53+
Look for the green indicator in the top-right corner!
54+
55+
## Browser Support
56+
57+
Works in all modern browsers that support pointer events (IE11+, all evergreen browsers).

docs/src/code.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function viewport() {
2929
stopPropagation: true
3030
}))
3131
_viewport
32-
.drag({ clampWheel: false })
32+
.drag({ clampWheel: false, dragOutside: true })
3333
.wheel({ smooth: 3, trackpadPinch: true, wheelZoom: false, })
3434
.pinch()
3535
.decelerate()
@@ -192,6 +192,7 @@ function API() {
192192
button.style.backgroundImage = 'linear-gradient(to bottom, #3498db, #2980b9)'
193193
button.style.padding = '10px 20px 10px 20px'
194194
clicked(button, () => window.location.href = 'https://davidfig.github.io/pixi-viewport/jsdoc/')
195+
195196
}
196197

197198
window.onload = function () {

docs/src/gui.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ export function gui(viewport, drawWorld, target) {
1717
_world = _gui.addFolder('world')
1818
options = {
1919
testDirty: false,
20-
drag: true,
20+
drag: {
21+
drag: true,
22+
dragOutside: true
23+
},
2124
clampZoom: {
2225
clampZoom: false,
2326
minWidth: 1000,
@@ -119,15 +122,37 @@ function guiWorld() {
119122
}
120123

121124
function guiDrag() {
122-
_gui.add(options, 'drag').onChange(
125+
function change() {
126+
_viewport.drag({
127+
clampWheel: true,
128+
dragOutside: options.drag.dragOutside
129+
})
130+
}
131+
132+
function add() {
133+
dragOutside = drag.add(options.drag, 'dragOutside').onChange(change)
134+
}
135+
136+
let dragOutside
137+
const drag = _gui.addFolder('drag')
138+
drag.add(options.drag, 'drag').onChange(
123139
function (value) {
124140
if (value) {
125-
_viewport.drag({ clampWheel: true })
141+
change()
142+
add()
126143
}
127144
else {
128145
_viewport.plugins.remove('drag')
146+
if (dragOutside) {
147+
drag.remove(dragOutside)
148+
dragOutside = null
149+
}
129150
}
130151
})
152+
if (options.drag.drag) {
153+
add()
154+
drag.open()
155+
}
131156
}
132157

133158
function guiClamp() {

src/plugins/Drag.ts

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ export interface IDragOptions
102102
* @default false
103103
*/
104104
wheelSwapAxes?: boolean;
105+
106+
/**
107+
* Continue dragging when mouse/pointer leaves the canvas/window.
108+
* Uses document-level event listeners to track mouse movement outside the viewport.
109+
*
110+
* @default false
111+
*/
112+
dragOutside?: boolean;
105113
}
106114

107115
const DEFAULT_DRAG_OPTIONS: Required<IDragOptions> = {
@@ -118,6 +126,7 @@ const DEFAULT_DRAG_OPTIONS: Required<IDragOptions> = {
118126
ignoreKeyToPressOnTouch: false,
119127
lineHeight: 20,
120128
wheelSwapAxes: false,
129+
dragOutside: false,
121130
};
122131

123132
/**
@@ -166,6 +175,15 @@ export class Drag extends Plugin
166175
handler: (e: any) => void;
167176
}> = [];
168177

178+
/** Tracks whether we're in dragOutside mode */
179+
private isDragOutside = false;
180+
181+
/** Initial viewport position when dragOutside starts */
182+
private dragOutsideStartPosition?: PointData;
183+
184+
/** Initial DOM coordinates when dragOutside starts */
185+
private dragOutsideStartMouse?: PointData;
186+
169187
/**
170188
* This is called by {@link Viewport.drag}.
171189
*/
@@ -193,6 +211,11 @@ export class Drag extends Plugin
193211
{
194212
this.handleKeyPresses(this.options.keyToPress);
195213
}
214+
215+
if (this.options.dragOutside)
216+
{
217+
this.setupDragOutside();
218+
}
196219
}
197220

198221
/**
@@ -232,6 +255,73 @@ export class Drag extends Plugin
232255
this.windowEventHandlers.push({ event, handler });
233256
}
234257

258+
/**
259+
* Sets up document-level event handlers for dragOutside functionality
260+
*/
261+
private setupDragOutside(): void
262+
{
263+
const documentMoveHandler = (e: PointerEvent) =>
264+
{
265+
if (this.isDragOutside && this.dragOutsideStartMouse && this.dragOutsideStartPosition && this.current !== undefined)
266+
{
267+
// Calculate movement delta from the original drag start position (in DOM coordinates)
268+
const deltaX = e.clientX - this.dragOutsideStartMouse.x;
269+
const deltaY = e.clientY - this.dragOutsideStartMouse.y;
270+
271+
272+
if (this.xDirection)
273+
{
274+
this.parent.x = this.dragOutsideStartPosition.x + deltaX * this.options.factor;
275+
}
276+
if (this.yDirection)
277+
{
278+
this.parent.y = this.dragOutsideStartPosition.y + deltaY * this.options.factor;
279+
}
280+
281+
if (!this.moved)
282+
{
283+
this.parent.emit('drag-start', {
284+
event: e as any,
285+
screen: new Point(this.dragOutsideStartMouse.x, this.dragOutsideStartMouse.y),
286+
world: this.parent.toWorld(new Point(this.dragOutsideStartMouse.x, this.dragOutsideStartMouse.y)),
287+
viewport: this.parent,
288+
});
289+
this.moved = true;
290+
}
291+
292+
this.parent.emit('moved', { viewport: this.parent, type: 'drag' });
293+
}
294+
};
295+
296+
const documentUpHandler = (e: PointerEvent) =>
297+
{
298+
if (this.isDragOutside)
299+
{
300+
this.isDragOutside = false;
301+
this.dragOutsideStartPosition = undefined;
302+
this.dragOutsideStartMouse = undefined;
303+
304+
if (this.moved)
305+
{
306+
const screen = new Point(e.clientX, e.clientY);
307+
this.parent.emit('drag-end', {
308+
event: e as any,
309+
screen,
310+
world: this.parent.toWorld(screen),
311+
viewport: this.parent,
312+
});
313+
}
314+
315+
this.last = null;
316+
this.moved = false;
317+
this.current = undefined;
318+
}
319+
};
320+
321+
this.addWindowEventHandler('pointermove', documentMoveHandler);
322+
this.addWindowEventHandler('pointerup', documentUpHandler);
323+
}
324+
235325
public override destroy(): void
236326
{
237327
if (typeof window === 'undefined') return;
@@ -341,14 +431,32 @@ export class Drag extends Plugin
341431
}
342432
if (this.checkButtons(event) && this.checkKeyPress(event))
343433
{
344-
this.last = { x: event.global.x, y: event.global.y };
345-
(this.parent.parent || this.parent).toLocal(
346-
this.last,
347-
undefined,
348-
this.last,
349-
);
350434
this.current = event.pointerId;
351435

436+
// Setup dragOutside mode if enabled
437+
if (this.options.dragOutside)
438+
{
439+
this.isDragOutside = true;
440+
this.dragOutsideStartPosition = { x: this.parent.x, y: this.parent.y };
441+
442+
// Store DOM coordinates for dragOutside calculation
443+
// Use the original pointer event to get DOM coordinates
444+
const nativeEvent = event.nativeEvent as PointerEvent;
445+
this.dragOutsideStartMouse = { x: nativeEvent.clientX, y: nativeEvent.clientY };
446+
447+
// Still store PIXI coordinates for normal drag functionality
448+
this.last = { x: event.global.x, y: event.global.y };
449+
}
450+
else
451+
{
452+
this.last = { x: event.global.x, y: event.global.y };
453+
(this.parent.parent || this.parent).toLocal(
454+
this.last,
455+
undefined,
456+
this.last,
457+
);
458+
}
459+
352460
return true;
353461
}
354462
this.last = null;
@@ -369,6 +477,17 @@ export class Drag extends Plugin
369477
}
370478
if (this.last && this.current === event.data.pointerId)
371479
{
480+
// Skip normal drag handling if dragOutside is active (handled by document events)
481+
if (this.options.dragOutside && this.isDragOutside)
482+
{
483+
// Still need to update moved flag for proper event handling
484+
if (!this.moved)
485+
{
486+
this.moved = true;
487+
}
488+
return true; // Return true to indicate drag is handled
489+
}
490+
372491
const x = event.global.x;
373492
const y = event.global.y;
374493
const count = this.parent.input.count();
@@ -435,6 +554,13 @@ export class Drag extends Plugin
435554
return false;
436555
}
437556

557+
// If dragOutside is active, don't process normal up events
558+
// The document handler will handle the drag end
559+
if (this.options.dragOutside && this.isDragOutside)
560+
{
561+
return true; // Return true to indicate we handled it
562+
}
563+
438564
const touches = this.parent.input.touches;
439565

440566
if (touches.length === 1)

0 commit comments

Comments
 (0)