Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/app.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
184 changes: 170 additions & 14 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -56,16 +58,170 @@ interface MatrixControlsProps {
}

function MatrixControls({ matrices, matrix, dispatch }: MatrixControlsProps) {
const [dragging, setDragging] = useState<HTMLElement | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isAnimating, setIsAnimating] = useState(false)
const [insertionIndex, setInsertionIndex] = useState<number | null>(null)
const [_previewFromIndex, setPreviewFromIndex] = useState<number | null>(null)
const animationFrameRef = useRef<number | null>(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<string, DOMRect>()
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<string, { left: number; top: number }>()

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<typeof spring<{ x: number; y: number }>>
>()

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 (
<div className={styles.controls}>
<div className={styles.section}>
<div ref={containerRef} className={styles.section}>
{matrices.map(({ id, visible, value }, i) => (
<Matrix
key={id}
index={i}
matrixId={id}
matrix={value}
visible={visible}
isAnimating={isAnimating}
toggleMatrix={() => {
dispatch({ type: 'update', index: i, visible: !visible })
}}
Expand All @@ -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 && (
<div
className={styles.insertionIndicator}
style={{
left: `${insertionIndex * (240 + 8)}px`,
}}
/>
)}
<button
className={styles.button}
onClick={() => dispatch({ type: 'insert', value: mat.IDENTITY })}
Expand Down
11 changes: 11 additions & 0 deletions src/components/matrix.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
gap: 8px;
display: flex;
flex-direction: column;
cursor: grab;
transition: opacity 0.2s ease;
}

.root.animating {
pointer-events: none;
}

.root.dragging {
opacity: 0.5;
cursor: grabbing;
}

.values {
Expand Down
Loading