import { ShapeUtil, TLShape, HTMLContainer, Rectangle2d, T, useEditor } from 'tldraw' import { useState } from 'react' import { math2svgFull } from '../utils/math2svg' // Component for interactive equation shapes with hover controls function EquationShapeComponent({ shape, svgContent }: { shape: IEquationShape, svgContent: string }) { const editor = useEditor() const [showControls, setShowControls] = useState(false) // Calculate scale factor for buttons based on shape size (relative to default 500x500) const scaleFactor = Math.min(shape.props.w / 500, shape.props.h / 500) const buttonSize = Math.max(16, Math.min(32, 24 * scaleFactor)) // Clamp between 16-32px const fontSize = Math.max(10, Math.min(18, 14 * scaleFactor)) const padding = Math.max(2, Math.min(6, 4 * scaleFactor)) const gap = Math.max(2, Math.min(6, 4 * scaleFactor)) const handlePan = (dx: number, dy: number, e: React.MouseEvent) => { e.stopPropagation() const offsetX = (shape.props.offsetX ?? 250) + dx const offsetY = (shape.props.offsetY ?? 250) + dy editor.updateShape({ id: shape.id, type: 'equation', props: { offsetX, offsetY } }) } const handleZoom = (factor: number, e: React.MouseEvent) => { e.stopPropagation() const scaleX = Math.max(1, shape.props.scaleX * factor) const scaleY = Math.max(1, shape.props.scaleY * factor) editor.updateShape({ id: shape.id, type: 'equation', props: { scaleX, scaleY } }) } const toggleAspectRatio = (e: React.MouseEvent) => { e.stopPropagation() editor.updateShape({ id: shape.id, type: 'equation', props: { lockAspectRatio: !shape.props.lockAspectRatio } }) } const handleReset = (e: React.MouseEvent) => { e.stopPropagation() editor.updateShape({ id: shape.id, type: 'equation', props: { scaleX: 40, scaleY: 40, offsetX: 250, offsetY: 250 } }) } const getButtonStyle = (active = false): React.CSSProperties => ({ width: `${buttonSize}px`, height: `${buttonSize}px`, border: 'none', background: active ? 'var(--color-selected)' : 'var(--color-low)', color: active ? 'var(--color-selected-contrast)' : 'var(--color-text)', borderRadius: 'var(--radius-1)', cursor: 'pointer', fontSize: `${fontSize}px`, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, flexShrink: 0, pointerEvents: 'all' // Ensure button itself captures events }) return (
setShowControls(true)} onMouseLeave={() => setShowControls(false)} >
{showControls && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} >
)}
) } export type IEquationShape = TLShape & { type: 'equation' props: { w: number h: number // Equation type: 'function' | 'parametric' expression: string xExpression: string yExpression: string xMin: number xMax: number tMin: number tMax: number scaleX: number scaleY: number offsetX: number offsetY: number // View showAxes: boolean showGrid: boolean showNumbers: boolean // Style axisColor: string gridColor: string strokeColor: string // Behavior lockAspectRatio?: boolean // Font (optional for backwards compatibility) fontSize?: number fontFamily?: string } } export class EquationShapeUtil extends ShapeUtil { static override type = 'equation' as const static override props = { w: T.number, h: T.number, type: T.string, expression: T.string, xExpression: T.string, yExpression: T.string, xMin: T.number, xMax: T.number, tMin: T.number, tMax: T.number, scaleX: T.number, scaleY: T.number, offsetX: T.number.optional(), offsetY: T.number.optional(), showAxes: T.boolean, showGrid: T.boolean, showNumbers: T.boolean, axisColor: T.string, gridColor: T.string, strokeColor: T.string, lockAspectRatio: T.boolean.optional(), fontSize: T.number.optional(), fontFamily: T.string.optional(), } override canResize = (_shape: IEquationShape) => true override isAspectRatioLocked = (shape: IEquationShape) => shape.props.lockAspectRatio ?? false // Disable text editing on double-click override canEdit = () => false override onResize = (shape: IEquationShape, info: { newPoint: { x: number, y: number }, handle: string, scaleX: number, scaleY: number }) => { return { props: { w: Math.max(100, shape.props.w * info.scaleX), h: Math.max(100, shape.props.h * info.scaleY), } } } override getDefaultProps(): IEquationShape['props'] { return { w: 500, h: 500, type: 'function', 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', lockAspectRatio: false, fontSize: 8, fontFamily: 'monospace' } } override getGeometry(shape: IEquationShape) { // Just a rectangular bounds for now return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true, }) } override component(shape: IEquationShape) { const { w, h, axisColor, gridColor, strokeColor } = shape.props const offsetX = shape.props.offsetX ?? 250 const offsetY = shape.props.offsetY ?? 250 const fontSize = shape.props.fontSize ?? 8 const fontFamily = shape.props.fontFamily ?? 'monospace' // Generate SVG string with fixed 500x500 viewBox, scaled to shape dimensions const svgString = math2svgFull({ ...shape.props, offsetX, offsetY, // Pass explicitly to ensure colors match props axisColor, gridColor, strokeColor, fontSize, fontFamily, samples: 400 }) // Replace the viewBox in the SVG to maintain aspect ratio when resizing const modifiedSvg = svgString.replace( /width="500" height="500"/, `width="${w}" height="${h}" preserveAspectRatio="none"` ) return ( ) } override indicator(shape: IEquationShape) { return } }