Skip to content

Commit 24d6348

Browse files
Refactor tile manager's drag and drop to use pointer events (#1557)
* Emulate native drag and drop using PointerEvent API providing touch-based drag and drop behavior. * Improved the deferred drag operations for tiles by moving the drag ghost into a separate layer, simplifying some internal implementation logic. * Use view transitions for drag operations for better UX. * Added a dragMode property to control if and how drag operations are done in the tile manager. * Added a resizeMode property to control if and how resize operations are done in the tile manager. --------- Co-authored-by: Radoslav Karaivanov <[email protected]>
1 parent 6e309c1 commit 24d6348

File tree

14 files changed

+705
-352
lines changed

14 files changed

+705
-352
lines changed

src/components/common/context.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ import type IgcTileManagerComponent from '../tile-manager/tile-manager.js';
66
import type IgcTileComponent from '../tile-manager/tile.js';
77

88
export type TileManagerContext = {
9-
/** The igc-tile-manager element providing the context. */
9+
/** The igc-tile-manager instance. */
1010
instance: IgcTileManagerComponent;
1111
/** The internal CSS grid container of the igc-tile-manager. */
1212
grid: Ref<HTMLElement>;
13-
/** The igc-tile element being dragged. */
14-
draggedItem: IgcTileComponent | null;
15-
lastSwapTile: IgcTileComponent | null;
13+
/** The internal igc-tile-manager overlay container. */
14+
overlay: Ref<HTMLElement>;
1615
};
1716

