@zag-js/splitter
Version:
Core logic for the splitter widget implemented as a state machine
183 lines (181 loc) • 6.81 kB
JavaScript
import {
__publicField
} from "../chunk-QZ7TP4HQ.mjs";
// src/utils/registry.ts
import { contains, getDocument, getParentElement, isElement } from "@zag-js/dom-query";
import { intersects, toRect } from "./intersects.mjs";
import { compareStackingOrder } from "./stacking-order.mjs";
var SplitterRegistry = class {
constructor(options = {}) {
__publicField(this, "handles", /* @__PURE__ */ new Map());
__publicField(this, "state", {
activeHandleIds: /* @__PURE__ */ new Set(),
isPointerDown: false
});
__publicField(this, "listenerAttached", false);
__publicField(this, "options");
__publicField(this, "handlePointerMove", (event) => {
if (this.state.isPointerDown) return;
const pointerType = this.getPointerType(event);
const intersecting = this.findHitHandles(event.clientX, event.clientY, pointerType);
const newActiveIds = new Set(intersecting.map((h) => h.id));
const changed = newActiveIds.size !== this.state.activeHandleIds.size || [...newActiveIds].some((id) => !this.state.activeHandleIds.has(id));
if (changed) {
this.state.activeHandleIds = newActiveIds;
this.updateCursor(intersecting);
}
});
__publicField(this, "handlePointerDown", (event) => {
const pointerType = this.getPointerType(event);
const intersecting = this.findIntersectingHandles(event.clientX, event.clientY, pointerType, event.target);
if (intersecting.length > 0) {
this.state.isPointerDown = true;
this.state.activeHandleIds = new Set(intersecting.map((h) => h.id));
const point = { x: event.clientX, y: event.clientY };
intersecting.forEach((handle) => {
handle.onActivate(point);
});
this.updateCursor(intersecting);
}
});
__publicField(this, "handlePointerUp", (_event) => {
if (this.state.isPointerDown) {
this.state.isPointerDown = false;
this.handles.forEach((handle) => {
if (this.state.activeHandleIds.has(handle.id)) {
handle.onDeactivate();
}
});
this.state.activeHandleIds.clear();
this.clearGlobalCursor();
}
});
__publicField(this, "globalCursorId", "splitter-registry-cursor");
this.options = {
nonce: options.nonce ?? "",
hitAreaMargins: {
coarse: options.hitAreaMargins?.coarse ?? 15,
fine: options.hitAreaMargins?.fine ?? 5
}
};
}
register(data) {
this.handles.set(data.id, data);
this.attachGlobalListeners();
return () => {
this.handles.delete(data.id);
this.state.activeHandleIds.delete(data.id);
if (this.handles.size === 0) {
this.detachGlobalListeners();
}
};
}
attachGlobalListeners() {
if (this.listenerAttached) return;
this.doc.addEventListener("pointermove", this.handlePointerMove, true);
this.doc.addEventListener("pointerdown", this.handlePointerDown, true);
this.doc.addEventListener("pointerup", this.handlePointerUp, true);
this.listenerAttached = true;
}
detachGlobalListeners() {
if (!this.listenerAttached) return;
this.doc.removeEventListener("pointermove", this.handlePointerMove, true);
this.doc.removeEventListener("pointerdown", this.handlePointerDown, true);
this.doc.removeEventListener("pointerup", this.handlePointerUp, true);
this.listenerAttached = false;
}
getPointerType(event) {
return event.pointerType === "touch" || event.pointerType === "pen" ? "coarse" : "fine";
}
get doc() {
const firstHandle = this.handles.values().next().value;
return getDocument(firstHandle?.element);
}
/**
* Fast hit-test: only checks pointer proximity to handles (no stacking order).
* Used for pointermove cursor feedback.
*/
findHitHandles(x, y, pointerType) {
const intersecting = [];
const margin = this.options.hitAreaMargins[pointerType];
this.handles.forEach((handle) => {
const rect = handle.element.getBoundingClientRect();
const hit = x >= rect.left - margin && x <= rect.right + margin && y >= rect.top - margin && y <= rect.bottom + margin;
if (hit) intersecting.push(handle);
});
return intersecting;
}
/**
* Full intersection check: hit-test + stacking order verification.
* Used for pointerdown activation where correctness matters.
*/
findIntersectingHandles(x, y, pointerType, eventTarget) {
const hits = this.findHitHandles(x, y, pointerType);
const targetElement = isElement(eventTarget) ? eventTarget : null;
if (!targetElement || !contains(this.doc, targetElement)) return hits;
return hits.filter((handle) => {
const dragHandleElement = handle.element;
if (targetElement === dragHandleElement || contains(dragHandleElement, targetElement) || contains(targetElement, dragHandleElement)) {
return true;
}
try {
if (compareStackingOrder(targetElement, dragHandleElement) > 0) {
const dragHandleRect = dragHandleElement.getBoundingClientRect();
let currentElement = targetElement;
while (currentElement) {
if (currentElement.contains(dragHandleElement)) break;
const currentRect = currentElement.getBoundingClientRect();
if (intersects(toRect(currentRect), toRect(dragHandleRect), true)) {
return false;
}
currentElement = getParentElement(currentElement);
}
}
} catch {
}
return true;
});
}
updateCursor(intersecting) {
if (intersecting.length === 0) {
this.clearGlobalCursor();
return;
}
const hasHorizontal = intersecting.some((h) => h.orientation === "horizontal");
const hasVertical = intersecting.some((h) => h.orientation === "vertical");
let cursor = "default";
if (hasHorizontal && hasVertical) {
cursor = "move";
} else if (hasHorizontal) {
cursor = "ew-resize";
} else if (hasVertical) {
cursor = "ns-resize";
}
this.setGlobalCursor(cursor);
}
setGlobalCursor(cursor) {
const doc = this.doc;
let styleEl = doc.getElementById(this.globalCursorId);
const textContent = `* { cursor: ${cursor} !important; }`;
if (styleEl) {
styleEl.textContent = textContent;
} else {
styleEl = doc.createElement("style");
styleEl.id = this.globalCursorId;
styleEl.textContent = textContent;
if (this.options.nonce) {
styleEl.nonce = this.options.nonce;
}
doc.head.appendChild(styleEl);
}
}
clearGlobalCursor() {
const styleEl = this.doc.getElementById(this.globalCursorId);
styleEl?.remove();
}
};
var registry = (opts = {}) => new SplitterRegistry(opts);
export {
SplitterRegistry,
registry
};