ui/Dock.tstypescript
import { createEvents } from "../Events";
import type { EventsHandler } from "../Events";
import type { Dialog, DialogElement } from "./Dialog";
export type DockSide = "top" | "bottom" | "left" | "right" | "center";
/** How a side lays its dialogs out. */
export type DockDirection = "row" | "column";
/** Which third of a side a dialog was dropped on, and so where it goes. */
export type DockPlacement = "start" | "middle" | "end";
export interface IDockManagerParams {
host: HTMLElement;
/** How close to an edge a dragged dialog has to come before that side offers itself. */
edgeThreshold?: number;
/** Size a side takes when nothing better is known. */
defaultSize?: number;
}
type DockEventsList = ["dock", "undock", "layout"];
const DOCK_EVENTS: DockEventsList = ["dock", "undock", "layout"];
/** Default `edgeThreshold`: the band along a free edge where an unused side offers itself. */
const DEFAULT_EDGE_THRESHOLD = 56;
/** Default `defaultSize`: what a side opens at when the dialog it takes has no size drawn yet. */
const DEFAULT_SIZE = 280;
/** No side gets thinner than this, however far its seam is dragged inwards. */
const MIN_ZONE_SIZE = 60;
/** What a side has to leave of the rectangle it takes from, so the sides after it still fit. */
const MIN_FREE_SIZE = 120;
/** The least of a side a dialog stacked in it keeps, so a seam never squeezes one out of sight. */
const MIN_ITEM_SHARE = 0.08;
/** How much of a side's length each of its end bands takes. */
const END_BAND = 0.3;
/** What the pointer gets, centered on the gap between docked dialogs. */
const SPLITTER_GRAB = 11;
/** A finger is blunter than a cursor and needs more of an edge to catch. */
const SPLITTER_GRAB_COARSE = 22;
function splitterGrab(): number {
const coarse = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;
return coarse ? SPLITTER_GRAB_COARSE : SPLITTER_GRAB;
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface DockZone {
side: DockSide;
direction: DockDirection;
size: number;
rect: Rect;
el: HTMLElement;
items: Dialog<any>[];
shares: number[];
}
/** A draggable edge: the side's own when `after` is null, otherwise a seam inside its stack. */
interface DockSplitter {
el: HTMLElement;
zone: DockZone;
after: number | null;
}
interface FloatingState {
left: string;
top: string;
width: string;
height: string;
position: string;
}
interface DialogState {
attached: boolean;
opened: boolean;
floating: FloatingState | null;
}
function isVertical(side: DockSide): boolean {
return side === "left" || side === "right";
}
/** Stacking that runs the length of a side. */
function alongDirection(side: DockSide): DockDirection {
return isVertical(side) ? "column" : "row";
}
/** Stacking that cuts a side across, so its dialogs divide its thickness. */
function acrossDirection(side: DockSide): DockDirection {
return isVertical(side) ? "row" : "column";
}
function stackFlex(fraction: number): string {
return `${fraction} 1 calc(${fraction * 100}% - var(--og-dock-gap, 0px))`;
}
function dialogOf(node: Node): Dialog<any> | null {
if (!(node instanceof HTMLElement) || !node.classList.contains("og-ddialog")) {
return null;
}
return (node as DialogElement).__og_dialog__ || null;
}
/**
* Docks dialogs to the sides of a host element. Sides claim bands in first-use
* order, while dialogs on the same side stack and share its length.
*
* Draggable edges render in a separate layer, keeping panels flush and preventing
* clipped seams or thickness-related layout adjustments.
*/
export class DockManager {
public readonly events: EventsHandler<DockEventsList>;
protected _host: HTMLElement;
protected _edgeThreshold: number;
protected _defaultSize: number;
protected _zones: DockZone[];
protected _splitters: DockSplitter[];
protected _splitterLayer: HTMLElement;
protected _grab: number;
protected _dialogs: Map<Dialog<any>, DialogState>;
protected _hint: HTMLElement;
protected _free: Rect;
protected _resizeObserver: ResizeObserver;
protected _domObserver: MutationObserver;
protected _dragging: Dialog<any> | null;
protected _drop: { side: DockSide; placement: DockPlacement } | null;
protected _pointer: { x: number; y: number };
constructor(params: IDockManagerParams) {
this.events = createEvents(DOCK_EVENTS, this);
this._host = params.host;
this._edgeThreshold = params.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD;
this._defaultSize = params.defaultSize ?? DEFAULT_SIZE;
this._zones = [];
this._splitters = [];
this._grab = splitterGrab();
this._dialogs = new Map();
this._free = { x: 0, y: 0, width: 0, height: 0 };
this._dragging = null;
this._drop = null;
this._pointer = { x: 0, y: 0 };
this._splitterLayer = document.createElement("div");
this._splitterLayer.className = "og-dock-splitters";
this._host.appendChild(this._splitterLayer);
this._hint = document.createElement("div");
this._hint.className = "og-dock-hint";
this._hint.style.display = "none";
this._host.appendChild(this._hint);
this._resizeObserver = new ResizeObserver(() => this.layout());
this._resizeObserver.observe(this._host);
this._domObserver = new MutationObserver(this._onMutations);
this._domObserver.observe(this._host, { childList: true });
for (const el of this._host.querySelectorAll(".og-ddialog")) {
this._attachElement(el);
}
document.addEventListener("pointerdown", this._onPointerDown, true);
}
/** The rectangle no side has taken, in host coordinates. */
public get freeRect(): Rect {
return { ...this._free };
}
public getSide(dialog: Dialog<any>): DockSide | null {
return this._zones.find((zone) => zone.items.includes(dialog))?.side ?? null;
}
public isDocked(dialog: Dialog<any>): boolean {
return this.getSide(dialog) !== null;
}
public attach(dialog: Dialog<any>): void {
this._register(dialog);
this._openDocked(dialog);
}
public detach(dialog: Dialog<any>): void {
const state = this._dialogs.get(dialog);
if (!state) return;
if (state.attached) {
state.attached = false;
dialog.events.off("dragstart", this._onDragStart);
dialog.events.off("dragend", this._onDragEnd);
dialog.events.off("visibility", this._onDialogVisibility);
}
this.undock(dialog);
this._dialogs.delete(dialog);
}
/**
* Puts the dialog under the manager's watch, which is all that being known to it means:
* where it ends up is left to whoever asked - `attach` reads the dialog's own `dock`
* option, `dock` is told a side outright, and either of them can be the first here.
*/
protected _register(dialog: Dialog<any>): void {
const state = this._state(dialog);
if (state.attached) return;
state.attached = true;
dialog.events.on("dragstart", this._onDragStart, this);
dialog.events.on("dragend", this._onDragEnd, this);
dialog.events.on("visibility", this._onDialogVisibility, this);
}
protected _state(dialog: Dialog<any>): DialogState {
let state = this._dialogs.get(dialog);
if (!state) {
state = { attached: false, opened: false, floating: null };
this._dialogs.set(dialog, state);
}
return state;
}
public dock(dialog: Dialog<any>, side: DockSide, placement: DockPlacement = "end"): void {
this._register(dialog);
this._state(dialog).opened = true;
if (this.getSide(dialog) === side) return;
this.undock(dialog);
this._remember(dialog);
const untouched = !this._zones.some((item) => item.side === side);
const zone = this._getZone(side);
if (untouched) {
zone.size = this._dockSize(dialog, side);
}
zone.direction = placement === "middle" ? acrossDirection(side) : alongDirection(side);
const share = zone.shares.length ? this._averageShare(zone) : 1;
if (placement === "start" && zone.items.length) {
zone.items.unshift(dialog);
zone.shares.unshift(share);
} else {
zone.items.push(dialog);
zone.shares.push(share);
}
this._normalize(zone);
dialog.el!.classList.add("og-ddialog__docked");
this._refresh(zone);
this.events.dispatch(this.events.dock, dialog, side);
}
public undock(dialog: Dialog<any>): void {
const zone = this._zones.find((item) => item.items.includes(dialog));
if (!zone) return;
const index = zone.items.indexOf(dialog);
zone.items.splice(index, 1);
zone.shares.splice(index, 1);
dialog.el!.classList.remove("og-ddialog__docked");
if (dialog.el!.isConnected) {
this._host.appendChild(dialog.el!);
}
this._restore(dialog);
if (zone.items.length) {
this._normalize(zone);
this._refresh(zone);
} else {
this._removeZone(zone);
this._rebuildSplitters();
this.layout();
}
this.events.dispatch(this.events.undock, dialog);
}
/** Recomputes every side from the host rectangle, taking a band off it in dock order. */
public layout(): void {
let rest: Rect = { x: 0, y: 0, width: this._host.clientWidth, height: this._host.clientHeight };
for (const zone of this._zones) {
if (zone.side === "center") continue;
if (!this._visibleItems(zone).length) {
zone.el.style.display = "none";
zone.rect = { x: rest.x, y: rest.y, width: 0, height: 0 };
continue;
}
const band = this._band(rest, zone.side, zone.size);
zone.size = band.size;
rest = band.rest;
this._applyZoneRect(zone, band.zone);
}
this._free = rest;
const center = this._zones.find((zone) => zone.side === "center");
if (center) {
if (this._visibleItems(center).length) {
this._applyZoneRect(center, rest);
} else {
center.rect = rest;
center.el.style.display = "none";
}
}
this._positionSplitters();
this.events.dispatch(this.events.layout, this._free);
}
public destroy(): void {
document.removeEventListener("pointerdown", this._onPointerDown, true);
document.removeEventListener("mousemove", this._onDragMove);
document.removeEventListener("pointermove", this._onDragMove);
this._resizeObserver.disconnect();
this._domObserver.disconnect();
for (const zone of [...this._zones]) {
for (const dialog of [...zone.items]) {
this.undock(dialog);
}
zone.el.remove();
}
this._zones = [];
for (const dialog of [...this._dialogs.keys()]) {
this.detach(dialog);
}
this._splitters = [];
this._splitterLayer.remove();
this._hint.remove();
this._dragging = null;
this._drop = null;
}
/** What a side opens at for this dialog: its width standing on end, or its height. */
protected _dockSize(dialog: Dialog<any> | null, side: DockSide): number {
if (!dialog?.el) return this._defaultSize;
const vertical = isVertical(side);
const drawn = vertical ? dialog.el.offsetWidth : dialog.el.offsetHeight;
return drawn || (vertical ? dialog.width : dialog.height) || this._defaultSize;
}
protected _applyZoneRect(zone: DockZone, rect: Rect): void {
zone.rect = rect;
zone.el.style.display = "flex";
zone.el.style.left = `${rect.x}px`;
zone.el.style.top = `${rect.y}px`;
zone.el.style.width = `${rect.width}px`;
zone.el.style.height = `${rect.height}px`;
}
/** The band a side takes off a rectangle, and what the sides after it will see. */
protected _band(rect: Rect, side: DockSide, size: number): { zone: Rect; rest: Rect; size: number } {
const along = isVertical(side) ? rect.width : rect.height;
const clamped = Math.max(MIN_ZONE_SIZE, Math.min(size, along - MIN_FREE_SIZE));
switch (side) {
case "top":
return {
size: clamped,
zone: { x: rect.x, y: rect.y, width: rect.width, height: clamped },
rest: { x: rect.x, y: rect.y + clamped, width: rect.width, height: rect.height - clamped }
};
case "bottom":
return {
size: clamped,
zone: { x: rect.x, y: rect.y + rect.height - clamped, width: rect.width, height: clamped },
rest: { x: rect.x, y: rect.y, width: rect.width, height: rect.height - clamped }
};
case "left":
return {
size: clamped,
zone: { x: rect.x, y: rect.y, width: clamped, height: rect.height },
rest: { x: rect.x + clamped, y: rect.y, width: rect.width - clamped, height: rect.height }
};
default:
return {
size: clamped,
zone: { x: rect.x + rect.width - clamped, y: rect.y, width: clamped, height: rect.height },
rest: { x: rect.x, y: rect.y, width: rect.width - clamped, height: rect.height }
};
}
}
/**
* Returns a newcomer's stack share: the existing average, normalized to 1 / (count + 1).
*/
protected _slot(rect: Rect, direction: DockDirection, atStart: boolean, count: number): Rect {
const fraction = 1 / (count + 1);
if (direction === "column") {
const height = rect.height * fraction;
return { x: rect.x, y: atStart ? rect.y : rect.y + rect.height - height, width: rect.width, height };
}
const width = rect.width * fraction;
return { x: atStart ? rect.x : rect.x + rect.width - width, y: rect.y, width, height: rect.height };
}
protected _attachElement(node: Node): void {
const dialog = dialogOf(node);
if (dialog) {
this.attach(dialog);
}
}
protected _onMutations = (records: MutationRecord[]): void => {
for (const record of records) {
for (const node of record.addedNodes) {
this._attachElement(node);
}
for (const node of record.removedNodes) {
const dialog = dialogOf(node);
if (dialog && !this._host.contains(node)) {
this.detach(dialog);
}
}
}
};
/**
* A dialog can ask to open docked, and it takes its side as soon as it turns up - a
* dialog created hidden would otherwise let a later one claim the corner first. A side
* with nothing shown in it costs no room until something is.
*/
protected _openDocked(dialog: Dialog<any>): void {
const side = dialog.dock;
const state = this._state(dialog);
if (!side || state.opened) return;
state.opened = true;
this.dock(dialog, side);
}
protected _onDialogVisibility = (visible: boolean, dialog: Dialog<any>): void => {
if (visible) {
this._openDocked(dialog);
}
const zone = this._zones.find((item) => item.items.includes(dialog));
if (zone) {
this._refresh(zone);
}
};
protected _getZone(side: DockSide): DockZone {
const existing = this._zones.find((zone) => zone.side === side);
if (existing) return existing;
const el = document.createElement("div");
el.className = `og-dock og-dock__${side}`;
this._host.insertBefore(el, this._splitterLayer);
this._domObserver.observe(el, { childList: true });
const zone: DockZone = {
side,
direction: alongDirection(side),
size: this._defaultSize,
rect: { x: 0, y: 0, width: 0, height: 0 },
el,
items: [],
shares: []
};
this._zones.push(zone);
return zone;
}
protected _removeZone(zone: DockZone): void {
zone.el.remove();
this._zones = this._zones.filter((item) => item !== zone);
}
protected _averageShare(zone: DockZone): number {
return zone.shares.reduce((sum, share) => sum + share, 0) / zone.shares.length;
}
protected _normalize(zone: DockZone): void {
const total = zone.shares.reduce((sum, share) => sum + share, 0) || 1;
zone.shares = zone.shares.map((share) => Math.max(MIN_ITEM_SHARE, share / total));
}
// A hidden panel leaves its share behind, so the visible ones rarely add up to one and
// are always weighed against their own sum: flex that falls short of one stops short of
// filling the side, and a seam placed on the raw shares would miss the boundary
protected _shares(zone: DockZone): { visible: number[]; total: number } {
const visible = this._visibleItems(zone);
const total = visible.reduce((sum, index) => sum + zone.shares[index], 0) || 1;
return { visible, total };
}
protected _visibleItems(zone: DockZone): number[] {
const visible: number[] = [];
zone.items.forEach((dialog, index) => {
if (dialog.getVisibility()) {
visible.push(index);
}
});
return visible;
}
protected _refresh(zone: DockZone): void {
this._renderZone(zone);
this._rebuildSplitters();
this.layout();
}
protected _renderZone(zone: DockZone): void {
const { visible, total } = this._shares(zone);
const shown = new Set(visible);
const children = zone.items.map((dialog, index) => {
const el = dialog.el!;
if (shown.has(index)) {
el.style.flex = stackFlex(zone.shares[index] / total);
}
return el;
});
zone.el.style.flexDirection = zone.direction;
zone.el.replaceChildren(...children);
}
protected _rebuildSplitters(): void {
this._splitters = [];
this._splitterLayer.replaceChildren();
for (const zone of this._zones) {
if (!this._visibleItems(zone).length) continue;
if (zone.side !== "center") {
this._splitters.push({ el: this._createSplitter(zone, null), zone, after: null });
}
const visible = this._visibleItems(zone);
for (let order = 1; order < visible.length; order++) {
const after = visible[order];
this._splitters.push({ el: this._createSplitter(zone, after), zone, after });
}
}
}
protected _createSplitter(zone: DockZone, after: number | null): HTMLElement {
const el = document.createElement("div");
const upright = after === null ? isVertical(zone.side) : zone.direction === "row";
el.className = `og-dock-splitter og-dock-splitter__${upright ? "v" : "h"}`;
el.addEventListener("pointerdown", (event) => this._onSplitterDown(event, zone, after));
this._splitterLayer.appendChild(el);
return el;
}
protected _positionSplitters(): void {
const grab = this._grab;
const half = grab * 0.5;
for (const splitter of this._splitters) {
const rect = splitter.zone.rect;
const style = splitter.el.style;
if (splitter.after === null) {
const side = splitter.zone.side;
if (isVertical(side)) {
style.left = `${(side === "left" ? rect.x + rect.width : rect.x) - half}px`;
style.top = `${rect.y}px`;
style.width = `${grab}px`;
style.height = `${rect.height}px`;
} else {
style.left = `${rect.x}px`;
style.top = `${(side === "top" ? rect.y + rect.height : rect.y) - half}px`;
style.width = `${rect.width}px`;
style.height = `${grab}px`;
}
continue;
}
const offset = this._itemOffset(splitter.zone, splitter.after);
if (splitter.zone.direction === "column") {
style.left = `${rect.x}px`;
style.top = `${rect.y + offset - half}px`;
style.width = `${rect.width}px`;
style.height = `${grab}px`;
} else {
style.left = `${rect.x + offset - half}px`;
style.top = `${rect.y}px`;
style.width = `${grab}px`;
style.height = `${rect.height}px`;
}
}
}
protected _itemOffset(zone: DockZone, item: number): number {
const { visible, total } = this._shares(zone);
const length = zone.direction === "column" ? zone.rect.height : zone.rect.width;
let before = 0;
for (const index of visible) {
if (index === item) break;
before += zone.shares[index];
}
return (before / total) * length;
}
protected _onSplitterDown = (event: PointerEvent, zone: DockZone, after: number | null): void => {
const el = event.currentTarget as HTMLElement;
event.preventDefault();
el.setPointerCapture(event.pointerId);
el.classList.add("og-dock-splitter__active");
const move = after === null ? this._zoneResizer(zone, event) : this._stackResizer(zone, after, event);
const up = (upEvent: PointerEvent) => {
el.releasePointerCapture(upEvent.pointerId);
el.classList.remove("og-dock-splitter__active");
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerup", up);
el.removeEventListener("pointercancel", up);
};
el.addEventListener("pointermove", move);
el.addEventListener("pointerup", up);
el.addEventListener("pointercancel", up);
};
protected _zoneResizer(zone: DockZone, start: PointerEvent): (event: PointerEvent) => void {
const startSize = zone.size;
const startX = start.clientX;
const startY = start.clientY;
return (event: PointerEvent) => {
const dx = event.clientX - startX;
const dy = event.clientY - startY;
zone.size =
zone.side === "left"
? startSize + dx
: zone.side === "right"
? startSize - dx
: zone.side === "top"
? startSize + dy
: startSize - dy;
this.layout();
};
}
protected _stackResizer(zone: DockZone, after: number, start: PointerEvent): (event: PointerEvent) => void {
const { visible, total } = this._shares(zone);
const before = visible[visible.indexOf(after) - 1];
const vertical = zone.direction === "column";
const from = vertical ? start.clientY : start.clientX;
const span = vertical ? zone.el.clientHeight : zone.el.clientWidth;
const startBefore = zone.shares[before];
const pair = startBefore + zone.shares[after];
return (event: PointerEvent) => {
// The pointer walks the whole side, the shares only cover the visible part of it
const delta = (((vertical ? event.clientY : event.clientX) - from) / (span || 1)) * total;
zone.shares[before] = Math.max(MIN_ITEM_SHARE, Math.min(pair - MIN_ITEM_SHARE, startBefore + delta));
zone.shares[after] = pair - zone.shares[before];
zone.items[before].el!.style.flex = stackFlex(zone.shares[before] / total);
zone.items[after].el!.style.flex = stackFlex(zone.shares[after] / total);
this._positionSplitters();
};
}
protected _remember(dialog: Dialog<any>): void {
const state = this._state(dialog);
if (state.floating) return;
const style = dialog.el!.style;
state.floating = {
left: style.left,
top: style.top,
width: style.width,
height: style.height,
position: style.position
};
}
protected _restore(dialog: Dialog<any>): void {
const floating = this._dialogs.get(dialog)?.floating;
const style = dialog.el!.style;
style.flex = "";
if (!floating) return;
style.left = floating.left;
style.top = floating.top;
style.width = floating.width;
style.height = floating.height;
style.position = floating.position;
this._dialogs.get(dialog)!.floating = null;
}
protected _onPointerDown = (event: PointerEvent): void => {
this._pointer = { x: event.clientX, y: event.clientY };
};
protected _onDragStart = (dialog: Dialog<any>): void => {
if (this.isDocked(dialog)) {
const width = dialog.width;
const height = dialog.height;
const rect = this._host.getBoundingClientRect();
this.undock(dialog);
dialog.setPosition(
Math.max(0, this._pointer.x - rect.left - width * 0.5),
Math.max(0, this._pointer.y - rect.top - 10)
);
dialog.el!.style.width = `${width}px`;
dialog.el!.style.height = `${height}px`;
}
this._dragging = dialog;
this._drop = null;
document.addEventListener("mousemove", this._onDragMove);
document.addEventListener("pointermove", this._onDragMove);
};
protected _onDragMove = (event: MouseEvent | PointerEvent): void => {
if (!this._dragging) return;
this._pointer = { x: event.clientX, y: event.clientY };
this._drop = this._dropAt(event.clientX, event.clientY);
this._showHint(this._drop);
};
protected _onDragEnd = (dialog: Dialog<any>): void => {
document.removeEventListener("mousemove", this._onDragMove);
document.removeEventListener("pointermove", this._onDragMove);
const drop = this._drop;
this._showHint(null);
this._dragging = null;
this._drop = null;
if (drop) {
this.dock(dialog, drop.side, drop.placement);
}
};
protected _shownZone(side: DockSide): DockZone | null {
const zone = this._zones.find((item) => item.side === side);
return zone && this._visibleItems(zone).length ? zone : null;
}
protected _sideAt(clientX: number, clientY: number, host: DOMRect): DockSide | null {
const x = clientX - host.left;
const y = clientY - host.top;
if (x < 0 || y < 0 || x > host.width || y > host.height) return null;
const free = this._free;
const nearest = (
[
["left", x - free.x],
["right", free.x + free.width - x],
["top", y - free.y],
["bottom", free.y + free.height - y]
] as [DockSide, number][]
)
.filter(([side]) => !this._shownZone(side))
.sort((left, right) => left[1] - right[1])[0];
if (nearest && nearest[1] >= 0 && nearest[1] <= this._edgeThreshold) {
return nearest[0];
}
// A side already in use is aimed at by pointing anywhere over it - what is offered
// is what is seen
for (const zone of this._zones) {
const rect = zone.rect;
if (
zone.side !== "center" &&
this._visibleItems(zone).length &&
x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height
) {
return zone.side;
}
}
return null;
}
protected _sideRect(side: DockSide): Rect {
if (side === "center") return this._free;
const shown = this._shownZone(side);
if (shown) return shown.rect;
const zone = this._zones.find((item) => item.side === side);
const size = zone ? zone.size : this._dockSize(this._dragging, side);
return this._band(this._free, side, size).zone;
}
protected _dropAt(clientX: number, clientY: number): { side: DockSide; placement: DockPlacement } | null {
const host = this._host.getBoundingClientRect();
const side = this._sideAt(clientX, clientY, host);
if (!side) return null;
const rect = this._sideRect(side);
const along = isVertical(side)
? (clientY - host.top - rect.y) / (rect.height || 1)
: (clientX - host.left - rect.x) / (rect.width || 1);
const clamped = Math.max(0, Math.min(1, along));
return {
side,
placement: clamped < END_BAND ? "start" : clamped > 1 - END_BAND ? "end" : "middle"
};
}
protected _showHint(drop: { side: DockSide; placement: DockPlacement } | null): void {
if (!drop) {
this._hint.style.display = "none";
return;
}
const zone = this._shownZone(drop.side);
let rect = this._sideRect(drop.side);
if (zone) {
const visible = this._visibleItems(zone).length;
if (visible > 0) {
const direction = drop.placement === "middle" ? acrossDirection(drop.side) : alongDirection(drop.side);
rect = this._slot(rect, direction, drop.placement === "start", visible);
}
}
this._hint.style.display = "block";
this._hint.style.left = `${rect.x}px`;
this._hint.style.top = `${rect.y}px`;
this._hint.style.width = `${rect.width}px`;
this._hint.style.height = `${rect.height}px`;
}
}