diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts index 541abde..ff8418b 100644 --- a/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts +++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts @@ -89,6 +89,7 @@ export class DndController { private simulator: DndSimulator private modifiers: Modifier[] private keyboardFlight: KeyboardFlight | null = null + private lastPointer: { x: number; y: number } | null = null // --- Entity maps --- // Reactive maps so $derived computations (TranslationEngine, dropPreviewSize) @@ -399,11 +400,24 @@ export class DndController { } updateMousePosition(mouseX: number, mouseY: number) { + this.lastPointer = { x: mouseX, y: mouseY } if (this.state.dragging && !this.state.performingDrop) { this.animationCoordinator.updateDropPreview({ x: mouseX, y: mouseY }) } } + /** + * Re-resolve the drop preview against the last known pointer position without + * a new pointer event. Used when zones shift under a stationary cursor, such + * as scrolling the container mid-drag. + */ + recomputeDropPreview() { + if (!this.lastPointer) return + if (this.state.dragging && !this.state.performingDrop) { + this.animationCoordinator.updateDropPreview(this.lastPointer) + } + } + handleAutoScroll(mouseX: number, mouseY: number) { if (this.state.dragging && !this.state.performingDrop) { this.scrollController.handleAutoScroll(mouseX, mouseY) diff --git a/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts index 80ce635..72d5a92 100644 --- a/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts +++ b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts @@ -24,6 +24,7 @@ export type DroppableControllerRef = { onZonesInvalidated(cb: () => void): () => void onDragEnd(cb: DragEndCallback): () => void refreshDroppableZones(droppable: Droppable): void + recomputeDropPreview(): void dragging: boolean } @@ -197,6 +198,7 @@ export class Droppable { if (this.scrollTimeout) clearTimeout(this.scrollTimeout) this.scrollTimeout = setTimeout(() => { this.invalidateZones() + this.controller.recomputeDropPreview() this.scrollTimeout = null }, 10) } @@ -204,21 +206,31 @@ export class Droppable { private setupScrollListeners() { if (!isBrowser) return - let parent = this.element?.parentElement + let parent: HTMLElement | null | undefined = this.element while (parent) { const style = window.getComputedStyle(parent) if ( ['auto', 'scroll', 'overlay'].includes(style.overflowY) || ['auto', 'scroll', 'overlay'].includes(style.overflowX) ) { - parent.addEventListener('scroll', this.handleScroll, { passive: true }) - this.scrollListeners.push(parent) + this.addScrollListener(parent) } parent = parent.parentElement } + // Virtualizers delegate scrolling to a nested `data-dnd-scroll` viewport; + // scroll events don't bubble, so listen on it directly. + this.element + ?.querySelectorAll('[data-dnd-scroll]') + .forEach((el) => this.addScrollListener(el)) window.addEventListener('scroll', this.handleScroll, { passive: true }) } + private addScrollListener(el: HTMLElement) { + if (this.scrollListeners.includes(el)) return + el.addEventListener('scroll', this.handleScroll, { passive: true }) + this.scrollListeners.push(el) + } + private cleanupScrollListeners() { this.scrollListeners.forEach((el) => el.removeEventListener('scroll', this.handleScroll)) if (isBrowser) window.removeEventListener('scroll', this.handleScroll) diff --git a/packages/svelte-dnd/tests/entities/droppable-scroll.test.ts b/packages/svelte-dnd/tests/entities/droppable-scroll.test.ts new file mode 100644 index 0000000..b2bceae --- /dev/null +++ b/packages/svelte-dnd/tests/entities/droppable-scroll.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + Droppable, + type DroppableControllerRef +} from '../../src/lib/core/entities/droppable.svelte.js' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' + +type DragStartCallback = Parameters[0] + +function makeController() { + let dragStart: DragStartCallback | undefined + const refreshDroppableZones = vi.fn() + const recomputeDropPreview = vi.fn() + const ref: DroppableControllerRef = { + session: null, + draggedElement: null, + cancelSession: () => {}, + slots: new Map(), + onDragStart: (cb) => { + dragStart = cb + return () => {} + }, + onZonesInvalidated: () => () => {}, + onDragEnd: () => () => {}, + refreshDroppableZones, + recomputeDropPreview, + dragging: true + } + return { + ref, + refreshDroppableZones, + recomputeDropPreview, + startDrag: () => dragStart?.({} as Parameters[0]) + } +} + +function makeDroppable(element: HTMLElement, controller: DroppableControllerRef): Droppable { + const d = new Droppable({ id: 'd', strategy: sortable({ virtual: { itemCount: () => 3 } }) }, controller) + d.element = element + return d +} + +describe('Droppable scroll handling during drag', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + document.body.innerHTML = '' + }) + + it('re-resolves zones and drop preview when its own element scrolls', () => { + const controller = makeController() + const el = document.createElement('div') + el.style.overflowY = 'auto' + document.body.appendChild(el) + + const d = makeDroppable(el, controller.ref) + d.setupEventListeners() + controller.startDrag() + + el.dispatchEvent(new Event('scroll')) + vi.advanceTimersByTime(10) + + expect(controller.refreshDroppableZones).toHaveBeenCalledWith(d) + expect(controller.recomputeDropPreview).toHaveBeenCalledTimes(1) + }) + + it('listens on a nested virtualizer viewport marked data-dnd-scroll', () => { + const controller = makeController() + const el = document.createElement('div') + el.style.overflowY = 'hidden' + const viewport = document.createElement('div') + viewport.setAttribute('data-dnd-scroll', '') + el.appendChild(viewport) + document.body.appendChild(el) + + const d = makeDroppable(el, controller.ref) + d.setupEventListeners() + controller.startDrag() + + viewport.dispatchEvent(new Event('scroll')) + vi.advanceTimersByTime(10) + + expect(controller.refreshDroppableZones).toHaveBeenCalledWith(d) + expect(controller.recomputeDropPreview).toHaveBeenCalledTimes(1) + }) + + it('stops re-resolving once the drag ends', () => { + const controller = makeController() + const el = document.createElement('div') + el.style.overflowY = 'auto' + document.body.appendChild(el) + + const d = makeDroppable(el, controller.ref) + d.setupEventListeners() + controller.startDrag() + d.destroy() + + el.dispatchEvent(new Event('scroll')) + vi.advanceTimersByTime(10) + + expect(controller.recomputeDropPreview).not.toHaveBeenCalled() + }) +}) diff --git a/packages/svelte-dnd/tests/helpers/fixtures.ts b/packages/svelte-dnd/tests/helpers/fixtures.ts index 983ce6c..1372361 100644 --- a/packages/svelte-dnd/tests/helpers/fixtures.ts +++ b/packages/svelte-dnd/tests/helpers/fixtures.ts @@ -16,6 +16,7 @@ export function noopController(): DroppableControllerRef { onZonesInvalidated: () => () => {}, onDragEnd: () => () => {}, refreshDroppableZones: () => {}, + recomputeDropPreview: () => {}, dragging: false } }