diff --git a/package.json b/package.json index 4ae6ac7..b5ba48d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@bschlenk/mat": "^0.0.11", "@bschlenk/util": "^0.0.2", "@bschlenk/vec": "^0.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea5de1d..20a7a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@atlaskit/pragmatic-drag-and-drop': + specifier: ^1.7.7 + version: 1.7.7 '@bschlenk/mat': specifier: ^0.0.11 version: 0.0.11 @@ -58,6 +61,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@atlaskit/pragmatic-drag-and-drop@1.7.7': + resolution: {integrity: sha512-jX+68AoSTqO/fhCyJDTZ38Ey6/wyL2Iq+J/moanma0YyktpnoHxevjY1UNJHYp0NCburdQDZSL1ZFac1mO1osQ==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -125,6 +131,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -615,6 +625,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bind-event-listener@3.0.0: + resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1284,6 +1297,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1541,6 +1557,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@atlaskit/pragmatic-drag-and-drop@1.7.7': + dependencies: + '@babel/runtime': 7.28.4 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -1628,6 +1650,8 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 + '@babel/runtime@7.28.4': {} + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -2104,6 +2128,8 @@ snapshots: balanced-match@1.0.2: {} + bind-event-listener@3.0.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -2879,6 +2905,8 @@ snapshots: queue-microtask@1.2.3: {} + raf-schd@4.0.3: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/src/app.module.css b/src/app.module.css index 547a100..a482a91 100644 --- a/src/app.module.css +++ b/src/app.module.css @@ -17,12 +17,24 @@ } .section { + position: relative; display: flex; gap: 8px; align-items: center; pointer-events: auto; } +.insertionIndicator { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background: #3b82f6; + border-radius: 2px; + pointer-events: none; + z-index: 10; +} + .button { width: 44px; height: 44px; diff --git a/src/app.tsx b/src/app.tsx index c096338..82a74ed 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -8,11 +8,13 @@ import { UseMatricesDispatch, WrappedMatrix, } from './hooks/use-matrices' -import { childIndex } from './lib/dom' import { setRef } from './lib/set-ref' +import { createSpring } from './lib/spring' import styles from './app.module.css' +const spring = createSpring({ stiffness: 300, damping: 30 }) + export function App() { const values = useMatrices() @@ -56,16 +58,170 @@ interface MatrixControlsProps { } function MatrixControls({ matrices, matrix, dispatch }: MatrixControlsProps) { - const [dragging, setDragging] = useState(null) + const containerRef = useRef(null) + const [isAnimating, setIsAnimating] = useState(false) + const [insertionIndex, setInsertionIndex] = useState(null) + const [_previewFromIndex, setPreviewFromIndex] = useState(null) + const animationFrameRef = useRef(null) + + // Handle drag enter - update canvas preview immediately + const handleDragEnter = useCallback( + (fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex) return + setInsertionIndex(toIndex) + setPreviewFromIndex(fromIndex) + // Immediately dispatch for canvas preview + dispatch({ type: 'move', from: fromIndex, to: toIndex }) + }, + [dispatch], + ) + + const handleDragLeave = useCallback(() => { + setInsertionIndex(null) + }, []) + + const handleReorder = useCallback( + (fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex) { + setPreviewFromIndex(null) + setInsertionIndex(null) + return + } + + // If we were previewing, the state is already updated + // Just animate the UI without dispatching again + const container = containerRef.current + if (!container) return + + const matrixElements = Array.from( + container.querySelectorAll('[data-matrix-id]'), + ) + + // Capture current positions (already in final state from preview) + const currentPositions = new Map() + matrixElements.forEach((el) => { + const id = el.getAttribute('data-matrix-id') + if (id) { + currentPositions.set(id, el.getBoundingClientRect()) + } + }) + + // Calculate where elements should have been before the swap + const elementWidth = 240 + 8 // matrix width + gap + const startPositions = new Map() + + matrixElements.forEach((el) => { + const id = el.getAttribute('data-matrix-id') + if (!id) return + + const current = currentPositions.get(id) + if (!current) return + + // Determine the visual offset based on the swap + let offset = 0 + const currentIndex = Array.from(matrixElements).indexOf(el) + + if (currentIndex === toIndex) { + // This element moved TO the target position + offset = fromIndex < toIndex ? elementWidth : -elementWidth + } else if (fromIndex < toIndex && currentIndex > fromIndex && currentIndex <= toIndex) { + // Elements between from and to shift left + offset = -elementWidth + } else if (fromIndex > toIndex && currentIndex >= toIndex && currentIndex < fromIndex) { + // Elements between to and from shift right + offset = elementWidth + } + + startPositions.set(id, { + left: current.left + offset, + top: current.top, + }) + }) + + setIsAnimating(true) + setPreviewFromIndex(null) + setInsertionIndex(null) + + // Set up spring animations for each element + const springs = new Map< + Element, + ReturnType> + >() + + matrixElements.forEach((el) => { + const id = el.getAttribute('data-matrix-id') + if (!id) return + + const startPos = startPositions.get(id) + const currentPos = currentPositions.get(id) + + if (startPos && currentPos) { + const deltaX = startPos.left - currentPos.left + const deltaY = startPos.top - currentPos.top + + if (deltaX !== 0 || deltaY !== 0) { + const s = spring({ x: deltaX, y: deltaY }) + springs.set(el, s) + s.set({ x: 0, y: 0 }) + } + } + }) + + // Animate using spring physics + let lastTime = performance.now() + const animate = (currentTime: number) => { + const delta = currentTime - lastTime + lastTime = currentTime + + let anyActive = false + + springs.forEach((s, el) => { + const active = s.update(delta) + if (active) { + anyActive = true + const element = el as HTMLElement + element.style.transform = `translate(${s.value.x}px, ${s.value.y}px)` + } + }) + + if (anyActive) { + animationFrameRef.current = requestAnimationFrame(animate) + } else { + // Clean up after animation + springs.forEach((_, el) => { + const element = el as HTMLElement + element.style.transform = '' + }) + setIsAnimating(false) + animationFrameRef.current = null + } + } + + animationFrameRef.current = requestAnimationFrame(animate) + }, + [dispatch], + ) + + // Clean up animation frame on unmount + useEffect(() => { + return () => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, []) return (
-
+
{matrices.map(({ id, visible, value }, i) => ( { dispatch({ type: 'update', index: i, visible: !visible }) }} @@ -82,19 +238,19 @@ function MatrixControls({ matrices, matrix, dispatch }: MatrixControlsProps) { cloneMatrix={() => { dispatch({ type: 'insert', value, after: matrices[i] }) }} - onDragStart={(e) => { - setDragging(e.target as HTMLElement) - }} - onDragEnter={(e) => { - if (e.target !== e.currentTarget) return - - const from = childIndex(dragging!) - const to = childIndex(e.target as HTMLElement) - - dispatch({ type: 'move', from, to }) - }} + onReorder={handleReorder} + onDragEnter={(fromIndex) => handleDragEnter(fromIndex, i)} + onDragLeave={handleDragLeave} /> ))} + {insertionIndex !== null && ( +
+ )}