Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'import type { Workspace, WorkspaceLeaf } from '../../plugin/workspace'
const MOUSE_DRAG_DISTANCE = 5const TOUCH_HOLD_MS = 350const TOUCH_SCROLL_DISTANCE = 8const EDGE_SCROLL_ZONE = 56const EDGE_SCROLL_MIN_PX_PER_SECOND = 160const EDGE_SCROLL_MAX_PX_PER_SECOND = 3200
function edgeScrollVelocity(clientX: number, left: number, right: number): number { const leftProgress = Math.min(1, Math.max(0, (left + EDGE_SCROLL_ZONE - clientX) / EDGE_SCROLL_ZONE)) const rightProgress = Math.min(1, Math.max(0, (clientX - right + EDGE_SCROLL_ZONE) / EDGE_SCROLL_ZONE)) const progress = rightProgress - leftProgress if (!progress) return 0 return Math.sign(progress) * (EDGE_SCROLL_MIN_PX_PER_SECOND + (EDGE_SCROLL_MAX_PX_PER_SECOND - EDGE_SCROLL_MIN_PX_PER_SECOND) * progress * progress)}
interface DragState { leaf: WorkspaceLeaf sourceIndex: number destinationIndex: number orderAtStart: readonly WorkspaceLeaf[] startX: number startScrollLeft: number maxScrollLeft: number tabRects: ReadonlyArray<{ left: number; width: number }>}
/** Tab-strip interaction only. Workspace remains the sole owner of tab order. */export function useTabReorder( workspace: Workspace, leaves: readonly WorkspaceLeaf[], scrollerRef: RefObject<HTMLDivElement>,) { const leavesRef = useRef(leaves) leavesRef.current = leaves const dragRef = useRef<DragState | null>(null) const [preview, setPreview] = useState<DragState | null>(null) const isDragging = preview !== null const [announcement, setAnnouncement] = useState('') const suppressedClickRef = useRef<{ id: string; until: number } | null>(null) const mouseCleanupRef = useRef<(() => void) | null>(null) const lastClientXRef = useRef<number | null>(null)
const clearPreview = useCallback(() => { scrollerRef.current?.querySelectorAll<HTMLElement>('.tab').forEach(tab => { tab.style.transform = '' delete tab.dataset.shifting }) dragRef.current = null lastClientXRef.current = null setPreview(null) }, [scrollerRef])
const startDrag = useCallback((leaf: WorkspaceLeaf, startX: number) => { const scroller = scrollerRef.current if (!scroller) return false const sourceIndex = leavesRef.current.indexOf(leaf) const tabs = scroller.querySelectorAll<HTMLElement>('.tab') if (sourceIndex < 0 || tabs.length !== leavesRef.current.length) return false dragRef.current = { leaf, sourceIndex, destinationIndex: sourceIndex, orderAtStart: [...leavesRef.current], startX, startScrollLeft: scroller.scrollLeft, maxScrollLeft: Math.max(0, scroller.scrollWidth - scroller.clientWidth), tabRects: Array.from(tabs, tab => { const { left, width } = tab.getBoundingClientRect() return { left, width } }), } setPreview({ ...dragRef.current }) return true }, [scrollerRef])
const suppressClick = useCallback((leaf: WorkspaceLeaf) => { suppressedClickRef.current = { id: leaf.id, until: Date.now() + 700 } }, [])
const shouldSuppressClick = useCallback((leaf: WorkspaceLeaf) => { const suppressed = suppressedClickRef.current if (suppressed?.id !== leaf.id || suppressed.until < Date.now()) return false suppressedClickRef.current = null return true }, [])
const commit = useCallback(() => { const drag = dragRef.current clearPreview() if (!drag) return // A tab might have been closed or opened during a drag. Never apply a // stale index to a different set of leaves. const current = leavesRef.current if (current.length !== drag.orderAtStart.length || current.some((leaf, index) => leaf !== drag.orderAtStart[index])) return if (drag.sourceIndex !== drag.destinationIndex) { workspace.moveRootLeaf(drag.leaf, drag.destinationIndex) setAnnouncement(`Moved ${drag.leaf.getDisplayText()} to position ${drag.destinationIndex + 1}`) } }, [clearPreview, workspace])
const updateDestination = useCallback((clientX: number) => { const drag = dragRef.current const scroller = scrollerRef.current if (!drag || !scroller) return lastClientXRef.current = clientX
const tabs = scroller.querySelectorAll<HTMLElement>('.tab') if (tabs.length !== drag.tabRects.length) return const scrollDelta = scroller.scrollLeft - drag.startScrollLeft let insertion = 0 for (const rect of drag.tabRects) { if (clientX > rect.left - scrollDelta + rect.width / 2) insertion++ } const destinationIndex = Math.min(tabs.length - 1, insertion > drag.sourceIndex ? insertion - 1 : insertion) const sourceWidth = drag.tabRects[drag.sourceIndex].width tabs.forEach((tab, index) => { const displacement = index === drag.sourceIndex ? clientX - drag.startX + scrollDelta : index > drag.sourceIndex && index <= destinationIndex ? -sourceWidth : index < drag.sourceIndex && index >= destinationIndex ? sourceWidth : 0 const transform = displacement ? `translateX(${displacement}px)` : '' if (tab.style.transform !== transform) tab.style.transform = transform if (index !== drag.sourceIndex && displacement) tab.dataset.shifting = 'true' else delete tab.dataset.shifting }) if (drag.destinationIndex !== destinationIndex) { drag.destinationIndex = destinationIndex setPreview({ ...drag }) } }, [scrollerRef])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, leaf: WorkspaceLeaf) => { if (event.pointerType === 'touch' || event.button !== 0 || (event.target as Element).closest('.tab-close')) return mouseCleanupRef.current?.() const startX = event.clientX const startY = event.clientY const pointerId = event.pointerId let started = false
const move = (e: PointerEvent) => { if (e.pointerId !== pointerId) return if (!started) { if (Math.hypot(e.clientX - startX, e.clientY - startY) < MOUSE_DRAG_DISTANCE) return if (!startDrag(leaf, startX)) return started = true } updateDestination(e.clientX) } const finish = (e: PointerEvent) => { if (e.pointerId !== pointerId) return mouseCleanupRef.current?.() if (started) { suppressClick(leaf) if (e.type === 'pointerup') commit() else clearPreview() } } window.addEventListener('pointermove', move) window.addEventListener('pointerup', finish) window.addEventListener('pointercancel', finish) mouseCleanupRef.current = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', finish) window.removeEventListener('pointercancel', finish) mouseCleanupRef.current = null } }, [clearPreview, commit, startDrag, suppressClick, updateDestination])
// Native, non-passive touchmove is necessary: React's delegated touchmove // listener is passive in Chromium, so it cannot stop scrolling *after* the // hold has claimed the gesture. Before the hold, native strip panning wins. useEffect(() => { const scroller = scrollerRef.current if (!scroller) return let touch: { id: number; leaf: WorkspaceLeaf; x: number; y: number; timer: number | null } | null = null const cancel = () => { if (touch?.timer != null) window.clearTimeout(touch.timer) touch = null clearPreview() } const start = (e: TouchEvent) => { cancel() if (e.touches.length !== 1) return const target = e.target instanceof Element ? e.target : (e.target as Node).parentElement if (!target || target.closest('.tab-close')) return const tab = target.closest<HTMLElement>('.tab') if (!tab || !scroller.contains(tab)) return const leaf = leavesRef.current.find(item => item.id === tab.dataset.leafId) if (!leaf) return const point = e.touches[0] touch = { id: point.identifier, leaf, x: point.clientX, y: point.clientY, timer: null } touch.timer = window.setTimeout(() => { if (!touch || touch.leaf !== leaf) return touch.timer = null startDrag(leaf, touch.x) }, TOUCH_HOLD_MS) } const move = (e: TouchEvent) => { if (!touch) return const point = Array.from(e.touches).find(item => item.identifier === touch!.id) if (!point || e.touches.length !== 1) { cancel(); return } if (!dragRef.current) { if (Math.hypot(point.clientX - touch.x, point.clientY - touch.y) > TOUCH_SCROLL_DISTANCE) cancel() return } e.preventDefault() updateDestination(point.clientX) } const finish = (e: TouchEvent) => { if (!touch) return if (!Array.from(e.changedTouches).some(item => item.identifier === touch!.id)) return if (dragRef.current) { suppressClick(touch.leaf) if (e.type === 'touchend') commit() } cancel() } scroller.addEventListener('touchstart', start, { passive: true }) scroller.addEventListener('touchmove', move, { passive: false }) scroller.addEventListener('touchend', finish) scroller.addEventListener('touchcancel', finish) return () => { cancel() scroller.removeEventListener('touchstart', start) scroller.removeEventListener('touchmove', move) scroller.removeEventListener('touchend', finish) scroller.removeEventListener('touchcancel', finish) } }, [clearPreview, commit, scrollerRef, startDrag, suppressClick, updateDestination])
// Follow the pointer at frame rate. A fixed 16px/30ms step made crossing a // 30-tab strip take several seconds, even with the pointer at the very edge. // Scale the velocity by proximity and keep scrolling while held still. useEffect(() => { if (!isDragging) return let frame = 0 let previousTime: number | null = null const tick = (time: number) => { const scroller = scrollerRef.current const clientX = lastClientXRef.current const drag = dragRef.current if (scroller && drag && clientX !== null) { const bounds = scroller.getBoundingClientRect() const velocity = edgeScrollVelocity(clientX, bounds.left, bounds.right) const previousScroll = scroller.scrollLeft const step = previousTime === null ? 0 : velocity * Math.min(time - previousTime, 48) / 1000 // Transformed drag previews extend the browser's scrollWidth. Clamp // against the *original* content width so release never snaps back // from an artificial scroll position past the actual last tab. scroller.scrollLeft = Math.max(0, Math.min(drag.maxScrollLeft, previousScroll + step)) if (scroller.scrollLeft !== previousScroll) updateDestination(clientX) } previousTime = time frame = window.requestAnimationFrame(tick) } frame = window.requestAnimationFrame(tick) return () => window.cancelAnimationFrame(frame) }, [isDragging, updateDestination])
useEffect(() => () => { mouseCleanupRef.current?.() }, [])
const moveByKeyboard = useCallback((leaf: WorkspaceLeaf, direction: -1 | 1) => { const index = leavesRef.current.indexOf(leaf) const destination = index + direction if (index < 0 || destination < 0 || destination >= leavesRef.current.length) return workspace.moveRootLeaf(leaf, destination) setAnnouncement(`Moved ${leaf.getDisplayText()} to position ${destination + 1}`) }, [workspace])
return { preview, announcement, onPointerDown, shouldSuppressClick, moveByKeyboard }}