dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
192 lines (191 loc) • 8.18 kB
JavaScript
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;
}
}