1817
export type TileContext = {

src/components/common/controllers/async-consumer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77

88
type AsyncContextOptions<T extends Context<unknown, unknown>> = {
99
context: T;
10+
callback?: (value: ContextType<T>, dispose?: () => void) => void;
1011
subscribe?: boolean;
1112
};
1213

@@ -39,6 +40,7 @@ export class AsyncContextConsumer<
3940
if (!this._consumer) {
4041
this._consumer = new ContextConsumer(this._host, {
4142
context: this._options.context,
43+
callback: this._options.callback,
4244
subscribe: this._options.subscribe,
4345
});
4446
}
@@ -48,9 +50,14 @@ export class AsyncContextConsumer<
4850
export function createAsyncContext<
4951
T extends Context<unknown, unknown>,
5052
Host extends ReactiveControllerHost & LitElement,
51-
>(host: Host, context: T): AsyncContextConsumer<T, Host> {
53+
>(
54+
host: Host,
55+
context: T,
56+
callback?: (value: ContextType<T>, dispose?: () => void) => void
57+
): AsyncContextConsumer<T, Host> {
5258
return new AsyncContextConsumer(host, {
5359
context,
60+
callback,
5461
subscribe: true,
5562
}) as AsyncContextConsumer<T, Host>;
5663
}
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import type {
2+
LitElement,
3+
ReactiveController,
4+
ReactiveControllerHost,
5+
} from 'lit';
6+
import type { Ref } from 'lit/directives/ref.js';
7+
import { findElementFromEventPath } from '../util.js';
8+
9+
type DragDropCallback = () => unknown;
10+
11+
type DragEnterCallback = (target: Element) => unknown;
12+
13+
type DragDropConfig = {
14+
/** Whether the drag and drop feature is enabled for the current host. */
15+
enabled?: boolean;
16+
/**
17+
* The mode of the drag and drop operation.
18+
*
19+
* Deferred will create a ghost element and keep the original element
20+
* at its place until the operation completes successfully.
21+
*/
22+
mode?: 'immediate' | 'deferred';
23+
/**
24+
* Whether drag operation should snap the dragged item top left corner
25+
* to the cursor position.
26+
*/
27+
snapToCursor?: boolean;
28+
/**
29+
* Guard function invoked during the `dragStart` callback.
30+
* Returning a truthy value will stop the current drag and drop operation.
31+
*/
32+
skip?: (event: PointerEvent) => boolean;
33+
// REVIEW: API signature
34+
matchTarget?: (target: Element) => boolean;
35+
/**
36+
*
37+
*/
38+
trigger?: () => HTMLElement;
39+
/**
40+
* Contain drag and drop operations to the scope of the passed DOM element.
41+
*/
42+
container?: Ref<HTMLElement>;
43+
/**
44+
* The DOM element that will "host" the ghost drag element when the controller
45+
* is set to **deferred**.
46+
*
47+
* @remarks
48+
* In **immediate** mode, this property is ignored.
49+
*/
50+
layer?: () => HTMLElement;
51+
52+
ghost?: () => HTMLElement;
53+
54+
/** Callback invoked at the beginning of a drag operation. */
55+
dragStart?: DragDropCallback;
56+
/** Callback invoked while dragging the target element. */
57+
dragMove?: DragDropCallback;
58+
59+
dragEnter?: DragEnterCallback;
60+
61+
dragLeave?: DragEnterCallback;
62+
63+
dragOver?: DragEnterCallback;
64+
65+
/** Callback invoked during a drop operation. */
66+
dragEnd?: DragDropCallback;
67+
/** Callback invoked when a drag and drop is cancelled */
68+
dragCancel?: unknown;
69+
};
70+
71+
const additionalEvents = ['pointermove', 'lostpointercapture'] as const;
72+
73+
class DragDropController implements ReactiveController {
74+
private _host: ReactiveControllerHost & LitElement;
75+
private _config: DragDropConfig = {
76+
enabled: true,
77+
mode: 'deferred',
78+
snapToCursor: false,
79+
};
80+
81+
private _dragOffset = { dx: 0, dy: 0 };
82+
private _previousMatch!: Element | null;
83+
84+
private _id = -1;
85+
private _hasPointerCapture = false;
86+
87+
private _ghost: HTMLElement | null = null;
88+
89+
/** Whether `snapToCursor` is enabled for the controller. */
90+
private get _hasSnapping(): boolean {
91+
return Boolean(this._config.snapToCursor);
92+
}
93+
94+
/** Whether the current drag mode is deferred. */
95+
private get _isDeferred(): boolean {
96+
return this._config.mode === 'deferred';
97+
}
98+
99+
/**
100+
* The source element which will capture pointer events and initiate drag mode.
101+
*
102+
* @remarks
103+
* By default that will be the host element itself, unless `trigger` is passed in.
104+
*/
105+
private get _element(): HTMLElement {
106+
return this._config.trigger?.() ?? this._host;
107+
}
108+
109+
/**
110+
* The element being dragged.
111+
*
112+
* @remarks
113+
* When in **deferred** mode this returns a reference to the drag ghost element,
114+
* otherwise it is the host element.
115+
*/
116+
private get _dragItem(): HTMLElement {
117+
return this._isDeferred ? this._ghost! : this._host;
118+
}
119+
120+
/**
121+
* The DOM element that will "host" the ghost drag element when the controller
122+
* is set to **deferred**.
123+
*
124+
* @remarks
125+
* In **immediate** mode, this property is ignored.
126+
*/
127+
private get _layer(): HTMLElement {
128+
if (!this._isDeferred) {
129+
return this._host;
130+
}
131+
132+
return this._config.layer?.() ?? this._host;
133+
}
134+
135+
/** Whether the drag controller is enabled. */
136+
public get enabled(): boolean {
137+
return Boolean(this._config.enabled);
138+
}
139+
140+
constructor(
141+
host: ReactiveControllerHost & LitElement,
142+
config?: DragDropConfig
143+
) {
144+
this._host = host;
145+
this._host.addController(this);
146+
this.setConfig(config);
147+
}
148+
149+
private _setInitialState({
150+
pointerId,
151+
clientX,
152+
clientY,
153+
}: PointerEvent): void {
154+
const rect = this._host.getBoundingClientRect();
155+
156+
this._id = pointerId;
157+
158+
this._dragOffset = {
159+
dx: rect.x - clientX,
160+
dy: rect.y - clientY,
161+
};
162+
}
163+
164+
private _setPointerCaptureState(state: boolean): void {
165+
this._hasPointerCapture = state;
166+
const cssValue = state ? 'none' : '';
167+
168+
Object.assign(this._element.style, {
169+
touchAction: cssValue,
170+
userSelect: cssValue,
171+
});
172+
173+
state
174+
? this._element.setPointerCapture(this._id)
175+
: this._element.releasePointerCapture(this._id);
176+
177+
// Toggle additional events
178+
for (const type of additionalEvents) {
179+
state
180+
? this._host.addEventListener(type, this)
181+
: this._host.removeEventListener(type, this);
182+
}
183+
}
184+
185+
// REVIEW
186+
private _updateMatcher(event: PointerEvent) {
187+
if (!this._config.matchTarget) {
188+
return;
189+
}
190+
191+
const match = document
192+
.elementsFromPoint(event.clientX, event.clientY)
193+
.find((value) => this._config.matchTarget!.call(this._host, value));
194+
195+
if (match && !this._previousMatch) {
196+
this._previousMatch = match;
197+
this._config.dragEnter?.call(this._host, this._previousMatch);
198+
return;
199+
}
200+
201+
if (!match && this._previousMatch) {
202+
this._config.dragLeave?.call(this._host, this._previousMatch);
203+
this._previousMatch = null;
204+
return;
205+
}
206+
207+
if (match && match === this._previousMatch) {
208+
this._config.dragOver?.call(this._host, this._previousMatch);
209+
}
210+
}
211+
212+
private _setPosition(x: number, y: number) {
213+
const { top, left } = this._layer.getBoundingClientRect();
214+
const posX = this._hasSnapping ? x - left : x - left + this._dragOffset.dx;
215+
const posY = this._hasSnapping ? y - top : y - top + this._dragOffset.dy;
216+
217+
this._dragItem.style.transform = `translate(${posX}px,${posY}px)`;
218+
}
219+
220+
private _createDragGhost({ clientX, clientY }: PointerEvent): void {
221+
this._ghost = this._config.ghost
222+
? this._config.ghost.call(this._host)
223+
: createDefaultDragGhost(this._host.getBoundingClientRect());
224+
225+
this._setPosition(clientX, clientY);
226+
this._layer.append(this._ghost);
227+
}
228+
229+
private _removeGhost(): void {
230+
if (this._ghost) {
231+
this._ghost.remove();
232+
this._ghost = null;
233+
}
234+
}
235+
236+
private _shouldSkip(event: PointerEvent): boolean {
237+
return (
238+
this._config.skip?.call(this._host, event) ||
239+
!findElementFromEventPath((e) => e === this._element, event)
240+
);
241+
}
242+
243+
private _handlePointerDown(event: PointerEvent): void {
244+
if (this._shouldSkip(event)) {
245+
return;
246+
}
247+
248+
// REVIEW
249+
event.preventDefault();
250+
251+
this._setInitialState(event);
252+
this._createDragGhost(event);
253+
254+
this._config.dragStart?.call(this._host);
255+
this._setPointerCaptureState(true);
256+
}
257+
258+
private _handlePointerMove(event: PointerEvent): void {
259+
if (!this._hasPointerCapture) {
260+
return;
261+
}
262+
263+
this._updateMatcher(event);
264+
this._config.dragMove?.call(this._host);
265+
266+
this._setPosition(event.clientX, event.clientY);
267+
}
268+
269+
private _handlePointerEnd(_: PointerEvent): void {
270+
this._config.dragEnd?.call(this._host);
271+
this.dispose();
272+
}
273+
274+
/** Updates the drag and drop controller configuration. */
275+
public setConfig(value?: DragDropConfig): void {
276+
Object.assign(this._config, value);
277+
}
278+
279+
/** @internal */
280+
public handleEvent(event: PointerEvent): void {
281+
if (!this.enabled) {
282+
return;
283+
}
284+
285+
switch (event.type) {
286+
case 'touchstart':
287+
case 'dragstart':
288+
event.preventDefault();
289+
break;
290+
case 'pointerdown':
291+
this._handlePointerDown(event);
292+
break;
293+
case 'pointermove':
294+
this._handlePointerMove(event);
295+
break;
296+
case 'lostpointercapture':
297+
this._handlePointerEnd(event);
298+
break;
299+
}
300+
}
301+
302+
/** Stops any drag operation and cleans up state and additional elements. */
303+
public dispose(): void {
304+
this._previousMatch = null;
305+
this._removeGhost();
306+
this._setPointerCaptureState(false);
307+
}
308+
309+
public hostConnected(): void {
310+
this._host.addEventListener('dragstart', this);
311+
this._host.addEventListener('touchstart', this, { passive: false });
312+
this._host.addEventListener('pointerdown', this);
313+
}
314+
315+
public hostDisconnected(): void {
316+
this._host.removeEventListener('dragstart', this);
317+
this._host.removeEventListener('touchstart', this);
318+
this._host.removeEventListener('pointerdown', this);
319+
}
320+
}
321+
322+
function createDefaultDragGhost(rect: DOMRect): HTMLElement {
323+
const element = document.createElement('div');
324+
Object.assign(element.style, {
325+
position: 'absolute',
326+
left: rect.x,
327+
top: rect.y,
328+
width: rect.width,
329+
height: rect.height,
330+
zIndex: 1000,
331+
background: 'gold',
332+
});
333+
334+
return element;
335+
}
336+
337+
/**
338+
* Adds a drag and drop controller to the given host
339+
*/
340+
export function addDragDropController(
341+
host: ReactiveControllerHost & LitElement,
342+
config?: DragDropConfig
343+
): DragDropController {
344+
return new DragDropController(host, config);
345+
}

0 commit comments

Comments
 (0)