diff --git a/docs/src/components/examples/KitchenSinkDemo.svelte b/docs/src/components/examples/KitchenSinkDemo.svelte index f344a449..3b53e3a9 100644 --- a/docs/src/components/examples/KitchenSinkDemo.svelte +++ b/docs/src/components/examples/KitchenSinkDemo.svelte @@ -12,6 +12,7 @@ draggable, events, grid, + overlap, position, scrollLock, } from '@neodrag/svelte'; @@ -48,7 +49,7 @@ let is_backdrop_visible = $state(false); - let z_indices = $state(Array.from({ length: 15 }).fill(0)); + let z_indices = $state(Array.from({ length: 17 }).fill(0)); function get_node_index(node: HTMLElement) { return Array.from(node.parentNode?.children!).indexOf(node); @@ -90,6 +91,24 @@ }, }; + // Overlap demo handlers without backdrop blur + const overlap_drag_handlers: Parameters[0] = { + onDrag: ({ rootNode }) => { + // No backdrop blur for overlap demo + rootNode.style.zIndex = '20'; + }, + onDragEnd: ({ rootNode }) => { + setTimeout(() => { + update_z_index(rootNode); + }, 200); + }, + }; + + // ===== OVERLAP PLUGIN DEMO - START ===== + let overlap_count = $state(0); + let show_overlap_enabled = $state(true); + // ===== OVERLAP PLUGIN DEMO - END ===== + let return_to_position_val = $state({ x: 0, y: 0, @@ -385,10 +404,38 @@ I will return to my position on drop, but with style! 😉 +
overlap_count = targets.length, + showOverlap: { + enabled: show_overlap_enabled, + } + }), + events(overlap_drag_handlers), + ])} + > + Overlap detection +
+ Overlapping: {overlap_count} +
+ +
+ +
disabled: true diff --git a/docs/src/content/plugin/13-overlap/+page.mdx b/docs/src/content/plugin/13-overlap/+page.mdx new file mode 100644 index 00000000..d0c35b6b --- /dev/null +++ b/docs/src/content/plugin/13-overlap/+page.mdx @@ -0,0 +1,696 @@ +--- +title: 'overlap' +tagline: 'Detect when draggable elements overlap with other elements' +--- + +import FrameworkSwitch from '$components/FrameworkSwitch.astro'; + +
+
+
+ Sheryians Logo +
+
+
Crafted by Sheryians
+
Developed & maintained by Aayush Chouhan
+
+
+ +
+ +The `overlap` plugin helps you detect when a draggable element touches other elements on the page. It provides real-time overlap detection with visual feedback and event callbacks, optimized for smooth performance. + +```typescript +overlap(); // Check for overlaps with specified targets +overlap({ targets: '.drop-zone' }); // Only check specific elements +overlap({ threshold: 0.5 }); // Need 50% overlap to trigger +overlap({ showOverlap: { enabled: true } }); // Show visual borders +``` + +## Basic Usage + + +
+ +```svelte + + +
+ Drag me over the targets +
+ +
Drop Zone 1
+
Drop Zone 2
+
Special Target
+
Interactive Element
+ +{#if overlapping.length > 0} +

Currently overlapping with {overlapping.length} elements!

+{/if} +``` + +
+ +
+ +```tsx +import { useRef, useState } from 'react'; +import { overlap, useDraggable } from '@neodrag/react'; + +function OverlapDetection() { + const dragRef = useRef(null); + const [overlapping, setOverlapping] = useState([]); + + useDraggable(dragRef, [ + overlap({ + targets: ['.drop-zone', '#special-target', 'div.interactive'], + onOverlapUpdate: (targets) => { + setOverlapping(targets); + console.log('Overlapping with:', targets.length, 'elements'); + } + }) + ]); + + return ( +
+
+ Drag me over the targets +
+
Drop Zone 1
+
Drop Zone 2
+
Special Target
+
Interactive Element
+ {overlapping.length > 0 && ( +

Currently overlapping with {overlapping.length} elements!

+ )} +
+ ); +} +``` + +
+ +
+ +```vue + + + +``` + +
+ +
+ +```tsx +import { createSignal } from 'solid-js'; +import { overlap, useDraggable } from '@neodrag/solid'; + +function OverlapDetection() { + const [dragEl, setDragEl] = createSignal(null); + const [overlapping, setOverlapping] = createSignal([]); + + useDraggable(dragEl, [ + overlap({ + targets: '.drop-zone', + onOverlapUpdate: (targets) => { + setOverlapping(targets); + console.log('Overlapping with:', targets.length, 'elements'); + } + }) + ]); + + return ( +
+
+ Drag me over the drop zones +
+
Drop Zone 1
+
Drop Zone 2
+ 0}> +

Currently overlapping with {overlapping().length} elements!

+
+
+ ); +} +``` + +
+ +
+ +```typescript +import { Draggable, overlap } from '@neodrag/vanilla'; + +const dragElement = document.querySelector('#drag-me') as HTMLElement; +let overlapping: HTMLElement[] = []; + +new Draggable(dragElement, [ + overlap({ + targets: '.drop-zone', + onOverlapUpdate: (targets) => { + overlapping = targets; + console.log('Overlapping with:', targets.length, 'elements'); + + // Update UI + const status = document.querySelector('#status'); + if (status) { + status.textContent = overlapping.length > 0 + ? `Currently overlapping with ${overlapping.length} elements!` + : ''; + } + } + }) +]); +``` + +
+
+ +## Settings + +### targets + +**Type:** `string | string[] | HTMLElement[]` +**Default:** `undefined` (requires targets to be specified) + +Choose which elements to check for overlaps. You can use CSS selectors, element arrays, or mixed formats. + +```typescript +// Single CSS selector +overlap({ targets: '.drop-zone' }) + +// Multiple CSS selectors +overlap({ targets: ['.drop-zone', '.trash-bin', '#special-area'] }) + +// Mixed selectors and elements +overlap({ targets: ['.drop-zone', document.getElementById('area1')] }) + +// Direct HTMLElement array +const zones = [dropZone1, dropZone2, dropZone3]; +overlap({ targets: zones }) + +// Complex selectors +overlap({ targets: ['div.drop-zone:not(.disabled)', '[data-droppable="true"]'] }) +``` + +### threshold + +**Type:** `number` +**Default:** `0.01` + +How much the elements need to overlap before it counts. `0.01` means 1% overlap, `0.5` means 50% overlap. + +```typescript +overlap({ threshold: 0.1 }); // Need 10% overlap +overlap({ threshold: 0.5 }); // Need 50% overlap +overlap({ threshold: 0.01 }); // Need 1% overlap (default) +``` + +### oncePerTarget + +**Type:** `boolean` +**Default:** `true` + +When `true`, start and end events only fire once per element. When `false`, they fire continuously. + +```typescript +overlap({ + oncePerTarget: true, // Fire start/end once per element + oncePerTarget: false, // Fire start/end continuously +}); +``` + +### checkFrequency + +**Type:** `number | 'frame'` +**Default:** `'frame'` + +How often to check for overlaps. Use `'frame'` for smooth checking, or a number for milliseconds. + +```typescript +overlap({ checkFrequency: 'frame' }); // Check every animation frame (smooth) +overlap({ checkFrequency: 16 }); // Check every 16ms (about 60fps) +overlap({ checkFrequency: 100 }); // Check every 100ms (uses less CPU) +``` + +## Events + +### onOverlapStart + +**Type:** `(target: HTMLElement) => void` + +Called when the draggable element starts touching another element. + +```typescript +overlap({ + onOverlapStart: (target) => { + target.classList.add('highlight'); + console.log('Started touching:', target); + } +}); +``` + +### onOverlapEnd + +**Type:** `(target: HTMLElement) => void` + +Called when the draggable element stops touching another element. + +```typescript +overlap({ + onOverlapEnd: (target) => { + target.classList.remove('highlight'); + console.log('Stopped touching:', target); + } +}); +``` + +### onOverlapUpdate + +**Type:** `(targets: HTMLElement[]) => void` + +Called continuously with the current list of overlapping elements. + +```typescript +overlap({ + onOverlapUpdate: (targets) => { + // Update UI based on what you're currently touching + updateDropZoneVisuals(targets); + + // Check if touching a specific element + const isTouchingSpecialZone = targets.some(el => + el.classList.contains('special-drop-zone') + ); + } +}); +``` + +## Visual Helpers + +The overlap plugin can show you visual borders to see what's happening during drag operations. This is great for testing and understanding overlap behavior. + +### Basic Visual Helpers + +```typescript +overlap({ + showOverlap: { + enabled: true, // Turn on visual helpers + } +}); +``` + +### All Visual Helper Options + +```typescript +overlap({ + showOverlap: { + enabled: true, // Turn on visual helpers + dragColor: '#00ff88', // Green for dragged element (default) + targetColor: '#0088ff', // Blue for drop zones (default) + overlapColor: '#ff0000', // Red for overlapping areas (default) + zIndex: 9999, // Layer order (default) + } +}); +``` + +### Custom Color Themes + +```typescript +overlap({ + showOverlap: { + enabled: true, + dragColor: '#ffaa00', // Orange for drag item + targetColor: '#00aaff', // Light blue for targets + overlapColor: '#ff00aa', // Pink for overlaps + zIndex: 10000 + } +}); +``` + +**Game-style colors:** +```typescript +overlap({ + showOverlap: { + enabled: true, + dragColor: '#00ff00', // Bright green for player + targetColor: '#ffff00', // Yellow for collectibles + overlapColor: '#ff0000', // Red for danger zones + } +}); +``` + +**Professional colors:** +```typescript +overlap({ + showOverlap: { + enabled: true, + dragColor: '#2196f3', // Material blue + targetColor: '#4caf50', // Material green + overlapColor: '#ff9800', // Material orange + } +}); +``` + +## Examples + +### File Manager with Drop Zones + +```typescript +// File manager with different types of drop zones +const fileManagerOverlap = [ + overlap({ + targets: [ + '.folder', // Folder elements + '.trash-bin', // Trash bin + '#upload-area', // Upload area + 'div[data-zone]' // Custom data zones + ], + threshold: 0.4, + onOverlapStart: (target) => { + if (target.classList.contains('folder')) { + target.classList.add('folder-highlight'); + } else if (target.classList.contains('trash-bin')) { + target.classList.add('delete-mode'); + } else if (target.id === 'upload-area') { + target.classList.add('upload-ready'); + } + }, + onOverlapEnd: (target) => { + target.classList.remove('folder-highlight', 'delete-mode', 'upload-ready'); + } + }) +]; +``` + +### Simple Game Zone Detection + +```typescript +// Game with different interaction zones +const gameZones = [ + overlap({ + targets: [ + '.enemy', // Enemy characters + '.powerup', // Power-up items + '.checkpoint', // Save points + '.exit-door' // Level exits + ], + threshold: 0.01, + checkFrequency: 'frame', + onOverlapUpdate: (targets) => { + // Handle different zone types at the same time + const enemies = targets.filter(el => el.classList.contains('enemy')); + const powerups = targets.filter(el => el.classList.contains('powerup')); + const checkpoints = targets.filter(el => el.classList.contains('checkpoint')); + + if (enemies.length > 0) handleCombat(enemies); + if (powerups.length > 0) collectPowerups(powerups); + if (checkpoints.length > 0) showSavePrompt(checkpoints[0]); + } + }) +]; +``` + +### Simple Collision Detection + +```typescript +const collision = [ + overlap({ + targets: '.obstacle', + threshold: 0.01, // Any touch counts + onOverlapStart: (obstacle) => { + // Handle collision + obstacle.classList.add('collision'); + showCollisionEffect(); + }, + onOverlapEnd: (obstacle) => { + obstacle.classList.remove('collision'); + } + }) +]; +``` + +### Close Detection + +```typescript +// Use larger invisible zones to detect when things are close +const proximity = [ + overlap({ + targets: '.proximity-zone', // Larger invisible elements + threshold: 0.01, + onOverlapStart: (zone) => { + const actualTarget = zone.querySelector('.actual-element'); + actualTarget?.classList.add('nearby'); + }, + onOverlapEnd: (zone) => { + const actualTarget = zone.querySelector('.actual-element'); + actualTarget?.classList.remove('nearby'); + } + }) +]; +``` + +### Performance Tips + +```typescript +// For better performance with many targets +const optimized = [ + overlap({ + targets: '.many-targets', + checkFrequency: 50, // Check every 50ms instead of every frame + threshold: 0.1, // Higher threshold = fewer false positives + oncePerTarget: true, // Fewer callback calls + showOverlap: { + enabled: false // Disable visuals in production + } + }) +]; + +// For maximum performance +const ultraFast = [ + overlap({ + targets: document.querySelectorAll('.target'), // Pre-selected elements + checkFrequency: 100, // Slower checking + threshold: 0.2, // Higher threshold + }) +]; +``` + +## Using with Other Plugins + +### With Events for Drop Handling + +```typescript +const plugins = [ + overlap({ + targets: '.drop-zone', + onOverlapUpdate: (targets) => { + currentDropTargets = targets; + } + }), + events({ + onDragEnd: () => { + if (currentDropTargets.length > 0) { + handleDrop(currentDropTargets[0]); + } + } + }) +]; +``` + +### With Bounds for Staying Inside Areas + +```typescript +const plugins = [ + bounds(BoundsFrom.parent()), + overlap({ + targets: '.inner-elements', + showOverlap: { enabled: true } + }) +]; +``` + +### With Grid for Snap-to-Grid + +```typescript +const plugins = [ + grid([20, 20]), + overlap({ + targets: '.grid-cell', + threshold: 0.8, // Need mostly overlapping for grid snapping + }) +]; +``` + +## Tips for Better Performance + +### Choosing Drop Zones +- Use specific CSS selectors instead of checking all elements +- Pre-select elements when possible: `targets: document.querySelectorAll('.zone')` +- Use data attributes to identify targets: `targets: '[data-droppable="true"]'` + +### Check Frequency +- Use `'frame'` for smooth visual feedback and games +- Use higher numbers (like 50-100ms) for better performance with many targets +- Consider slower checking on mobile devices + +### Overlap Amount +- Higher thresholds (0.3-0.5) reduce false positives and improve performance +- Lower thresholds (0.01-0.1) provide more sensitive detection +- Test with different screen sizes and touch interfaces + +### Visual Helpers +- Disable `showOverlap` in production for better performance +- Use visual helpers only during development and testing +- Custom colors can help distinguish different interaction zones + +## Real Examples + +### File Manager + + +
+ +```svelte + + +{#each folders as folder} +
+ {folder} +
+{/each} + +
draggedFile = 'file.txt', + onDragEnd: handleFileDrop + }) + ])}> + file.txt +
+ + +``` + +
+
+ +### Simple Game Collision + +```typescript +// Game-like collision detection +const gameCollision = [ + overlap({ + targets: '.enemy, .obstacle, .powerup', + threshold: 0.01, + checkFrequency: 'frame', // Smooth for games + onOverlapStart: (target) => { + if (target.classList.contains('enemy')) { + handleEnemyHit(target); + } else if (target.classList.contains('powerup')) { + collectPowerup(target); + } else if (target.classList.contains('obstacle')) { + handleCrash(target); + } + }, + showOverlap: { + enabled: isDevelopment, // Only show during development + dragColor: '#00ff00', + overlapColor: '#ff000080' // Semi-transparent red + } + }) +]; +``` + +The overlap plugin is perfect for creating drag-and-drop interfaces, collision detection, and proximity-based interactions. The visual helpers make it easy to see and debug overlap behavior during development, while the optimized performance ensures smooth operation in production. diff --git a/packages/core/src/overlap.ts b/packages/core/src/overlap.ts new file mode 100644 index 00000000..a02d007a --- /dev/null +++ b/packages/core/src/overlap.ts @@ -0,0 +1,447 @@ +/** + * Overlap Detection Plugin for NeoDrag + * + * Developed and maintained by Sheryians (Aayush Chouhan) + * https://github.com/sheryianscodingschool + */ + +import { unstable_definePlugin } from "./plugins"; + + +export interface ShowOverlapConfig { + enabled?: boolean; + dragColor?: string; + targetColor?: string; + overlapColor?: string; + zIndex?: number; +} + +export interface OverlapOptions { + targets?: string | string[] | HTMLElement[]; + threshold?: number; + oncePerTarget?: boolean; + checkFrequency?: number | 'frame'; + showOverlap?: ShowOverlapConfig; + onOverlapStart?: (target: HTMLElement) => void; + onOverlapEnd?: (target: HTMLElement) => void; + onOverlapUpdate?: (targets: HTMLElement[]) => void; +} + +/** Creates an overlap detection plugin for neodrag */ +export const overlap = unstable_definePlugin((options: OverlapOptions = {}) => { + const { targets, threshold = 0.01, oncePerTarget = true, checkFrequency = 'frame', showOverlap = {}, onOverlapStart, onOverlapEnd, onOverlapUpdate } = options; + + const cfg = { + enabled: showOverlap.enabled ?? false, + dragColor: showOverlap.dragColor ?? '#00ff88', + targetColor: showOverlap.targetColor ?? '#0088ff', + overlapColor: showOverlap.overlapColor ?? '#ff0000', + zIndex: showOverlap.zIndex ?? 9999, + } as Required; + + let lastOverlaps: Set = new Set(); + let intervalId: number | null = null; + let animationFrameId: number | null = null; + let cachedTargets: HTMLElement[] | null = null; + let targetsNeedRefresh = true; + let debugOverlays: Map = new Map(); + let debugStyleSheet: HTMLStyleElement | null = null; + let overlayPool: HTMLElement[] = []; // Pool for reusing overlay elements + + // Observer-based optimization + let intersectionObserver: IntersectionObserver | null = null; + let resizeObserver: ResizeObserver | null = null; + let draggedElement: HTMLElement | null = null; + let observedElements: Set = new Set(); + let elementRectCache: Map = new Map(); + let observerUpdateScheduled = false; + let scrollEventListener: (() => void) | null = null; + + const initOverlapStyles = () => { + if (!cfg.enabled || debugStyleSheet) return; + + debugStyleSheet = document.createElement('style'); + debugStyleSheet.id = 'neodrag-overlap-visual-styles'; + + const z = cfg.zIndex; + + debugStyleSheet.textContent = `.neodrag-overlap-indicator{pointer-events:none;position:fixed;box-sizing:border-box;z-index:${z}}.neodrag-overlap-overlap{border:2px solid ${cfg.overlapColor}!important}.neodrag-overlap-drag{border:2px solid ${cfg.dragColor}!important}.neodrag-overlap-target{border:2px solid ${cfg.targetColor}!important}`; + + document.head.appendChild(debugStyleSheet); + }; + + const cleanupOverlapVisuals = () => { + debugOverlays.forEach(o => { + o.remove(); + overlayPool.push(o); // Return to pool for reuse + }); + debugOverlays.clear(); + if (debugStyleSheet?.parentNode) { + debugStyleSheet.remove(); + debugStyleSheet = null; + } + }; + + const createOverlay = (rect: DOMRect, className: string) => { + const o = overlayPool.pop() || document.createElement('div'); + o.className = className; + o.style.cssText = `left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px`; + return o; + }; + + const updateOverlapVisuals = (draggedEl: HTMLElement | null, targetElements: HTMLElement[], overlappedData: Array<{ element: HTMLElement, percentage: number, draggedRect: DOMRect, targetRect: DOMRect }>) => { + if (!cfg.enabled) return; + + debugOverlays.forEach(o => o.remove()); + debugOverlays.clear(); + + // Show border on dragged element - use cached rect only + if (draggedEl) { + const dragRect = elementRectCache.get(draggedEl); + if (dragRect && dragRect.width > 0 && dragRect.height > 0) { + const o = createOverlay(dragRect, 'neodrag-overlap-indicator neodrag-overlap-drag'); + document.body.appendChild(o); + debugOverlays.set('drag-indicator', o); + } + } + + // Show borders on target elements - use cached rects only + for (let i = 0; i < targetElements.length; i++) { + const el = targetElements[i]; + if (el !== draggedEl) { + const targetRect = elementRectCache.get(el); + if (targetRect && targetRect.width > 0 && targetRect.height > 0) { + const o = createOverlay(targetRect, 'neodrag-overlap-indicator neodrag-overlap-target'); + document.body.appendChild(o); + debugOverlays.set(`target-indicator-${i}`, o); + } + } + } + + for (let i = 0; i < overlappedData.length; i++) { + const { targetRect } = overlappedData[i]; + if (targetRect.width > 0 && targetRect.height > 0) { + const o = createOverlay(targetRect, 'neodrag-overlap-indicator neodrag-overlap-overlap'); + document.body.appendChild(o); + debugOverlays.set(`overlap-indicator-${i}`, o); + } + } + }; + + const updateElementRectCache = (element: HTMLElement) => { + if (element.isConnected) { + elementRectCache.set(element, element.getBoundingClientRect()); + } else { + elementRectCache.delete(element); + observedElements.delete(element); + } + }; + + const cleanupObservers = () => { + if (intersectionObserver) { + intersectionObserver.disconnect(); + intersectionObserver = null; + } + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (scrollEventListener) { + window.removeEventListener('scroll', scrollEventListener); + scrollEventListener = null; + } + }; + + const scheduleObserverUpdate = () => { + if (observerUpdateScheduled) return; + observerUpdateScheduled = true; + + requestAnimationFrame(() => { + if (!observerUpdateScheduled) return; + observerUpdateScheduled = false; + + observedElements.forEach(element => { + if (element?.isConnected) updateElementRectCache(element); + }); + + if (draggedElement?.isConnected) { + updateElementRectCache(draggedElement); + runDetection({ rootNode: draggedElement }); + } + }); + }; + + const initObservers = () => { + cleanupObservers(); + + // Intersection Observer to detect when elements enter/leave viewport + intersectionObserver = new IntersectionObserver((entries) => { + let shouldUpdate = false; + entries.forEach(entry => { + const element = entry.target as HTMLElement; + if (entry.isIntersecting) { + updateElementRectCache(element); + observedElements.add(element); + } else { + elementRectCache.delete(element); + observedElements.delete(element); + } + shouldUpdate = true; + }); + if (shouldUpdate) scheduleObserverUpdate(); + }, { + root: null, + rootMargin: '50px', // Small margin to catch elements just outside viewport + threshold: 0 + }); + + // Resize Observer to detect when elements change size or position + resizeObserver = new ResizeObserver((entries) => { + let shouldUpdate = false; + entries.forEach(entry => { + const element = entry.target as HTMLElement; + if (observedElements.has(element) || element === draggedElement) { + updateElementRectCache(element); + shouldUpdate = true; + } + }); + if (shouldUpdate) scheduleObserverUpdate(); + }); + + // Listen for scroll events to update element positions + scrollEventListener = scheduleObserverUpdate; + window.addEventListener('scroll', scrollEventListener, { passive: true }); + }; + + const observeElement = (element: HTMLElement) => { + if (!observedElements.has(element) && element.isConnected) { + intersectionObserver?.observe(element); + resizeObserver?.observe(element); + observedElements.add(element); + updateElementRectCache(element); + } + }; + + const unobserveElement = (element: HTMLElement) => { + if (observedElements.has(element)) { + intersectionObserver?.unobserve(element); + resizeObserver?.unobserve(element); + observedElements.delete(element); + elementRectCache.delete(element); + } + }; + + const refreshObservedElements = () => { + const targetElements = getTargetElements(); + + // Remove elements that are no longer targets + for (const element of observedElements) { + if (!targetElements.includes(element) && element !== draggedElement) { + unobserveElement(element); + } + } + + // Add new target elements + for (let i = 0; i < targetElements.length; i++) { + const element = targetElements[i]; + if (element !== draggedElement) observeElement(element); + } + }; + + const getTargetElements = (): HTMLElement[] => { + if (cachedTargets && !targetsNeedRefresh) return cachedTargets; + let elements: HTMLElement[]; + + if (!targets) { + elements = []; + } else if (typeof targets === 'string') { + // Single string selector + try { + elements = Array.from(document.querySelectorAll(targets)); + } catch (error) { + elements = []; + } + } else if (Array.isArray(targets)) { + // Array of selectors or HTMLElements + elements = []; + for (const target of targets) { + if (typeof target === 'string') { + try { + const found = Array.from(document.querySelectorAll(target)); + elements.push(...found); + } catch (error) { + // Skip invalid selectors + } + } else if (target instanceof HTMLElement && target.isConnected) { + elements.push(target); + } + } + } else { + elements = []; + } + + // Remove duplicates using Set and filter out disconnected elements + const uniqueElements = Array.from(new Set(elements)).filter(el => el.isConnected); + cachedTargets = uniqueElements; + targetsNeedRefresh = false; + return uniqueElements; + }; + + // Ultra-fast overlap calculation with minimal operations + const getOverlapPercentage = (a: DOMRect, b: DOMRect) => { + const intersection = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * + Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); + return intersection / (a.width * a.height || 1); + }; + + const runDetection = (ctx: any) => { + if (!ctx?.rootNode) return; + + const draggedEl = ctx.rootNode as HTMLElement; + if (!draggedEl) return; + + // Always use cached rect for dragged element + draggedElement = draggedEl; + updateElementRectCache(draggedElement); + const draggedRect = elementRectCache.get(draggedElement); + + if (!draggedRect || draggedRect.width === 0 || draggedRect.height === 0) return; + + // Create fresh buffers + const overlappedBuffer: HTMLElement[] = []; + const overlappedDataBuffer: Array<{ element: HTMLElement, percentage: number, draggedRect: DOMRect, targetRect: DOMRect }> = []; + + const targetElements = getTargetElements(); + const minThreshold = Math.max(threshold, 0.000001); + + // Optimized detection loop - cache-only + for (let i = 0; i < targetElements.length; i++) { + const el = targetElements[i]; + if (el === draggedEl) continue; + + let elRect = elementRectCache.get(el); + if (!elRect) { + // Cache miss - update cache + updateElementRectCache(el); + elRect = elementRectCache.get(el); + if (!elRect) continue; // Element disconnected + } + + if (elRect.width === 0 || elRect.height === 0) continue; + + // Fast intersection check + if (draggedRect.right <= elRect.left || elRect.right <= draggedRect.left || + draggedRect.bottom <= elRect.top || elRect.bottom <= draggedRect.top) continue; + + const percent = getOverlapPercentage(draggedRect, elRect); + if (percent >= minThreshold) { + overlappedBuffer.push(el); + overlappedDataBuffer.push({ element: el, percentage: percent, draggedRect, targetRect: elRect }); + } + } + + // Update visuals if enabled + if (cfg.enabled) { + updateOverlapVisuals(draggedEl, targetElements, overlappedDataBuffer); + } + + // Handle overlap events - optimized + const currentSet = new Set(overlappedBuffer); + + if (oncePerTarget) { + // Process new overlaps + for (const el of currentSet) { + if (!lastOverlaps.has(el)) onOverlapStart?.(el); + } + // Process ended overlaps + for (const el of lastOverlaps) { + if (!currentSet.has(el)) onOverlapEnd?.(el); + } + } else { + for (let i = 0; i < overlappedBuffer.length; i++) { + onOverlapStart?.(overlappedBuffer[i]); + } + } + + onOverlapUpdate?.(overlappedBuffer); + lastOverlaps = currentSet; + }; + + return { + name: 'overlap-detection', + liveUpdate: true, + + drag(ctx) { + if (checkFrequency === 'frame') { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(() => runDetection(ctx)); + } + }, + start(ctx) { + if (!ctx?.rootNode) return; + + if (cfg.enabled) initOverlapStyles(); + targetsNeedRefresh = true; + + if (!intersectionObserver || !resizeObserver) initObservers(); + + const rootElement = ctx.rootNode as HTMLElement; + if (rootElement) { + draggedElement = rootElement; + if (resizeObserver) { + resizeObserver.observe(draggedElement); + updateElementRectCache(draggedElement); + } + } else { + return; + } + + refreshObservedElements(); + + // Show borders when dragging starts + if (cfg.enabled) { + updateOverlapVisuals(draggedElement, getTargetElements(), []); + } + + if (checkFrequency !== 'frame' && typeof checkFrequency === 'number' && checkFrequency > 0) { + intervalId = window.setInterval(() => runDetection(ctx), checkFrequency); + } + }, + end() { + if (intervalId !== null) { clearInterval(intervalId); intervalId = null; } + if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } + + if (draggedElement && resizeObserver) { + resizeObserver.unobserve(draggedElement); + elementRectCache.delete(draggedElement); + } + + for (const el of lastOverlaps) onOverlapEnd?.(el); + lastOverlaps.clear(); + + // Clean up visuals when dragging ends + if (cfg.enabled) { + cleanupOverlapVisuals(); + } + + draggedElement = null; + }, + cleanup() { + if (intervalId !== null) { clearInterval(intervalId); intervalId = null; } + if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } + + cleanupObservers(); + + observedElements.clear(); + elementRectCache.clear(); + lastOverlaps.clear(); + cachedTargets = null; + targetsNeedRefresh = true; + draggedElement = null; + observerUpdateScheduled = false; + + cleanupOverlapVisuals(); + overlayPool.length = 0; // Clear overlay pool + }, + }; +}); diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts index decc3bf8..7c0f72ee 100644 --- a/packages/core/src/plugins.ts +++ b/packages/core/src/plugins.ts @@ -79,6 +79,8 @@ export function unstable_definePlugin( return fn; } +export { overlap } from './overlap.ts'; + export const ignoreMultitouch = unstable_definePlugin((value: boolean = true) => ({ name: 'neodrag:ignoreMultitouch', @@ -652,35 +654,35 @@ export const ControlFrom = { elements: (elements: NodeListOf | (Element | null | undefined)[]) => - (root: Element): ControlZone[] => { - const root_rect = root.getBoundingClientRect(); - - const data: { - element: Element; - top: number; - right: number; - bottom: number; - left: number; - area: number; - }[] = []; - - for (const el of Array.from(elements)) { - if (!el) continue; - - const rect = el.getBoundingClientRect(); - - data.push({ - element: el, - top: rect.top - root_rect.top, - right: rect.right - root_rect.left, - bottom: rect.bottom - root_rect.top, - left: rect.left - root_rect.left, - area: rect.width * rect.height, - }); - } + (root: Element): ControlZone[] => { + const root_rect = root.getBoundingClientRect(); + + const data: { + element: Element; + top: number; + right: number; + bottom: number; + left: number; + area: number; + }[] = []; + + for (const el of Array.from(elements)) { + if (!el) continue; + + const rect = el.getBoundingClientRect(); + + data.push({ + element: el, + top: rect.top - root_rect.top, + right: rect.right - root_rect.left, + bottom: rect.bottom - root_rect.top, + left: rect.left - root_rect.left, + area: rect.width * rect.height, + }); + } - return data; - }, + return data; + }, }; // Helper to check if a zone is nested within another zone diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index da8a0a6a..ceb696ce 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig([ format: 'esm', dts: { resolve: true }, clean: true, + minify: true, treeshake: 'smallest', }, ]);