import { useEffect, useRef } from 'react' import { Editor } from 'tldraw' export function useSPen(editor: Editor) { const previousToolRef = useRef('draw') // State tracking const isPenDownRef = useRef(false) const isInButtonModeRef = useRef(false) const lastEventRef = useRef(null) // DOUBLE TAP TRACKER const lastTapTimeRef = useRef(0) useEffect(() => { if (!editor) return // 1. Independent Pointer State Tracker & Double Tap Detector const trackPointer = (e: PointerEvent) => { // Only care about the pen if (e.pointerType !== 'pen' || !e.isTrusted) return lastEventRef.current = e if (e.type === 'pointerdown') { isPenDownRef.current = true // --- GESTURE: HOLD BUTTON + DOUBLE TAP TO UNDO --- if (isInButtonModeRef.current) { const now = Date.now() const timeSinceLastTap = now - lastTapTimeRef.current // Check if double tap (within 300ms) if (timeSinceLastTap < 300) { console.log("↩️ S-Pen Gesture: Undo") // 1. Stop this event so we don't draw a second dot e.stopPropagation() e.preventDefault() // 2. Perform Undo // We undo twice: once to remove the 'dot' from the first tap, // and once to undo the actual action you wanted to undo. // (If the first tap didn't erase anything, tldraw ignores the extra undo safely) editor.undo() setTimeout(() => editor.undo(), 50) return // Stop processing } lastTapTimeRef.current = now } // ------------------------------------------------ } if (e.type === 'pointerup' || e.type === 'pointercancel') { isPenDownRef.current = false } } // 2. The "Hot Swap" Function (Draw <-> Eraser) const performHotSwap = (newTool: string) => { const lastEvent = lastEventRef.current // If pen isn't touching screen, just switch tool if (!isPenDownRef.current || !lastEvent) { editor.setCurrentTool(newTool) return } // If pen IS touching, cut the line and switch const target = document.elementFromPoint(lastEvent.clientX, lastEvent.clientY) || window // A. End current stroke target.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, view: window, clientX: lastEvent.clientX, clientY: lastEvent.clientY, pointerId: lastEvent.pointerId, pointerType: 'pen', isPrimary: true, buttons: 0, pressure: 0 })) // B. Switch tool editor.setCurrentTool(newTool) // C. Start new stroke (delayed slightly for state machine) setTimeout(() => { target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, view: window, clientX: lastEvent.clientX, clientY: lastEvent.clientY, pointerId: lastEvent.pointerId, pointerType: 'pen', isPrimary: true, buttons: 1, pressure: lastEvent.pressure || 0.5, tiltX: lastEvent.tiltX, tiltY: lastEvent.tiltY })) }, 0) } const onButtonDown = () => { isInButtonModeRef.current = true // Mark button as active const currentTool = editor.getCurrentToolId() if (currentTool !== 'eraser') { previousToolRef.current = currentTool performHotSwap('eraser') } } const onButtonUp = () => { isInButtonModeRef.current = false // Mark button as inactive performHotSwap(previousToolRef.current) } // Listeners window.addEventListener('spen-button-down', onButtonDown) window.addEventListener('spen-button-up', onButtonUp) // Capture phase (true) is critical to intercept the double tap before tldraw window.addEventListener('pointerdown', trackPointer, { capture: true }) window.addEventListener('pointerup', trackPointer, { capture: true }) window.addEventListener('pointercancel', trackPointer, { capture: true }) window.addEventListener('pointermove', trackPointer, { capture: true }) return () => { window.removeEventListener('spen-button-down', onButtonDown) window.removeEventListener('spen-button-up', onButtonUp) window.removeEventListener('pointerdown', trackPointer, { capture: true }) window.removeEventListener('pointerup', trackPointer, { capture: true }) window.removeEventListener('pointercancel', trackPointer, { capture: true }) window.removeEventListener('pointermove', trackPointer, { capture: true }) } }, [editor]) }