import { css, html, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { createDragPreview, endDrag, startDrag, updateDragPointer, } from "../../core/drag-state.ts"; import type { CellHandle } from "@commonfabric/runtime-client"; import "../cf-cell-context/index.ts"; /** * CFDragSource - Wraps draggable content and initiates drag operations * * This component makes any content draggable and manages the drag lifecycle. * It automatically wraps content with cf-cell-context for debugging support. * * @element cf-drag-source * * @property {CellHandle} cell - Required: the cell being dragged * @property {string} type - Optional: type identifier for filtering drop zones * @property {boolean} disabled - Disable dragging * * @fires cf-drag-start - Fired when drag starts with { cell: CellHandle } * @fires cf-drag-end - Fired when drag ends with { cell: CellHandle, dropped: boolean } * * @slot - Default slot for draggable content * * @example * *
Drag me!
*
*/ export class CFDragSource extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: block; } :host([disabled]) { opacity: 0.5; cursor: not-allowed; } :host(:not([disabled])) cf-cell-context { cursor: grab; } :host(:not([disabled])) cf-cell-context.dragging { cursor: grabbing; opacity: 0.5; } `, ]; @property({ attribute: false }) accessor cell: CellHandle | undefined = undefined; @property({ type: String }) accessor type: string | undefined = undefined; @property({ type: Boolean, reflect: true }) accessor disabled: boolean | undefined = undefined; @state() private accessor _resolvedCell: CellHandle | undefined = undefined; private _isDragging = false; private _isTracking = false; private _startX = 0; private _startY = 0; private _pointerId?: number; private _preview?: HTMLElement; private _boundPointerMove = this._handlePointerMove.bind(this); private _boundPointerUp = this._handlePointerUp.bind(this); protected override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); if (changedProperties.has("cell")) { this._resolveCell(); } } private async _resolveCell() { // Clear immediately so stale values can't be used during async resolution this._resolvedCell = undefined; if (this.cell) { this._resolvedCell = await this.cell.resolveAsCell(); } } private _handlePointerDown(e: PointerEvent) { // Skip if disabled if (this.disabled || !this.cell) { return; } // Skip if Alt is held - user is interacting with cf-cell-context debug UI if (e.altKey) { return; } // Skip if clicking on interactive elements const target = e.target as HTMLElement; if (this._isInteractiveElement(target)) { return; } // Don't preventDefault or setPointerCapture here - wait until we confirm // it's a drag. This allows clicks to work on non-interactive content. // Store initial position this._startX = e.clientX; this._startY = e.clientY; this._pointerId = e.pointerId; this._isTracking = true; // Add document-level listeners for move and up document.addEventListener("pointermove", this._boundPointerMove); document.addEventListener("pointerup", this._boundPointerUp); document.addEventListener("pointercancel", this._boundPointerUp); } private _handlePointerMove(e: PointerEvent) { // Ignore events from other pointers (multi-touch, secondary mouse buttons) if (!this.cell || !this._isTracking || e.pointerId !== this._pointerId) { return; } const deltaX = e.clientX - this._startX; const deltaY = e.clientY - this._startY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); // Start drag after threshold (~5px) if (!this._isDragging && distance > 5) { this._isDragging = true; this._startDrag(e); } // Update preview position and notify drop zones if dragging if (this._isDragging && this._preview) { this._preview.style.left = `${e.clientX + 10}px`; this._preview.style.top = `${e.clientY + 10}px`; // Update drag state so drop zones can check intersection updateDragPointer(e.clientX, e.clientY); } } private _handlePointerUp(e: PointerEvent) { // Ignore events from other pointers (multi-touch releases shouldn't cancel drag) if (e.pointerId !== this._pointerId) { return; } // Clean up listeners document.removeEventListener("pointermove", this._boundPointerMove); document.removeEventListener("pointerup", this._boundPointerUp); document.removeEventListener("pointercancel", this._boundPointerUp); if (this._isDragging) { // Drop detection is now handled by drop-zones polling the drag state this._endDrag(); } this._isDragging = false; this._isTracking = false; this._pointerId = undefined; } private _isInteractiveElement(element: HTMLElement): boolean { // Check if element or any ancestor (up to this component) is interactive. // This ensures clicks on descendants of buttons/links work correctly. const interactiveSelector = "input, button, select, textarea, a, [role='button']"; const interactive = element.closest(interactiveSelector); return interactive !== null && this.contains(interactive); } private _startDrag(e: PointerEvent) { const cell = this._resolvedCell ?? this.cell; if (!cell) { return; } // Now that we're actually dragging, capture the pointer and add dragging class const cellContext = this.shadowRoot?.querySelector( "cf-cell-context", ) as HTMLElement; if (cellContext) { if (this._pointerId !== undefined) { cellContext.setPointerCapture(this._pointerId); } cellContext.classList.add("dragging"); } // Create preview element this._preview = createDragPreview(cell); document.body.appendChild(this._preview); // Position preview near cursor this._preview.style.left = `${e.clientX + 10}px`; this._preview.style.top = `${e.clientY + 10}px`; // Start drag in drag state startDrag({ cell, type: this.type, sourceElement: this, preview: this._preview, pointerX: e.clientX, pointerY: e.clientY, }); // Emit drag start event this.emit("cf-drag-start", { cell }); } private _endDrag() { const cell = this._resolvedCell ?? this.cell; if (!cell) { return; } // Remove dragging class const cellContext = this.shadowRoot?.querySelector("cf-cell-context"); if (cellContext) { cellContext.classList.remove("dragging"); } // End drag in drag state (this will clean up preview) // Drop zones handle their own detection and emit cf-drop events endDrag(); // Emit drag end event this.emit("cf-drag-end", { cell }); this._preview = undefined; } override render() { return html` `; } } declare global { interface HTMLElementTagNameMap { "cf-drag-source": CFDragSource; } }