import { css, html } from "lit"; import { property, state } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { subscribeToDrag, subscribeToEndDrag } from "../../core/drag-state.ts"; import type { DragState } from "../../core/drag-state.ts"; /** * CTDropZone - Marks a region as droppable and emits events when valid drops occur * * Purely behavioral component with no visual representation except CSS feedback * during drag-over. Subscribes to drag state and checks if pointer intersects * this element's bounding box. * * @element ct-drop-zone * * @property {string} accept - Optional filter by drag source type (comma-separated) * * @fires ct-drag-enter - When a valid drag enters the zone * @fires ct-drag-leave - When a drag leaves the zone * @fires ct-drop - When a valid drop occurs (drag ends while over this zone) * * @slot - Default slot for wrapped content * * @example * *
Drop items here
*
*/ export class CTDropZone extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: block; } :host([drag-over]) { outline: 2px dashed var(--ct-color-primary, #0066cc); outline-offset: -2px; } `, ]; @property({ type: String }) accept?: string; @state() private _isDragOver: boolean = false; private _unsubscribeDrag?: () => void; private _unsubscribeEndDrag?: () => void; override connectedCallback() { super.connectedCallback(); // Subscribe to global drag state changes // This fires on every pointer move during drag this._unsubscribeDrag = subscribeToDrag((state) => { this._handleDragStateChange(state); }); // Subscribe to drag end events to emit drop this._unsubscribeEndDrag = subscribeToEndDrag((state) => { this._handleDragEnd(state); }); } override disconnectedCallback() { super.disconnectedCallback(); // Unsubscribe from drag state if (this._unsubscribeDrag) { this._unsubscribeDrag(); this._unsubscribeDrag = undefined; } if (this._unsubscribeEndDrag) { this._unsubscribeEndDrag(); this._unsubscribeEndDrag = undefined; } } /** * Handle drag state changes - check if pointer is over us */ private _handleDragStateChange(state: DragState | null): void { if (!state) { // Drag ended - if we were in drag-over state, emit drop event if (this._isDragOver) { // We need to get the cell from the previous state, but it's gone now // The drag-source will have already cleaned up, so we can't emit drop here // Instead, we need to track the last known drag state this._setDragOver(false); } return; } // Check if this drag type is accepted if (!this._isAccepted(state.type)) { if (this._isDragOver) { this._setDragOver(false); } return; } // Check if pointer is within our bounding box const isOver = this._isPointerOver(state.pointerX, state.pointerY); if (isOver && !this._isDragOver) { this._setDragOver(true, state); } else if (!isOver && this._isDragOver) { this._setDragOver(false); } } /** * Check if a drag type is accepted by this drop zone */ private _isAccepted(dragType?: string): boolean { if (!this.accept) return true; // Accept all if no filter if (!dragType) return false; // Reject untyped drags when filter is set const types = this.accept.split(",").map((t) => t.trim()); return types.includes(dragType); } /** * Check if the pointer is within this element's bounding box */ private _isPointerOver(x: number, y: number): boolean { const rect = this.getBoundingClientRect(); return ( x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom ); } /** * Update drag-over state and emit appropriate events */ private _setDragOver(isOver: boolean, dragState?: DragState): void { if (isOver === this._isDragOver) return; this._isDragOver = isOver; this.toggleAttribute("drag-over", isOver); if (isOver && dragState) { this.emit("ct-drag-enter", { sourceCell: dragState.cell, type: dragState.type, }); } else { this.emit("ct-drag-leave", {}); } } /** * Handle drag end - if we're in drag-over state, emit drop */ private _handleDragEnd(dragState: DragState): void { if (this._isDragOver && this._isAccepted(dragState.type)) { // Clean up visual state this._isDragOver = false; this.toggleAttribute("drag-over", false); // Emit leave event before drop (dropping is a form of leaving) this.emit("ct-drag-leave", {}); // Get drop zone bounding rect for position calculation in handler const dropZoneRect = this.getBoundingClientRect(); // Emit drop event with pointer coordinates and drop zone rect this.emit("ct-drop", { sourceCell: dragState.cell, type: dragState.type, pointerX: dragState.pointerX, pointerY: dragState.pointerY, dropZoneRect: { left: dropZoneRect.left, top: dropZoneRect.top, width: dropZoneRect.width, height: dropZoneRect.height, }, }); } } override render() { return html` `; } } globalThis.customElements.define("ct-drop-zone", CTDropZone); declare global { interface HTMLElementTagNameMap { "ct-drop-zone": CTDropZone; } }