Skip to content

Add touch handlers #106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
47 changes: 46 additions & 1 deletion src/app/profile/[user]/post/[post]/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ const TreeVisualizationInner = memo(function TreeVisualization({
onSetSelected: (node: LayoutNode) => void
onExpandNode: (node: LayoutNode) => void
}) {
const touchTimeout = useRef<NodeJS.Timeout | null>(null)
const lastTouchTime = useRef<number>(0)

const handleTouchStart = useCallback(
(node: LayoutNode, event: React.TouchEvent) => {
event.preventDefault()
const now = Date.now()
const timeSinceLastTouch = now - lastTouchTime.current

if (timeSinceLastTouch < 300) {
// Double tap detected
if (touchTimeout.current) {
clearTimeout(touchTimeout.current)
touchTimeout.current = null
}
onExpandNode(node)
} else {
// Single tap - wait to see if it's a double tap
touchTimeout.current = setTimeout(() => {
onSetSelected(node)
touchTimeout.current = null
}, 300)
}

lastTouchTime.current = now
},
[onSetSelected, onExpandNode],
)

useEffect(() => {
return () => {
if (touchTimeout.current) {
clearTimeout(touchTimeout.current)
}
}
}, [])

return (
<>
<g>
Expand Down Expand Up @@ -50,6 +87,7 @@ const TreeVisualizationInner = memo(function TreeVisualization({
className="transition-transform duration-300 ease-out"
onMouseOver={() => onSetSelected(node)}
onDoubleClick={() => onExpandNode(node)}
onTouchStart={(e) => handleTouchStart(node, e)}
>
<rect x={-DIM / 2} y={-DIM / 2} width={DIM} height={DIM} fill="white" />
<image
Expand Down Expand Up @@ -136,7 +174,14 @@ export default function TreeVisualization({
)

return (
<svg className="w-full h-full select-none" onMouseMove={zoomState.handleMouseMove} ref={svgRef}>
<svg
className="w-full h-full select-none"
onMouseMove={zoomState.handleMouseMove}
onTouchStart={zoomState.handleTouchStart}
onTouchMove={zoomState.handleTouchMove}
onTouchEnd={zoomState.handleTouchEnd}
ref={svgRef}
>
{zoomState.transform && (
<g transform={zoomState.transform}>
<TreeVisualizationInner
Expand Down
92 changes: 92 additions & 0 deletions src/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,24 @@ interface ZoomState {
target: { width: number; height: number }
}

interface TouchState {
touchCount: number
lastTouchDistance: number | null
lastTouchCenter: { x: number; y: number } | null
}

export function useZoomState() {
const innerState = useRef<ZoomState>({
position: null,
target: { width: 0, height: 0 },
})
const animationFrame = useRef<number | null>(null)
const [transform, setTransform] = useState('')
const touchState = useRef<TouchState>({
touchCount: 0,
lastTouchDistance: null,
lastTouchCenter: null,
})

useEffect(() => {
return () => {
Expand Down Expand Up @@ -123,10 +134,91 @@ export function useZoomState() {
[updateTransform],
)

const handleTouchStart = useCallback((event: React.TouchEvent<SVGSVGElement>) => {
event.preventDefault()
touchState.current.touchCount = event.touches.length

if (event.touches.length === 2) {
const touch1 = event.touches[0]
const touch2 = event.touches[1]
touchState.current.lastTouchDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY,
)
touchState.current.lastTouchCenter = {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2,
}
}
}, [])

const handleTouchMove = useCallback(
(event: React.TouchEvent<SVGSVGElement>) => {
event.preventDefault()
if (!innerState.current.position) return

if (event.touches.length === 1) {
// Single touch - pan
const touch = event.touches[0]
const prevTouch = event.changedTouches[0]
const movementX = touch.clientX - prevTouch.clientX
const movementY = touch.clientY - prevTouch.clientY

innerState.current.position.offset.x += movementX
innerState.current.position.offset.y += movementY
} else if (event.touches.length === 2) {
// Pinch to zoom
const touch1 = event.touches[0]
const touch2 = event.touches[1]
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY,
)
const currentCenter = {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2,
}

if (touchState.current.lastTouchDistance && touchState.current.lastTouchCenter) {
const scale = currentDistance / touchState.current.lastTouchDistance
const oldScale = innerState.current.position.scale
innerState.current.position.scale *= scale

// Adjust offset to zoom around the center point of the pinch
const scaleDelta = innerState.current.position.scale / oldScale
innerState.current.position.offset.x -=
(currentCenter.x - innerState.current.position.offset.x) * (scaleDelta - 1)
innerState.current.position.offset.y -=
(currentCenter.y - innerState.current.position.offset.y) * (scaleDelta - 1)
}

touchState.current.lastTouchDistance = currentDistance
touchState.current.lastTouchCenter = currentCenter
}

if (!animationFrame.current) {
animationFrame.current = requestAnimationFrame(updateTransform)
}
},
[updateTransform],
)

const handleTouchEnd = useCallback((event: React.TouchEvent<SVGSVGElement>) => {
event.preventDefault()
touchState.current.touchCount = event.touches.length
if (event.touches.length < 2) {
touchState.current.lastTouchDistance = null
touchState.current.lastTouchCenter = null
}
}, [])

return {
transform,
handleWheel,
handleMouseMove,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
setBounds,
setTarget,
}
Expand Down