/** * Reusable selection state-machine: shift+click/shift+drag range select plus * rubber-band marquee drag, anchored on the last clicked row. Both the sidebar * conversation list and the dialog conversations table share this. * * The hook mutates the consumer's SvelteSet directly; the consumer * owns the source of truth and reads it like any other $state. orderedIds * must reflect the current visual order of selectable rows so the range * matches what the user sees on screen. */ import { UI_DATA_ATTRS } from '$lib/constants'; import { SvelteSet } from 'svelte/reactivity'; interface UseMarqueeSelectionOptions { /** Latest selected-IDs set. Re-read per selection event so consumer-side reassignment works. */ selectedIds: () => SvelteSet; /** IDs in the current rendered order; used to compute shift+click ranges and gate marquee visibility. */ orderedIds: () => string[]; /** Document listeners attach only while the getter returns true. */ enabled: () => boolean; /** Full `data-*` attribute that marks selectable rows. */ dataAttr?: () => string; /** Minimum pixel distance before a press becomes a marquee drag. */ dragThresholdPx?: number; } export function useMarqueeSelection(options: UseMarqueeSelectionOptions) { const dragThresholdPx = options.dragThresholdPx ?? 5; let dragAnchorId = $state(null); let isMarqueeDragging = $state(false); let mouseDownActive = false; let dragStartX = 0; let dragStartY = 0; let mousedownRowId: string | null = null; let dragMode: 'add' | 'remove' | null = null; let suppressNextClick = false; function resolveDataAttr(): string { return options.dataAttr?.() ?? UI_DATA_ATTRS.CONVERSATION_ROW; } function decideDragMode(startingRowId: string | null, currentlySelected: ReadonlySet) { return startingRowId !== null && currentlySelected.has(startingRowId) ? 'remove' : 'add'; } /** * Range-select uses Finder-style toggle-by-target semantics: if the target * row is currently selected the range becomes deselected, otherwise it * becomes selected. Anchor moves to `toId` so chained shift+clicks keep * extending from the previous endpoint. */ function rangeSelect(fromId: string, toId: string) { const selected = options.selectedIds(); const order = options.orderedIds(); const fromIdx = order.indexOf(fromId); const toIdx = order.indexOf(toId); if (fromIdx === -1 || toIdx === -1) return; const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; const shouldSelect = !selected.has(toId); for (let i = lo; i <= hi; i++) { const id = order[i]; if (shouldSelect) selected.add(id); else selected.delete(id); } } function findRowAtPoint(x: number, y: number): string | null { const attr = resolveDataAttr(); const selector = `[${attr}]`; let bestMatch: HTMLElement | null = null; let bestCenterDistance = Infinity; for (const row of document.querySelectorAll(selector)) { const rect = row.getBoundingClientRect(); if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) { return row.getAttribute(attr); } if (x >= rect.left && x <= rect.right) { const centerDistance = Math.abs(y - (rect.top + rect.height / 2)); if (centerDistance < bestCenterDistance) { bestCenterDistance = centerDistance; bestMatch = row; } } } return bestMatch ? bestMatch.getAttribute(attr) : null; } function updateMarqueeRect(currentX: number, currentY: number) { const attr = resolveDataAttr(); const selector = `[${attr}]`; const selected = options.selectedIds(); const left = Math.min(dragStartX, currentX); const top = Math.min(dragStartY, currentY); const right = Math.max(dragStartX, currentX); const bottom = Math.max(dragStartY, currentY); const visibleIds = new SvelteSet(options.orderedIds()); for (const row of document.querySelectorAll(selector)) { const id = row.getAttribute(attr); if (!id || !visibleIds.has(id)) continue; const rect = row.getBoundingClientRect(); const intersects = !( rect.right < left || rect.left > right || rect.bottom < top || rect.top > bottom ); if (dragMode === 'add') { if (intersects) selected.add(id); } else if (dragMode === 'remove') { if (intersects && selected.has(id)) selected.delete(id); } } } function handleDocumentMouseMove(event: MouseEvent) { if (!mouseDownActive) return; if (event.shiftKey && dragAnchorId !== null) { const target = findRowAtPoint(event.clientX, event.clientY); if (target && target !== mousedownRowId) rangeSelect(dragAnchorId, target); return; } if (!isMarqueeDragging) { const dx = event.clientX - dragStartX; const dy = event.clientY - dragStartY; if (Math.hypot(dx, dy) < dragThresholdPx) return; isMarqueeDragging = true; dragMode = decideDragMode(mousedownRowId, options.selectedIds()); } updateMarqueeRect(event.clientX, event.clientY); } function handleDocumentMouseUp(event: MouseEvent) { if (isMarqueeDragging) { suppressNextClick = true; const target = findRowAtPoint(event.clientX, event.clientY); if (target) dragAnchorId = target; } isMarqueeDragging = false; mouseDownActive = false; mousedownRowId = null; dragMode = null; dragStartX = 0; dragStartY = 0; } function handleClickCapture(event: MouseEvent) { if (suppressNextClick) { event.stopPropagation(); event.preventDefault(); suppressNextClick = false; } } $effect(() => { if (!options.enabled()) { reset(); return; } document.addEventListener('mousemove', handleDocumentMouseMove); document.addEventListener('mouseup', handleDocumentMouseUp); document.addEventListener('click', handleClickCapture, { capture: true }); return () => { document.removeEventListener('mousemove', handleDocumentMouseMove); document.removeEventListener('mouseup', handleDocumentMouseUp); document.removeEventListener('click', handleClickCapture, { capture: true }); }; }); function rowMouseDown(id: string, event: MouseEvent) { if (!options.enabled()) return; if (event.button !== 0) return; event.preventDefault(); mouseDownActive = true; mousedownRowId = id; dragStartX = event.clientX; dragStartY = event.clientY; isMarqueeDragging = false; dragMode = null; } function rowClick(id: string, shiftKey: boolean) { if (!options.enabled()) return; const selected = options.selectedIds(); if (shiftKey) { const anchor = dragAnchorId; if (anchor !== null && anchor !== id) { rangeSelect(anchor, id); } else if (selected.has(id)) { selected.delete(id); } else { selected.add(id); } dragAnchorId = id; return; } if (selected.has(id)) selected.delete(id); else selected.add(id); dragAnchorId = id; } function reset() { dragAnchorId = null; isMarqueeDragging = false; mouseDownActive = false; suppressNextClick = false; mousedownRowId = null; dragMode = null; dragStartX = 0; dragStartY = 0; } return { get dragAnchorId() { return dragAnchorId; }, reset, rowClick, rowMouseDown }; }