/** @jsxImportSource react */ import { getCursorPos, captureMouseOrTouch, CursorPosition } from "../overlay/captureMouse"; import { startAppLoop } from "../../ui/app/startAppLoop"; import { View } from "../../data/View"; import type { RenderingContext } from "../../ui/RenderingContext"; import type { Instance } from "../../ui/Instance"; export interface DragEvent { type: "dragstart" | "dragmove" | "dragdrop"; event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent; cursor: CursorPosition; source: { width: number; height: number; margin: string[]; data?: any; store: View; [other: string]: any; }; dataTransfer?: DataTransfer; result?: any; } export interface DragDropOptions { sourceEl?: Element | null; clone?: any; source?: any; } export interface DragDropOperationContext { test: any; } export type DragEventHandler = (e: DragEvent) => void; export interface IDropZone { onDropTest?: (e: DragEvent) => boolean; onDragStart?: DragEventHandler; onDragAway?: DragEventHandler; onDragNear?: DragEventHandler; onDragEnd?: DragEventHandler; onDragMeasure?: ( e: DragEvent, operation: DragDropOperationContext, ) => { over: number | false; near: number | boolean } | false | undefined; onDragLeave?: DragEventHandler; onDragOver?: (e: DragEvent, operation: DragDropOperationContext) => void; onDragEnter?: DragEventHandler; onDrop?: DragEventHandler; onGetVScrollParent?: (operation: DragDropOperationContext) => Element | null; onGetHScrollParent?: (operation: DragDropOperationContext) => Element | null; } export type UnregisterFunction = () => void; import { getScrollerBoundingClientRect } from "../../util/getScrollerBoundingClientRect"; import { isNumber } from "../../util/isNumber"; import { isObject } from "../../util/isObject"; import { isString } from "../../util/isString"; import { ZIndexManager } from "../../ui/ZIndexManager"; import { getTopLevelBoundingClientRect } from "../../util/getTopLevelBoundingClientRect"; import { VDOM } from "../../ui/VDOM"; import { Container } from "../../ui/Container"; import { Console } from "../../util"; import { WidgetConfig } from "../../ui"; interface Puppet { deltaX: number; deltaY: number; el: HTMLDivElement; clone: any; source: any; onDragEnd?: (e?: DragEvent) => void; stop?: () => void; } let dropZones: IDropZone[] = []; let dragStartedZones: WeakMap | null = null; let activeZone: IDropZone | null = null; let nearZones: WeakMap | null = null; let puppet: Puppet | null = null; let scrollTimer: number | null = null; let vscrollParent: Element | null = null; let hscrollParent: Element | null = null; export function registerDropZone(dropZone: IDropZone): UnregisterFunction { dropZones.push(dropZone); return () => { let index = dropZones.indexOf(dropZone); if (index !== -1) dropZones.splice(index, 1); }; } export function initiateDragDrop( e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent, options: DragDropOptions = {}, onDragEnd?: (e?: DragEvent) => void, ): void { if (puppet) { //last operation didn't finish properly notifyDragDrop(e); } let sourceEl = options.sourceEl || (e.currentTarget as Element); let sourceBounds = getTopLevelBoundingClientRect(sourceEl); let cursor = getCursorPos(e); let clone = { ...options.clone, }; let cloneEl = document.createElement("div"); cloneEl.classList.add("cxb-dragclone"); if (isString(clone["class"])) cloneEl.classList.add(clone["class"]); if (isObject(clone.style)) Object.assign(cloneEl.style, clone.style); cloneEl.style.left = `-1000px`; cloneEl.style.top = `-1000px`; if (clone.matchSize || clone.matchWidth) cloneEl.style.width = `${Math.ceil(sourceBounds.width)}px`; if (clone.matchSize || clone.matchHeight) cloneEl.style.height = `${Math.ceil(sourceBounds.height)}px`; cloneEl.style.zIndex = String(ZIndexManager.next() + 1000); if (clone.cloneContent) { cloneEl.appendChild(sourceEl.cloneNode(true)); } document.body.appendChild(cloneEl); let styles = getComputedStyle(sourceEl); let deltaX = clone.matchCursorOffset ? cursor.clientX - sourceBounds.left : -3; let deltaY = clone.matchCursorOffset ? cursor.clientY - sourceBounds.top : -3; let source = { ...options.source, width: sourceBounds.width, height: sourceBounds.height, deltaX, deltaY, margin: [ styles.getPropertyValue("margin-top"), styles.getPropertyValue("margin-right"), styles.getPropertyValue("margin-bottom"), styles.getPropertyValue("margin-left"), ], }; puppet = { deltaX, deltaY, el: cloneEl, clone, source, onDragEnd, }; if (clone.widget && clone.store && !clone.cloneContent) { let content = { $type: ContextWrap, value: { disabled: true }, children: clone.widget }; puppet.stop = startAppLoop(cloneEl, clone.store, content, { removeParentDOMElement: true, }); } else { puppet.stop = () => { document.body.removeChild(cloneEl); }; } let event = getDragEvent(e, "dragstart"); dragStartedZones = new WeakMap(); dropZones.forEach((zone) => { if (zone.onDropTest) try { if (!zone.onDropTest(event)) return; } catch (err) { Console.warn("Drop zone onDropTest failed. Error: ", err, zone); return; } if (zone.onDragStart) zone.onDragStart(event); dragStartedZones!.set(zone, true); }); notifyDragMove(e, null); captureMouseOrTouch(e, notifyDragMove, notifyDragDrop); } function notifyDragMove(e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, _captureData: any): void { let event = getDragEvent(e, "dragmove"); let over: IDropZone | null = null, overTest: any = null, best: number | null = null; let near: IDropZone[] = [], away: IDropZone[] = []; for (let zone of dropZones) { let test; try { test = zone.onDropTest && zone.onDropTest(event); if (!test) continue; } catch (err) { //the problem is already reported, so here we just swallow the bug to avoid spammming the console too much continue; } if (zone.onDragMeasure) { let result = zone.onDragMeasure(event, { test }); if (!result) continue; if (result.near) near.push(zone); else away.push(zone); if (isNumber(result.over) && (best == null || result.over < best)) { over = zone; overTest = test; best = result.over; } } } let newNear = new WeakMap(); if (nearZones != null) { away.forEach((z) => { if (z.onDragAway && nearZones!.has(z)) z.onDragAway(event); }); } near.forEach((z) => { if (z.onDragNear && z != over && (nearZones == null || !nearZones.has(z))) { z.onDragNear(event); newNear.set(z, true); } }); nearZones = newNear; if (over != activeZone) { vscrollParent = null; hscrollParent = null; } if (over != activeZone && activeZone && activeZone.onDragLeave) activeZone.onDragLeave(event); if (over != activeZone && over) { if (over.onDragEnter) over.onDragEnter(event); vscrollParent = (over.onGetVScrollParent && over.onGetVScrollParent({ test: overTest })) || null; hscrollParent = (over.onGetHScrollParent && over.onGetHScrollParent({ test: overTest })) || null; } activeZone = over; if (over && over.onDragOver) { over.onDragOver(event, { test: overTest }); } //do it last to avoid forced redraw if nothing changed let cursor = getCursorPos(e); puppet!.el.style.left = `${cursor.clientX - puppet!.deltaX}px`; puppet!.el.style.top = `${cursor.clientY - puppet!.deltaY}px`; if (vscrollParent || hscrollParent) { let scrollX = 0, scrollY = 0; let vscrollBounds = vscrollParent && getScrollerBoundingClientRect(vscrollParent, true); let hscrollBounds = hscrollParent == vscrollParent ? vscrollBounds : hscrollParent && getScrollerBoundingClientRect(hscrollParent, true); if (vscrollBounds) { if (cursor.clientY < vscrollBounds.top + 20) scrollY = -1; else if (cursor.clientY >= vscrollBounds.bottom - 20) scrollY = 1; } if (hscrollBounds) { if (cursor.clientX < hscrollBounds.left + 20) scrollX = -1; else if (cursor.clientX >= hscrollBounds.right - 20) scrollX = 1; } if (scrollY || scrollX) { if (!scrollTimer) { let cb = () => { if (scrollY) { let current = vscrollParent!.scrollTop; let next = Math.min( vscrollParent!.scrollHeight, Math.max(0, current + (scrollY * 5 * Math.min(200, Math.max(50, event.source.height))) / 60), ); //60 FPS vscrollParent!.scrollTop = next; } if (scrollX) { let current = hscrollParent!.scrollLeft; let next = Math.min( hscrollParent!.scrollWidth, Math.max(0, current + (scrollX * 5 * Math.min(200, Math.max(50, event.source.width))) / 60), ); //60 FPS hscrollParent!.scrollLeft = next; } scrollTimer = requestAnimationFrame(cb); }; scrollTimer = requestAnimationFrame(cb); } } else { clearScrollTimer(); } } else clearScrollTimer(); } function clearScrollTimer() { if (scrollTimer) { cancelAnimationFrame(scrollTimer); scrollTimer = null; } } function notifyDragDrop(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent): void { clearScrollTimer(); let event = getDragEvent(e, "dragdrop"); if (puppet!.stop) puppet!.stop(); if (activeZone && activeZone.onDrop) event.result = activeZone.onDrop(event); dropZones.forEach((zone) => { if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(event); if (!dragStartedZones!.has(zone)) return; if (zone.onDragEnd) zone.onDragEnd(event); }); if (puppet!.onDragEnd) puppet!.onDragEnd(event); nearZones = null; activeZone = null; puppet = null; dragStartedZones = null; } function getDragEvent( event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent, type: DragEvent["type"], ): DragEvent { return { type: type, event, cursor: getCursorPos(event), source: puppet!.source, }; } interface DragCandidate { el?: EventTarget | null; start?: CursorPosition; timeStamp?: number; } let dragCandidate: DragCandidate = {}; export function ddMouseDown(e: React.MouseEvent | React.TouchEvent): void { //do not allow that the same event is processed by multiple drag sources //the first (top-level) source should be a drag-candidate if (dragCandidate.timeStamp != null && e.timeStamp <= dragCandidate.timeStamp) return; dragCandidate = { el: e.currentTarget, start: { ...getCursorPos(e) }, timeStamp: e.timeStamp, }; } export function ddMouseUp(): void { dragCandidate = {}; } export function ddDetect(e: MouseEvent | TouchEvent | React.TouchEvent | React.MouseEvent): void | true { let cursor = getCursorPos(e); if ( dragCandidate.start && e.currentTarget == dragCandidate.el && Math.abs(cursor.clientX - dragCandidate.start.clientX) + Math.abs(cursor.clientY - dragCandidate.start.clientY) >= 2 ) { dragCandidate = {}; return true; } } let lastDragHandle: any; export function ddHandle(e: React.SyntheticEvent): void { lastDragHandle = e.currentTarget; } export function isDragHandleEvent(e: React.SyntheticEvent): boolean { return lastDragHandle && (e.target == lastDragHandle || lastDragHandle.contains(e.target)); } export const DragDropContext = ( VDOM.createContext ? VDOM.createContext({ disabled: false }) : ({ children }: { children: any }) => children ) as React.Context<{ disabled: boolean }>; class ContextWrap extends Container { declare value: any; render(context: RenderingContext, instance: Instance, key: string): React.ReactNode { return ( {this.renderChildren(context, instance)} ); } }