diff --git a/.prettierrc b/.prettierrc index 67578c13..b2ca63be 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { - "singleQuote": true, - "printWidth": 100, - "useTabs": true + "singleQuote": true, + "printWidth": 100, + "useTabs": true, + "trailingComma": "all" } diff --git a/docs/src/data/sizes.json b/docs/src/data/sizes.json index f9292849..62f5ece2 100644 --- a/docs/src/data/sizes.json +++ b/docs/src/data/sizes.json @@ -1,22 +1,22 @@ { - "react": { - "size": 2.14, - "version": "2.2.0" - }, "svelte": { - "size": 2.96, + "size": 3.64, "version": "2.2.0" }, - "vanilla": { - "size": 1.95, + "react": { + "size": 2.28, "version": "2.2.0" }, "solid": { - "size": 1.93, + "size": 2.07, "version": "2.2.0" }, "vue": { "size": 1.98, "version": "2.2.0" + }, + "vanilla": { + "size": 2.1, + "version": "2.2.0" } } \ No newline at end of file diff --git a/docs/src/helpers/utils.ts b/docs/src/helpers/utils.ts index 3d6335c4..b9501d02 100644 --- a/docs/src/helpers/utils.ts +++ b/docs/src/helpers/utils.ts @@ -20,3 +20,8 @@ export function elements_overlap(el1: HTMLElement, el2: HTMLElement) { export function wait_for(ms: number) { return new Promise((r) => setTimeout(r, ms)); } + +// Add scroll helpers +export function isWindow(container: HTMLElement | Window): container is Window { + return container === window; +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index cc0a019f..ba676abf 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,15 +1,16 @@ import type { Plugin, PluginContext } from './plugins.ts'; +import { listen } from './utils.ts'; type DeepMutable = T extends object ? { -readonly [P in keyof T]: T[P] extends readonly any[] ? DeepMutable : T[P] extends object - ? keyof T[P] extends never - ? T[P] - : DeepMutable - : T[P]; - } + ? keyof T[P] extends never + ? T[P] + : DeepMutable + : T[P]; + } : T; interface DraggableInstance { @@ -25,8 +26,8 @@ interface DraggableInstance { export function createDraggable({ plugins: initial_plugins = [], - delegateTargetFn = () => document.body, -}: { plugins?: Plugin[]; delegateTargetFn?: () => HTMLElement } = {}) { + delegate: delegateTargetFn = () => document.body, +}: { plugins?: Plugin[]; delegate?: () => HTMLElement } = {}) { const instances = new WeakMap(); let listeners_initialized = false; let active_node: HTMLElement | null = null; @@ -36,16 +37,16 @@ export function createDraggable({ const delegateTarget = delegateTargetFn(); - delegateTarget.addEventListener('pointerdown', handle_pointer_down, { + listen(delegateTarget, 'pointerdown', handle_pointer_down, { passive: true, capture: false, }); - delegateTarget.addEventListener('pointermove', handle_pointer_move, { + listen(delegateTarget, 'pointermove', handle_pointer_move, { passive: false, capture: false, }); - delegateTarget.addEventListener('pointerup', handle_pointer_up, { - passive: false, + listen(delegateTarget, 'pointerup', handle_pointer_up, { + passive: true, capture: false, }); @@ -132,8 +133,7 @@ export function createDraggable({ if (!instance.ctx.isInteracting) return; if (instance.ctx.isDragging) { - // Listen for click handler and cancel it - active_node.addEventListener('click', (e) => e.stopPropagation(), { + listen(active_node, 'click', (e) => e.stopPropagation(), { once: true, signal: instance.controller.signal, capture: true, @@ -298,7 +298,7 @@ export function createDraggable({ // Clean up old instance if different if (old_plugin && old_plugin !== new_plugin) { - old_plugin.cleanup?.(); + old_plugin.cleanup?.(instance.ctx, instance.states.get(old_plugin.name)); instance.states.delete(old_plugin.name); } @@ -340,7 +340,7 @@ export function createDraggable({ ); for (const plugin of removed_plugins) { - plugin.cleanup?.(); + plugin.cleanup?.(instance.ctx, instance.states.get(plugin.name)); instance.states.delete(plugin.name); has_changes = true; } @@ -377,7 +377,7 @@ export function createDraggable({ } for (const plugin of instance.plugins) { - plugin.cleanup?.(); + plugin.cleanup?.(instance.ctx, instance.states.get(plugin.name)); } instances.delete(node); diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts index e37f99fe..2ebbe651 100644 --- a/packages/core/src/plugins.ts +++ b/packages/core/src/plugins.ts @@ -1,3 +1,5 @@ +import { get_node_style, set_node_dataset, set_node_key_style } from './utils.ts'; + export interface PluginContext { delta: { x: number; @@ -91,7 +93,7 @@ export interface Plugin { dragEnd?: (context: PluginContext, state: PrivateState, event: PointerEvent) => void; // Cleanup when draggable is destroyed - cleanup?: () => void; + cleanup?: (context: PluginContext, state: PrivateState) => void; } /** @@ -140,9 +142,9 @@ export const stateMarker = unstable_definePlugin(() => { cancelable: false, setup(ctx) { - ctx.rootNode.dataset.neodrag = ''; - ctx.rootNode.dataset.neodragState = 'idle'; - ctx.rootNode.dataset.neodragCount = '0'; + set_node_dataset(ctx.rootNode, 'neodrag', ''); + set_node_dataset(ctx.rootNode, 'neodragState', 'idle'); + set_node_dataset(ctx.rootNode, 'neodragCount', '0'); return { count: 0, @@ -151,13 +153,13 @@ export const stateMarker = unstable_definePlugin(() => { dragStart(ctx) { ctx.effect(() => { - ctx.rootNode.dataset.neodragState = 'dragging'; + set_node_dataset(ctx.rootNode, 'neodragState', 'dragging'); }); }, dragEnd(ctx, state) { - ctx.rootNode.dataset.neodragState = 'idle'; - ctx.rootNode.dataset.neodragCount = (++state.count).toString(); + set_node_dataset(ctx.rootNode, 'neodragState', 'idle'); + set_node_dataset(ctx.rootNode, 'neodragCount', ++state.count); }, }; }); @@ -189,15 +191,15 @@ export const applyUserSelectHack = unstable_definePlugin((value: boolean = true) dragStart(ctx, state) { ctx.effect(() => { if (value) { - state.body_user_select_val = document.body.style.userSelect; - document.body.style.userSelect = 'none'; + state.body_user_select_val = get_node_style(document.body, 'userSelect'); + set_node_key_style(document.body, 'userSelect', 'none'); } }); }, dragEnd(_, state) { if (value) { - document.body.style.userSelect = state.body_user_select_val; + set_node_key_style(document.body, 'userSelect', state.body_user_select_val); } }, }; @@ -251,7 +253,11 @@ export const transform = unstable_definePlugin( rootNode: ctx.rootNode, }); } else { - ctx.rootNode.style.translate = `${ctx.offset.x}px ${ctx.offset.y}px`; + set_node_key_style( + ctx.rootNode, + 'transform', + `translate(${ctx.offset.x}px, ${ctx.offset.y}px)`, + ); } } }, @@ -266,7 +272,11 @@ export const transform = unstable_definePlugin( }); } - ctx.rootNode.style.translate = `${ctx.offset.x}px ${ctx.offset.y}px`; + set_node_key_style( + ctx.rootNode, + 'transform', + `translate(${ctx.offset.x}px, ${ctx.offset.y}px)`, + ); }); }, }; @@ -298,6 +308,8 @@ export const BoundsFrom = { }; }, + // selector, + parent(padding?: { top?: number; left?: number; @@ -669,15 +681,143 @@ export const touchAction = unstable_definePlugin((mode: TouchActionMode = 'manip liveUpdate: true, setup(ctx) { - const original_touch_action = ctx.rootNode.style.touchAction; - ctx.rootNode.style.touchAction = mode; + const original_touch_action = get_node_style(ctx.rootNode, 'touchAction'); + set_node_key_style(ctx.rootNode, 'touchAction', mode); return { original_touch_action }; }, dragEnd(ctx, state) { // Restore original touch-action - ctx.rootNode.style.touchAction = state.original_touch_action || 'auto'; + set_node_key_style(ctx.rootNode, 'touchAction', state.original_touch_action || 'auto'); }, }; }); + +// Scroll-lock plugin that prevents scrolling while dragging +export const scrollLock = unstable_definePlugin( + ( + options: { + lockAxis?: 'x' | 'y' | 'both'; // Which axes to lock scrolling on + container?: HTMLElement | (() => HTMLElement); // Custom container to lock + allowScrollbar?: boolean; // Whether to allow scrollbar interaction + } = {}, + ) => { + return { + name: 'neodrag:scrollLock', + + setup() { + const defaults = { + lockAxis: 'both', + container: window, + allowScrollbar: false, + }; + + const config = { ...defaults, ...options }; + + return { + config, + originalStyles: new Map< + HTMLElement, + { + userSelect: string; + touchAction: string; + overflow: string; + } + >(), + containerRect: null as DOMRect | null, + lastContainerCheck: 0, + }; + }, + + dragStart(ctx, state) { + const container = + typeof state.config.container === 'function' + ? state.config.container() + : state.config.container; + + // Reset cache + state.containerRect = null; + state.lastContainerCheck = 0; + + ctx.effect(() => { + // Store original styles + if (container instanceof HTMLElement) { + state.originalStyles.set(container, { + userSelect: get_node_style(container, 'userSelect'), + touchAction: get_node_style(container, 'touchAction'), + overflow: get_node_style(container, 'overflow'), + }); + + // Apply scroll locking styles + set_node_key_style(container, 'userSelect', 'none'); + + if (!state.config.allowScrollbar) { + set_node_key_style(container, 'overflow', 'hidden'); + } + + if (state.config.lockAxis === 'x' || state.config.lockAxis === 'both') { + set_node_key_style(container, 'touchAction', 'pan-y'); + } else if (state.config.lockAxis === 'y') { + set_node_key_style(container, 'touchAction', 'pan-x'); + } + } else { + // For window, we need to lock the body + const body = document.body; + state.originalStyles.set(body, { + userSelect: get_node_style(body, 'userSelect'), + touchAction: get_node_style(body, 'touchAction'), + overflow: get_node_style(body, 'overflow'), + }); + + set_node_key_style(body, 'userSelect', 'none'); + + if (!state.config.allowScrollbar) { + set_node_key_style(body, 'overflow', 'hidden'); + } + + if (state.config.lockAxis === 'x' || state.config.lockAxis === 'both') { + set_node_key_style(body, 'touchAction', 'pan-y'); + } else if (state.config.lockAxis === 'y') { + set_node_key_style(body, 'touchAction', 'pan-x'); + } + } + }); + }, + + dragEnd(ctx, state) { + const container = + typeof state.config.container === 'function' + ? state.config.container() + : state.config.container; + + ctx.effect(() => { + const target = container instanceof HTMLElement ? container : document.body; + const originalStyles = state.originalStyles.get(target); + + if (originalStyles) { + set_node_key_style(target, 'userSelect', originalStyles.userSelect); + set_node_key_style(target, 'touchAction', originalStyles.touchAction); + set_node_key_style(target, 'overflow', originalStyles.overflow); + } + + state.originalStyles.delete(target); + }); + + // Clear cache + state.containerRect = null; + state.lastContainerCheck = 0; + }, + + cleanup(_, state) { + // Restore any remaining original styles + for (const [element, styles] of state.originalStyles) { + set_node_key_style(element, 'userSelect', styles.userSelect); + set_node_key_style(element, 'touchAction', styles.touchAction); + set_node_key_style(element, 'overflow', styles.overflow); + } + state.originalStyles.clear(); + }, + }; + }, +); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 00000000..21e09b60 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,25 @@ +// Write a perfectly generic function that is an event listener +export function listen( + el: HTMLElement, + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +) { + el.addEventListener(type, listener, options); +} + +export function set_node_key_style( + node: HTMLElement, + key: T, + value: CSSStyleDeclaration[T], +) { + node.style.setProperty(key.toString(), value?.toString() ?? ''); +} + +export function get_node_style(node: HTMLElement, key: keyof CSSStyleDeclaration) { + return node.style.getPropertyValue(key.toString()); +} + +export function set_node_dataset(node: HTMLElement, key: string, value: unknown) { + node.dataset[key.toString()] = value?.toString() ?? ''; +} diff --git a/packages/svelte/demo/src/routes/modular/+page.svelte b/packages/svelte/demo/src/routes/modular/+page.svelte index c81d9b23..54827678 100644 --- a/packages/svelte/demo/src/routes/modular/+page.svelte +++ b/packages/svelte/demo/src/routes/modular/+page.svelte @@ -1,5 +1,13 @@ @@ -63,7 +72,7 @@ :global { body { margin: 0; - height: 50vh; + height: 200vh; } }