UNPKG

@zag-js/splitter

Version:

Core logic for the splitter widget implemented as a state machine

183 lines (181 loc) 6.81 kB
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 };