import { css, html } from "lit"; import { property } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { endDrag, startDrag, updateDragPointer, } from "../../core/drag-state.ts"; import { render } from "@commontools/html"; import { UI } from "@commontools/runner"; import type { Cell } from "@commontools/runner"; import "../ct-cell-context/ct-cell-context.ts"; import "../ct-cell-link/ct-cell-link.ts"; /** * CTDragSource - Wraps draggable content and initiates drag operations * * This component makes any content draggable and manages the drag lifecycle. * It automatically wraps content with ct-cell-context for debugging support. * * @element ct-drag-source * * @property {Cell} cell - Required: the cell being dragged * @property {string} type - Optional: type identifier for filtering drop zones * @property {boolean} disabled - Disable dragging * * @fires ct-drag-start - Fired when drag starts with { cell: Cell } * @fires ct-drag-end - Fired when drag ends with { cell: Cell, dropped: boolean } * * @slot - Default slot for draggable content * * @example * *
Drag me!
*
*/ export class CTDragSource extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: block; } :host([disabled]) { opacity: 0.5; cursor: not-allowed; } :host(:not([disabled])) ct-cell-context { cursor: grab; } :host(:not([disabled])) ct-cell-context.dragging { cursor: grabbing; opacity: 0.5; } `, ]; @property({ attribute: false }) cell?: Cell; @property({ type: String }) type?: string; @property({ type: Boolean, reflect: true }) disabled?: boolean; 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); private _handlePointerDown(e: PointerEvent) { // Skip if disabled if (this.disabled || !this.cell) { return; } // Skip if Alt is held - user is interacting with ct-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; } // Prevent default and capture pointer for drag e.preventDefault(); // Store initial position this._startX = e.clientX; this._startY = e.clientY; this._pointerId = e.pointerId; this._isTracking = true; // Capture pointer events const cellContext = this.shadowRoot?.querySelector( "ct-cell-context", ) as HTMLElement; if (cellContext) { cellContext.setPointerCapture(e.pointerId); } // Add document-level listeners for move and up document.addEventListener("pointermove", this._boundPointerMove); document.addEventListener("pointerup", 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); 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) { if (!this.cell) { return; } // Create preview element this._preview = this._createPreview(); 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`; // Add dragging class to source const cellContext = this.shadowRoot?.querySelector("ct-cell-context"); if (cellContext) { cellContext.classList.add("dragging"); } // Start drag in drag state startDrag({ cell: this.cell, type: this.type, sourceElement: this, preview: this._preview, pointerX: e.clientX, pointerY: e.clientY, }); // Emit drag start event this.emit("ct-drag-start", { cell: this.cell }); } private _createPreview(): HTMLElement { if (!this.cell) { throw new Error("Cannot create preview without cell"); } const preview = document.createElement("div"); // Apply inline styles since this element is appended to document.body // (outside our shadow DOM where .preview class would apply) preview.style.cssText = ` position: fixed; pointer-events: none; z-index: 10000; opacity: 0.9; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); background: white; border: 1px solid #ccc; padding: 0.5rem; border-radius: 4px; max-width: 300px; max-height: 200px; overflow: hidden; `; // Check if cell value has [UI] property const cellValue = this.cell.get(); if (cellValue && typeof cellValue === "object" && UI in cellValue) { // Render using [UI] try { const uiValue = (cellValue as Record)[UI]; render(preview, uiValue as any); } catch (error) { console.warn("[ct-drag-source] Failed to render [UI]:", error); this._createFallbackPreview(preview); } } else { // Use ct-cell-link as fallback this._createFallbackPreview(preview); } return preview; } private _createFallbackPreview(container: HTMLElement) { if (!this.cell) { return; } const link = document.createElement("ct-cell-link"); link.cell = this.cell; container.appendChild(link); } private _endDrag() { if (!this.cell) { return; } // Remove dragging class const cellContext = this.shadowRoot?.querySelector("ct-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 ct-drop events endDrag(); // Emit drag end event this.emit("ct-drag-end", { cell: this.cell }); this._preview = undefined; } override render() { return html` `; } } globalThis.customElements.define("ct-drag-source", CTDragSource); declare global { interface HTMLElementTagNameMap { "ct-drag-source": CTDragSource; } }