0 directories, 46 files

util

Home / tinai / util
import { scroller_of } from './scrollbars.js';

/**
 * Shared grip-drag reorder controller used by the document, choices, and
 * sticky notes editors. Owns the mechanics every editor repeats: container
 * delegation of pointer events, pointer capture, drop-target highlighting
 * via elementFromPoint, the requestAnimationFrame autoscroll loop, and the
 * click guard that swallows the click a drag release produces.
 *
 * The editor supplies callbacks for the parts that differ: how a pointerdown
 * resolves to a draggable element, what extra state identifies the drag
 * (index, guids, kind), which scroll element autoscrolls, which element the
 * pointer is over as drop target, which ancestor elements carry the drag
 * chrome classes, and how a committed drop is applied to the model.
 *
 * @param {Object} options - The drag configuration.
 * @param {Function} options.draggable_of - (e) => draggable element or null;
 *   decides whether a pointerdown starts a drag.
 * @param {Function|null} [options.state_of=null] - (el, e) => state object
 *   merged into the drag record, or null to abort the drag.
 * @param {Function} options.target_of - (x, y, drag) => drop target element
 *   or null; the helper toggles its drop-target class.
 * @param {Function|null} [options.chrome_of=null] - (el) => array of
 *   [root, className] pairs carrying the dragging chrome (e.g. the list's
 *   dragging-items class) for the drag's duration.
 * @param {Function} options.scroll_el_of - (el) => the element whose scroll
 *   container autoscrolls near the viewport edges.
 * @param {Function} options.commit - (drag) => void; applies a committed
 *   drop. drag carries el, target_el, x, y plus the state_of fields.
 * @returns {Object} { bind, take_click_guard } — bind attaches the pointer
 *   listeners to a container; take_click_guard reports and clears the guard
 *   raised by a drag release.
 */
export function create_reorder_drag(options) {
	let drag = null;
	let frame = 0;
	let click_guard = false;

	/**
	 * Highlights the drop target under the pointer, clearing the previous
	 * one.
	 */
	function update_target() {
		const over = options.target_of(drag.x, drag.y, drag) || null;
		if (over === drag.target_el) return;
		drag.target_el?.classList.remove('drop-target');
		drag.target_el = over;
		over?.classList.add('drop-target');
	}

	/**
	 * Autoscrolls near the scroller's edges and refreshes the drop target,
	 * since scrolling moves content under a still pointer.
	 */
	function scroll_step() {
		const scroll = scroller_of(options.scroll_el_of(drag.el));
		if (scroll) {
			const rect = scroll.getBoundingClientRect();
			const edge = 48;
			if (drag.y < rect.top + edge) scroll.scrollTop -= 12;
			else if (drag.y > rect.bottom - edge) scroll.scrollTop += 12;
		}
		update_target();
		frame = requestAnimationFrame(scroll_step);
	}

	/**
	 * Restores the editor chrome and, when committed, hands the drag record
	 * to the editor's commit callback. The click guard is always raised so
	 * the drag release cannot trigger other actions.
	 * @param {boolean} commit - Whether to apply the drop.
	 */
	function end(commit) {
		if (!drag) return;
		cancelAnimationFrame(frame);
		frame = 0;
		drag.target_el?.classList.remove('drop-target');
		drag.el.classList.remove('dragging');
		(options.chrome_of(drag.el) || []).forEach(([root, cls]) => root?.classList.remove(cls));
		const finished = drag;
		drag = null;
		click_guard = true;
		if (commit) options.commit(finished);
	}

	return {
		/**
		 * Attaches the delegated pointer listeners to a container.
		 * @param {HTMLElement} container - The surface the draggable elements
		 *   render into.
		 */
		bind(container) {
			container.addEventListener('pointerdown', (e) => {
				click_guard = false;
				const el = options.draggable_of(e);
				if (!el) return;
				const state = options.state_of ? options.state_of(el, e) : null;
				if (!state) return;
				e.preventDefault();
				drag = Object.assign({ el, target_el: null, x: e.clientX, y: e.clientY }, state);
				try {
					e.target.setPointerCapture(e.pointerId);
				} catch {
					// synthetic or already-released pointers cannot capture
				}
				el.classList.add('dragging');
				(options.chrome_of(el) || []).forEach(([root, cls]) => root?.classList.add(cls));
				update_target();
				frame = requestAnimationFrame(scroll_step);
			});
			container.addEventListener('pointermove', (e) => {
				if (!drag) return;
				drag.x = e.clientX;
				drag.y = e.clientY;
				update_target();
			});
			container.addEventListener('pointerup', () => end(true));
			container.addEventListener('pointercancel', () => end(false));
		},

		/**
		 * Reports whether a drag release consumed the last click, clearing
		 * the guard.
		 * @returns {boolean} True when the click should be swallowed.
		 */
		take_click_guard() {
			if (!click_guard) return false;
			click_guard = false;
			return true;
		}
	};
}
🌐
drag-reorder.js ×
Type: Web, text/x-java
4.82 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File