import { useState, useCallback, useRef, useMemo, useEffect } from 'react' import { track, useEditor, TldrawUiButton, TldrawUiButtonIcon, createShapeId, TLShapeId } from 'tldraw' import { math2svg, math2svgFull } from '../utils/math2svg' import { EnhancedColorPicker } from './EnhancedColorPicker' // Default Initial State const INITIAL_STATE = { type: 'function' as 'function' | 'parametric', expression: 'sin(x)', xExpression: 'cos(t)', yExpression: 'sin(t)', xMin: -10, xMax: 10, tMin: 0, tMax: 6.28, scaleX: 40, scaleY: 40, offsetX: 250, offsetY: 250, showAxes: true, showGrid: false, showNumbers: true, axisColor: '#666666', gridColor: '#e5e5e5', strokeColor: '#2563eb', fontSize: 8, fontFamily: 'monospace' as 'monospace' | 'sans-serif' | 'serif' } export const EquationRenderer = track(() => { const editor = useEditor() const [isOpen, setIsOpen] = useState(false) const [editingShapeId, setEditingShapeId] = useState(null) const isDark = editor.user.getIsDarkMode() // Local state for creation mode const [localState, setLocalState] = useState(INITIAL_STATE) // Reset state when closing const handleClose = useCallback(() => { setIsOpen(false) setEditingShapeId(null) setLocalState(INITIAL_STATE) }, []) // Listen for external open-equation-editor event (from context menu) useEffect(() => { const handleOpenEditor = (event: CustomEvent) => { const { shapeId } = event.detail const shape = editor.getShape(shapeId) if (shape && shape.type === 'equation') { setEditingShapeId(shapeId) setLocalState({ type: (shape.props as any).type || 'function', expression: (shape.props as any).expression || 'sin(x)', xExpression: (shape.props as any).xExpression || 'cos(t)', yExpression: (shape.props as any).yExpression || 'sin(t)', xMin: (shape.props as any).xMin ?? -10, xMax: (shape.props as any).xMax ?? 10, tMin: (shape.props as any).tMin ?? 0, tMax: (shape.props as any).tMax ?? 6.28, scaleX: (shape.props as any).scaleX ?? 40, scaleY: (shape.props as any).scaleY ?? 40, offsetX: (shape.props as any).offsetX ?? 250, offsetY: (shape.props as any).offsetY ?? 250, showAxes: (shape.props as any).showAxes ?? true, showGrid: (shape.props as any).showGrid ?? false, showNumbers: (shape.props as any).showNumbers ?? true, axisColor: (shape.props as any).axisColor || '#666666', gridColor: (shape.props as any).gridColor || '#e5e5e5', strokeColor: (shape.props as any).strokeColor || '#2563eb', fontSize: (shape.props as any).fontSize ?? 8, fontFamily: (shape.props as any).fontFamily || 'monospace' }) setIsOpen(true) } } window.addEventListener('open-equation-editor' as any, handleOpenEditor) return () => window.removeEventListener('open-equation-editor' as any, handleOpenEditor) }, [editor]) // Selection Logic - only open via explicit trigger, not auto-open on selection // Note: We don't auto-open on selection anymore // Derived State (Current Values) const values = localState // Ensure defaults for missing props (compatibility) const state = { ...INITIAL_STATE, ...values } // Update Handler const updateState = useCallback((changes: Partial) => { setLocalState(prev => ({ ...prev, ...changes })) // If editing an existing shape, update it in real-time if (editingShapeId) { editor.updateShape({ id: editingShapeId, type: 'equation', props: changes }) } }, [editor, editingShapeId]) // SVG Generation for Preview const svgPath = useMemo(() => { return math2svg({ type: state.type, expression: state.expression, xExpression: state.xExpression, yExpression: state.yExpression, xMin: state.xMin, xMax: state.xMax, tMin: state.tMin, tMax: state.tMax, scaleX: state.scaleX, scaleY: state.scaleY, offsetX: state.offsetX, offsetY: state.offsetY, samples: 400 }) }, [state]) // Interaction State for Preview Pan/Zoom const isDragging = useRef(false) const lastPos = useRef({ x: 0, y: 0 }) const initialDistance = useRef(0) const isPinching = useRef(false) const velocity = useRef({ x: 0, y: 0 }) const lastMoveTime = useRef(0) const animationFrame = useRef(null) const handleWheel = (e: React.WheelEvent) => { e.stopPropagation() e.preventDefault() const s = e.deltaY > 0 ? 0.9 : 1.1 updateState({ scaleX: Math.max(1, state.scaleX * s), scaleY: Math.max(1, state.scaleY * s) }) } // Use touch events for better Android support const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { // Two-finger pinch isPinching.current = true isDragging.current = false const touch1 = e.touches[0] const touch2 = e.touches[1] const distance = Math.hypot( touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ) initialDistance.current = distance } else if (e.touches.length === 1) { // Single finger drag isDragging.current = true isPinching.current = false const touch = e.touches[0] lastPos.current = { x: touch.clientX, y: touch.clientY } } } const handleTouchMove = (e: React.TouchEvent) => { if (e.touches.length === 2 && isPinching.current) { // Pinch zoom e.preventDefault() const touch1 = e.touches[0] const touch2 = e.touches[1] const distance = Math.hypot( touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ) if (initialDistance.current > 0) { const scale = distance / initialDistance.current updateState({ scaleX: Math.max(1, state.scaleX * scale), scaleY: Math.max(1, state.scaleY * scale) }) initialDistance.current = distance } } else if (e.touches.length === 1 && isDragging.current && !isPinching.current) { // Single finger pan with velocity tracking e.preventDefault() const touch = e.touches[0] const now = Date.now() const dt = now - lastMoveTime.current const dx = touch.clientX - lastPos.current.x const dy = touch.clientY - lastPos.current.y // Calculate velocity for momentum if (dt > 0) { velocity.current = { x: dx / dt * 16, // Normalize to ~60fps y: dy / dt * 16 } } lastPos.current = { x: touch.clientX, y: touch.clientY } lastMoveTime.current = now updateState({ offsetX: state.offsetX + dx, offsetY: state.offsetY + dy }) } } const handleTouchEnd = (e: React.TouchEvent) => { if (e.touches.length < 2) { isPinching.current = false initialDistance.current = 0 } if (e.touches.length === 0) { isDragging.current = false // Apply momentum/inertia const speed = Math.hypot(velocity.current.x, velocity.current.y) if (speed > 1) { applyMomentum() } } } const applyMomentum = () => { const friction = 0.95 // Deceleration factor const minSpeed = 0.5 const animate = () => { const speed = Math.hypot(velocity.current.x, velocity.current.y) if (speed < minSpeed || isDragging.current) { velocity.current = { x: 0, y: 0 } animationFrame.current = null return } // Apply velocity updateState({ offsetX: state.offsetX + velocity.current.x, offsetY: state.offsetY + velocity.current.y }) // Apply friction velocity.current = { x: velocity.current.x * friction, y: velocity.current.y * friction } animationFrame.current = requestAnimationFrame(animate) } if (animationFrame.current) { cancelAnimationFrame(animationFrame.current) } animationFrame.current = requestAnimationFrame(animate) } // Mouse events for desktop const handleMouseDown = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() // Cancel any ongoing momentum if (animationFrame.current) { cancelAnimationFrame(animationFrame.current) animationFrame.current = null } velocity.current = { x: 0, y: 0 } isDragging.current = true lastPos.current = { x: e.clientX, y: e.clientY } lastMoveTime.current = Date.now() } const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging.current) return e.stopPropagation() e.preventDefault() const now = Date.now() const dt = now - lastMoveTime.current const dx = e.clientX - lastPos.current.x const dy = e.clientY - lastPos.current.y // Calculate velocity for momentum if (dt > 0) { velocity.current = { x: dx / dt * 16, y: dy / dt * 16 } } lastPos.current = { x: e.clientX, y: e.clientY } lastMoveTime.current = now updateState({ offsetX: state.offsetX + dx, offsetY: state.offsetY + dy }) } const handleMouseUp = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() isDragging.current = false // Apply momentum/inertia for mouse too const speed = Math.hypot(velocity.current.x, velocity.current.y) if (speed > 1) { applyMomentum() } } // Cleanup animation frame on unmount useEffect(() => { return () => { if (animationFrame.current) { cancelAnimationFrame(animationFrame.current) } } }, []) const copy = async () => { const svgString = math2svgFull({ ...state, samples: 400 }) try { await navigator.clipboard.writeText(svgString) editor.setCurrentTool('select') } catch (err) {} } const addToCanvas = () => { // Create Equation Shape const id = createShapeId() const center = editor.getViewportPageBounds().center editor.createShape({ id, type: 'equation', x: center.x - 250, // Center the 500x500 shape y: center.y - 250, props: { w: 500, h: 500, ...state } }) // Don't close, maybe user wants to add more? Or close. // setIsOpen(false) } if (!isOpen) { return (
setIsOpen(true)} title="Function Plotter" style={{ width: '40px', height: '40px', background: 'var(--color-panel)', border: '1px solid var(--color-divider)', borderRadius: 'var(--radius-medium)', boxShadow: 'var(--shadow-1)', }} > f(x)
) } // Styles const previewBg = isDark ? '#212121' : '#ffffff' return (
e.stopPropagation()} style={{ position: 'fixed', top: 'max(60px, env(safe-area-inset-top, 12px))', left: 'max(60px, env(safe-area-inset-left, 12px))', right: 'max(12px, env(safe-area-inset-right, 12px))', width: '320px', maxWidth: 'calc(100vw - max(72px, env(safe-area-inset-left, 12px) + env(safe-area-inset-right, 12px) + 24px))', maxHeight: 'calc(100dvh - max(80px, env(safe-area-inset-top, 12px) + env(safe-area-inset-bottom, 12px) + 24px))', background: 'var(--color-panel)', border: '1px solid var(--color-divider)', borderRadius: 'var(--radius-medium)', boxShadow: 'var(--shadow-3)', zIndex: 400, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(20px)', overflow: 'hidden' }} > {/* Header */}
{editingShapeId ? 'Edit Formula' : 'Function Plotter'}
{/* Content */}
{/* Type Selector */}
{/* Inputs */} {state.type === 'function' ? ( <>
updateState({ expression: v })} placeholder="sin(x)" />
updateState({ xMin: Number(v) })} type="number" />
updateState({ xMax: Number(v) })} type="number" />
) : ( <>
updateState({ xExpression: v })} placeholder="cos(t)" />
updateState({ yExpression: v })} placeholder="sin(t)" />
updateState({ tMin: Number(v) })} type="number" />
updateState({ tMax: Number(v) })} type="number" />
)} {/* Visual Settings */}
updateState({ scaleX: Number(v) })} type="number" />
updateState({ scaleY: Number(v) })} type="number" />
{/* Toggles */}
updateState({ showAxes: v, showGrid: v })} /> updateState({ showNumbers: v })} />
{/* Font Settings */} {state.showNumbers && (
updateState({ fontSize: Number(v) })} type="number" />
)} {/* Colors */}
updateState({ strokeColor: color })} /> updateState({ axisColor: color })} /> updateState({ gridColor: color })} />
{/* Preview */}
{state.showGrid && ( )} {state.showGrid && } {state.showAxes && ( <> {state.showNumbers && ( <> {/* X-axis numbers */} {(() => { const ticks = [] const xStep = state.scaleX if (xStep > 20) { for (let x = state.offsetX % xStep; x < 500; x += xStep) { const val = Math.round((x - state.offsetX) / state.scaleX) if (val === 0) continue ticks.push( {val} ) } } return ticks })()} {/* Y-axis numbers */} {(() => { const ticks = [] const yStep = state.scaleY if (yStep > 20) { for (let y = state.offsetY % yStep; y < 500; y += yStep) { const val = Math.round((state.offsetY - y) / state.scaleY) if (val === 0) continue ticks.push( {val} ) } } return ticks })()} 0 )} )}
Pinch to zoom • Drag to pan
Copy SVG {!editingShapeId && ( Add to Board )} {editingShapeId && ( Done )}
) }) const Label = ({ children }: { children: React.ReactNode }) => ( ) const Input = ({ value, onChange, placeholder, type = 'text' }: any) => ( onChange(e.target.value)} placeholder={placeholder} style={{ width: '100%', padding: '6px', fontSize: '12px', background: 'var(--color-low)', color: 'var(--color-text)', border: '1px solid var(--color-divider)', borderRadius: 'var(--radius-small)', outline: 'none', fontFamily: 'monospace' }} /> ) const Checkbox = ({ label, checked, onChange }: any) => (
onChange(!checked)} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '11px', fontWeight: 500, color: 'var(--color-text)' }} >
{checked &&
}
{label}
)