UNPKG

dockview-core

Version:

Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript

192 lines (191 loc) 8.18 kB
import { disableIframePointEvents } from '../../dom'; import { addDisposableListener, Emitter } from '../../events'; import { CompositeDisposable } from '../../lifecycle'; /** * Singleton — only one pointer-driven drag active at a time. * * State is shared across every Dockview instance on the page. Targets * from instance B receive hit-tests from drags originating in instance A; * that's intentional for cross-instance drops since `LocalSelectionTransfer` * is also process-wide. The corollary is that every Tabs subscriber to * `onDragMove` fires for every pointer drag globally — each subscriber * hit-tests against its own DOM, so this is O(N) per pointermove where N * is the number of registered listeners across all instances. */ export class PointerDragController extends CompositeDisposable { static getInstance() { if (!PointerDragController._instance) { PointerDragController._instance = new PointerDragController(); } return PointerDragController._instance; } constructor() { super(); this._targets = new Set(); /** Kept in sync with `_targets` so hit-testing is allocation-free. */ this._targetByElement = new Map(); this._onDragStart = new Emitter(); this.onDragStart = this._onDragStart.event; this._onDragMove = new Emitter(); this.onDragMove = this._onDragMove.event; this._onDragEnd = new Emitter(); this.onDragEnd = this._onDragEnd.event; this.addDisposables(this._onDragStart, this._onDragMove, this._onDragEnd); } get active() { return this._active; } registerTarget(target) { this._targets.add(target); this._targetByElement.set(target.element, target); return { dispose: () => { this._targets.delete(target); if (this._targetByElement.get(target.element) === target) { this._targetByElement.delete(target.element); } if (this._currentTarget === target) { this._currentTarget = undefined; } }, }; } beginDrag(args) { var _a, _b, _c; if (this._active) { this.cancel(); } const { pointerEvent, source } = args; // Call `getData()` before mutating controller state — a throw // here would otherwise leave `_active` populated with no window // listeners installed, blocking every subsequent drag. const dataDisposable = args.getData(); this._active = { pointerId: pointerEvent.pointerId, startX: pointerEvent.clientX, startY: pointerEvent.clientY, source, }; this._onDragMoveCallback = args.onDragMove; this._onDragEndCallback = args.onDragEnd; this._dataDisposable = dataDisposable; this._ghost = args.ghost; // Iframes capture pointermove once the cursor crosses into them, // which would freeze the drag from the parent window's POV. this._iframeShield = disableIframePointEvents((_a = source.ownerDocument) !== null && _a !== void 0 ? _a : document); const startEvent = { clientX: pointerEvent.clientX, clientY: pointerEvent.clientY, pointerEvent, }; this._onDragStart.fire(startEvent); // Source's owning window — popout drags fire on their own window, // not the main one. const targetWindow = (_c = (_b = source.ownerDocument) === null || _b === void 0 ? void 0 : _b.defaultView) !== null && _c !== void 0 ? _c : window; this._moveListener = addDisposableListener(targetWindow, 'pointermove', (e) => { if (!this._active || e.pointerId !== this._active.pointerId) { return; } this._handleMove(e); }); this._upListener = addDisposableListener(targetWindow, 'pointerup', (e) => { if (!this._active || e.pointerId !== this._active.pointerId) { return; } this._handleEnd(e, true); }); this._cancelListener = addDisposableListener(targetWindow, 'pointercancel', (e) => { if (!this._active || e.pointerId !== this._active.pointerId) { return; } this._handleEnd(e, false); }); } cancel() { var _a, _b; if (!this._active) { return; } (_a = this._currentTarget) === null || _a === void 0 ? void 0 : _a.handleDragLeave(); this._teardown(); (_b = this._dataDisposable) === null || _b === void 0 ? void 0 : _b.dispose(); this._dataDisposable = undefined; } _findTargetUnder(x, y) { var _a, _b; // `elementsFromPoint` is topmost-first; walk up to find the closest // registered ancestor (so a tab beats the layout-root that contains it). // Use the source's owning document so popout drags hit their own targets. const sourceDoc = (_b = (_a = this._active) === null || _a === void 0 ? void 0 : _a.source.ownerDocument) !== null && _b !== void 0 ? _b : document; const elements = sourceDoc.elementsFromPoint(x, y); for (const el of elements) { let current = el; while (current) { const target = this._targetByElement.get(current); if (target) { return target; } current = current.parentElement; } } return undefined; } _handleMove(e) { var _a, _b, _c; (_a = this._ghost) === null || _a === void 0 ? void 0 : _a.update(e.clientX, e.clientY); const dragEvent = { clientX: e.clientX, clientY: e.clientY, pointerEvent: e, }; const newTarget = this._findTargetUnder(e.clientX, e.clientY); if (newTarget !== this._currentTarget) { (_b = this._currentTarget) === null || _b === void 0 ? void 0 : _b.handleDragLeave(); this._currentTarget = newTarget; } if (newTarget) { newTarget.handleDragOver(dragEvent); } (_c = this._onDragMoveCallback) === null || _c === void 0 ? void 0 : _c.call(this, dragEvent); this._onDragMove.fire(dragEvent); } _handleEnd(e, dropped) { var _a; const dragEvent = { clientX: e.clientX, clientY: e.clientY, pointerEvent: e, }; if (dropped && this._currentTarget) { this._currentTarget.handleDrop(dragEvent); } else { (_a = this._currentTarget) === null || _a === void 0 ? void 0 : _a.handleDragLeave(); } const onEnd = this._onDragEndCallback; const dataDisposable = this._dataDisposable; this._teardown(); this._dataDisposable = undefined; // Defer disposal so drop handlers can still read the transfer data. setTimeout(() => dataDisposable === null || dataDisposable === void 0 ? void 0 : dataDisposable.dispose(), 0); onEnd === null || onEnd === void 0 ? void 0 : onEnd(dragEvent, dropped); this._onDragEnd.fire(dragEvent); } _teardown() { var _a, _b, _c, _d, _e; this._currentTarget = undefined; this._active = undefined; this._onDragMoveCallback = undefined; this._onDragEndCallback = undefined; (_a = this._ghost) === null || _a === void 0 ? void 0 : _a.dispose(); this._ghost = undefined; (_b = this._iframeShield) === null || _b === void 0 ? void 0 : _b.release(); this._iframeShield = undefined; (_c = this._moveListener) === null || _c === void 0 ? void 0 : _c.dispose(); (_d = this._upListener) === null || _d === void 0 ? void 0 : _d.dispose(); (_e = this._cancelListener) === null || _e === void 0 ? void 0 : _e.dispose(); this._moveListener = undefined; this._upListener = undefined; this._cancelListener = undefined; } }