import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { ActionMove, ActionMoveBase, ComputedQueryFull, FiltersMap, KeyedItem, QueryState, ReportBI, ToastConfig, WixPatternsContainer, } from '@wix/bex-core'; import { CollectionDragEndEvent, DragAndDropBulkSubmit, DragAndDropSubmit, } from './DragAndDrop'; import { HTMLAttributes, MutableRefObject } from 'react'; import { subtract } from '@wix/wix-style-react-incubator/dnd-kit/utilities'; import { hasExceededDistance } from './hasExceededDistance'; import { Announcements } from '@wix/wix-style-react-incubator/drag-and-drop'; import { ClientRect, DragStartEvent, } from '@wix/wix-style-react-incubator/dnd-kit/core'; import { haveIntersection } from './rectIntersection'; import { EventEmitter } from 'events'; import { TypedEmitter } from '@wix/bex-core/events'; import { v4 as uuid } from 'uuid'; import { emptyRect, RectState } from '../../state/RectState'; import { ReorderModeState } from './ReorderModeState'; export interface DragAndDropParentState { readonly reportBi: ReportBI; readonly dragAndDropCategories?: (keyof F)[][] | null | undefined; forceRenderIndexes: undefined | number[]; readonly containerRect?: ClientRect; readonly _dnd?: DragAndDropState | null; } export interface DragAndDropCollection { getKeyedItem: (key: string) => KeyedItem | undefined | null; getKeyedItemByIndex: (ind: number) => KeyedItem | null; keyedItems: KeyedItem[]; originQuery: ComputedQueryFull; query: QueryState; total: number; } interface DragAndDropBaseBI { onDragStart: () => | ((params: { endType?: 'clicked ESC' | 'drop outside' | null }) => void) | undefined; onMoveCancelled: (move: ActionMoveBase) => void; onAttemptDragStart: (itemId: string) => () => void; } export interface DragAndDropStateParams extends DragAndDropBaseStateParams { move: (ev: { over: DragItemOver | null }) => void; announcements?: Partial; bi?: DragAndDropBaseBI; } export interface DragAndDropBaseStateParams { container: WixPatternsContainer; collection: DragAndDropCollection; state: DragAndDropParentState; } interface DragItemActive { id: string | number; rect?: MutableRefObject<{ initial: ClientRect | null; translated: ClientRect | null; }>; } export interface DragItemOver { id: string | number; rect?: ClientRect; disabled?: boolean; } export class DragAndDropState { readonly state; readonly container; readonly collection; readonly events = new EventEmitter() as TypedEmitter<{ dropAnimationEnd: () => unknown; dragStart: () => unknown; }>; status: 'start' | 'over' | 'end' | 'cancel' | null | undefined = null; activeId: string | null | undefined = null; overlayRect; _moveDelta: { x: number } = { x: 0 }; overId: string | null | undefined = null; droppingId: string | null | undefined = null; droppedId: string | null | undefined = null; _dropAnimationId: string | null | undefined = null; scheduleDropEnd; scheduleIdle; move; onSubmit?: DragAndDropSubmit; onBulkSubmit?: DragAndDropBulkSubmit; onCancel?: () => Promise; reorderModeState?: ReorderModeState | null = null; readonly bi; _isHandleActive = false; _isKeyboardDragging = false; _moveId = ''; _onDragEndBi?: | ((params: { endType?: 'clicked ESC' | 'drop outside' | null }) => void) | null; _endType: 'clicked ESC' | 'drop outside' | null = null; private readonly _baseAnimation = { duration: 250, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', }; readonly sortableAnimation = { ...this._baseAnimation, }; readonly dropAnimation = { duration: 75, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', sideEffects: () => { this._dropAnimationId = this.droppingId; return () => { this.onDropAnimationEnd(); }; }, }; readonly activationConstraint = { distance: 1, }; readonly keyboardCodes = { start: ['Space', 'Enter'], cancel: ['Escape', 'Tab'], end: ['Space', 'Enter'], move: ['ArrowDown'], }; readonly nullAnnouncements: Announcements; // Should be triggered before `onDragStart` for correct BI readonly handleEvents: Partial> = { onPointerDown: action(() => { this._isHandleActive = true; }), onKeyDown: action((e) => { const { keyboardCodes } = this; if (keyboardCodes.start.includes(e.nativeEvent.code)) { this._isHandleActive = true; this._isKeyboardDragging = true; return; } }), }; readonly containerEvents: Partial> = { onPointerDown: action(() => { this._isKeyboardDragging = false; }), }; constructor(params: DragAndDropStateParams) { this.container = params.container; this.state = params.state; this.collection = params.collection; this.overlayRect = new RectState({ container: this.container }); this.events.setMaxListeners(100); this.bi = params.bi; // We have an issue with pptr where `dropAnimation.sideEffects` is not being called. // This ensures `onDropAnimationEnd` will be called eventually, even if `dropAnimation.sideEffects` wasn't called. this.scheduleDropEnd = this.container.lodash.debounce(() => { // Do not call `onDropAnimationEnd` if _dropAnimationId was set, meaning that `dropAnimation.sideEffects` worked as expected if (this._dropAnimationId && this._dropAnimationId === this.droppingId) { return; } this.onDropAnimationEnd(); }, this.dropAnimation.duration); this.scheduleIdle = this.container.lodash.debounce( action(() => { if (this.status === 'end' || this.status === 'cancel') { this.status = null; this._isKeyboardDragging = false; this._onDragEndBi = null; this._endType = null; } }), 500, ); this.move = params.move; this.nullAnnouncements = { onDragStart: () => undefined, onDragMove: () => undefined, onDragOver: () => undefined, onDragEnd: () => undefined, onDragCancel: () => undefined, listAttributes: () => { const { translate: t } = this.container; return { 'aria-label': t('cairo.dragAndDrop.list-aria-label.sr'), }; }, ...(params.announcements ?? {}), }; makeObservable(this, { activeId: observable.ref, overlayRect: observable.ref, overId: observable.ref, _moveDelta: observable.ref, status: observable.ref, onDragStart: action, init: action, beforeDrop: action, onDragEnd: action, onDragMove: action, onDragOver: action, onDragCancel: action, focusDragHandleIfDropped: action, active: computed, isActiveRectOutsideContainer: computed, over: computed, activeIndex: computed, isActive: computed, droppingId: observable.ref, isDropping: computed, _isHandleActive: observable.ref, reorderModeState: observable.ref, }); } onDropAnimationEnd = action(() => { if (this.droppingId && this.activeId === this.droppingId) { this.activeId = null; this._isHandleActive = false; this.droppedId = this.droppingId; this.droppingId = null; this._dropAnimationId = null; this.events.emit('dropAnimationEnd'); this._onDragEndBi?.({ endType: this._endType, }); this.scheduleIdle(); } }); get active() { const { activeId, collection } = this; return activeId != null ? collection.getKeyedItem(activeId) : null; } get over() { const { overId, collection } = this; return overId != null ? collection.getKeyedItem(overId) : null; } get isDropping() { return this.droppingId != null; } get activeIndex() { return this.active?.index; } get isActive() { return this.activeId != null; } _onGlobalKeydown = action((e: KeyboardEvent) => { const { keyboardCodes, activeId } = this; if (activeId == null) { return; } if (keyboardCodes.cancel.includes(e.code)) { this._endType = 'clicked ESC'; return; } }); onDragMove(event: { active: { id: DragStartEvent['active']['id']; rect?: DragStartEvent['active']['rect']; }; delta: { x: number }; }) { const { active } = event; this._moveDelta = event.delta; Object.assign(this.overlayRect.rect, active.rect?.current.translated); } onDragStart(event: { active: { id: DragStartEvent['active']['id']; rect?: DragStartEvent['active']['rect']; }; }) { const { events } = this; const { active } = event; this.status = 'start'; Object.assign(this.overlayRect.rect, active.rect?.current.translated); this.activeId = event.active.id as string; this.overId = this.activeId; this._moveId = uuid(); this._onDragEndBi = this.bi?.onDragStart(); events.emit('dragStart'); } attachDisabledDragAttempt = ( itemId: string, downEvent: MouseEvent, axis: 'x' | 'y' | 'all', ) => { const state = { onDragAttemptEnd: null as (() => void) | null, }; const coordinates = { x: downEvent.x, y: downEvent.y, }; const onMouseUp = () => { if (state.onDragAttemptEnd) { state.onDragAttemptEnd(); state.onDragAttemptEnd = null; } document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onMouseUp); }; const onMove = this.container.lodash.throttle((moveEvent: MouseEvent) => { const delta = subtract({ x: moveEvent.x, y: moveEvent.y }, coordinates); // for detecting drag attempt use larger activation constraint (10px instead of 1px) if ( process.env.NODE_ENV !== 'test' && !hasExceededDistance({ delta, measurement: 10, axis }) ) { return; } document.removeEventListener('mousemove', onMove); state.onDragAttemptEnd = this.bi?.onAttemptDragStart(itemId) ?? null; }, 200); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onMouseUp); }; get isActiveRectOutsideContainer() { const { state: { containerRect }, overlayRect: { rect: overlayRect }, } = this; if (overlayRect.width == null || containerRect == null) { return false; } return !haveIntersection(overlayRect, containerRect); } _maybeShowErrorToast = ( shouldCancel: Omit | true, dragEndEvent: ActionMoveBase, ) => { const { container } = this; if (shouldCancel && typeof shouldCancel === 'object') { this.bi?.onMoveCancelled?.(dragEndEvent); container.showToast?.({ ...shouldCancel, type: 'ERROR', biName: 'cairo-drag-and-drop-external-logic-error', }); } }; async beforeDrop(event: { active: DragItemActive | null; over: DragItemOver | null; }): Promise { this.droppingId = this.activeId; this.overId = event.over?.id as string; const { active } = event; if (active == null) { return false; } const cancelResult = await this.onCancel?.(); if (cancelResult) { return true; } const rect = active.rect?.current?.translated; const container = this.state.containerRect; if (rect == null || container == null) { return false; } const shouldCancel = !haveIntersection(rect, container); if (shouldCancel) { this._endType = 'drop outside'; } return shouldCancel; } _createDragEndEvent() { const { active, collection, overId } = this; const { query } = collection; if (active == null || overId == null || overId === active.id) { return null; } const from = collection.getKeyedItem(active.id as string); const over = collection.getKeyedItem(overId as string); if (from == null || over == null) { return null; } const after = over.index === 0 ? null : over.index < from.index ? collection.getKeyedItemByIndex(over.index - 1) : over; return { moveId: this._moveId, from, over, after, query: collection.originQuery, filtersKeyHash: query.filtersKeyHash, isFromHandle: Boolean(this._isHandleActive), }; } _createSimpleDragEndEvent( event: ActionMove, ): CollectionDragEndEvent { const simplifyIndexedItem = (keyedItem: KeyedItem) => ({ id: keyedItem.id, item: keyedItem.item, index: keyedItem.index, }); return { from: simplifyIndexedItem(event.from), after: event.after != null ? simplifyIndexedItem(event.after) : null, over: simplifyIndexedItem(event.over), filters: event.query.filters, }; } onDragCancel() { this.status = 'cancel'; Object.assign(this.overlayRect.rect, emptyRect()); this.droppingId = this.activeId; this.scheduleDropEnd(); } onDragOver(event: { over: DragItemOver | null }) { this.status = 'over'; this.overId = event.over?.id as string; } onDragEnd(event: { over: DragItemOver | null }) { this.status = 'end'; this.overId = event.over?.id as string; Object.assign(this.overlayRect.rect, emptyRect()); this.move(event); this.scheduleDropEnd(); } get isDisabled() { const { dragAndDropCategories } = this.state; const { query: { hasActiveSort, filtersArray }, } = this.collection; const activeFiltersNames = filtersArray .filter((filter) => !filter.isEmpty) .map((filter) => filter.name); if (hasActiveSort) { return true; } if (activeFiltersNames.length) { return ( dragAndDropCategories == null || !dragAndDropCategories.some( (combination) => activeFiltersNames.length === combination.length && activeFiltersNames.every((name) => combination.includes(name)), ) ); } if (dragAndDropCategories) { const hasEmptyCombination = dragAndDropCategories.some( (combination) => !combination.length, ); return !hasEmptyCombination; } return false; } init() { document.addEventListener('keydown', this._onGlobalKeydown); const disposers = [ reaction( () => this.active, (keyedItem) => { this.state.forceRenderIndexes = keyedItem ? [keyedItem.index] : undefined; }, { fireImmediately: true, }, ), ]; return () => { document.removeEventListener('keydown', this._onGlobalKeydown); disposers.forEach((dispose) => dispose()); }; } focusDragHandleIfDropped( id: string | number, element: HTMLElement | null | undefined, ) { const { status, droppedId, droppingId } = this; if ( (status === 'end' || status === 'cancel') && droppingId == null && droppedId === id && element ) { element.focus({ preventScroll: true, }); } } get reportBi() { return this.state.reportBi; } }