import { useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; // --------------------------------------------------------------------------- // Ruler configuration // --------------------------------------------------------------------------- const RULER_SIZE = 20; // px const TICK_COLOR = "var(--color-muted-foreground)"; const BG_COLOR = "var(--color-card)"; const TEXT_COLOR = "var(--color-muted-foreground)"; // --------------------------------------------------------------------------- // Helper: pick tick spacing based on zoom level // --------------------------------------------------------------------------- function getTickInterval(zoom: number): { major: number; minor: number } { // Adjust tick spacing so labels don't overlap at any zoom if (zoom >= 4) return { major: 25, minor: 5 }; if (zoom >= 2) return { major: 50, minor: 10 }; if (zoom >= 1) return { major: 100, minor: 10 }; if (zoom >= 0.5) return { major: 200, minor: 50 }; if (zoom >= 0.25) return { major: 500, minor: 100 }; return { major: 1000, minor: 200 }; } // --------------------------------------------------------------------------- // HorizontalRuler // --------------------------------------------------------------------------- export function HorizontalRuler() { const canvasRef = useRef(null); const zoom = useEditorStore((s) => s.zoom); const panOffset = useEditorStore((s) => s.panOffset); const canvasSize = useEditorStore((s) => s.canvasSize); const showRulers = useEditorStore((s) => s.rulersVisible); const addGuide = useEditorStore((s) => s.addGuide); const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = RULER_SIZE; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); // Background ctx.fillStyle = BG_COLOR; ctx.fillRect(0, 0, w, h); const { major, minor } = getTickInterval(zoom); const startPx = -panOffset.x / zoom; const endPx = (w - panOffset.x) / zoom; const startTick = Math.floor(startPx / minor) * minor; ctx.strokeStyle = TICK_COLOR; ctx.fillStyle = TEXT_COLOR; ctx.font = "9px sans-serif"; ctx.textBaseline = "top"; for (let t = startTick; t <= endPx + minor; t += minor) { const screenX = t * zoom + panOffset.x; if (screenX < 0 || screenX > w) continue; const isMajor = t % major === 0; const tickHeight = isMajor ? 12 : 6; ctx.beginPath(); ctx.moveTo(screenX, h); ctx.lineTo(screenX, h - tickHeight); ctx.lineWidth = isMajor ? 0.8 : 0.4; ctx.stroke(); if (isMajor) { ctx.fillText(String(Math.round(t)), screenX + 2, 2); } } // Bottom border ctx.strokeStyle = TICK_COLOR; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(0, h - 0.5); ctx.lineTo(w, h - 0.5); ctx.stroke(); // canvasSize referenced to trigger redraw when canvas dimensions change void canvasSize; }, [zoom, panOffset, canvasSize]); useEffect(() => { draw(); }, [draw]); useEffect(() => { const handleResize = () => draw(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [draw]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { // Drag from ruler to create a horizontal guide const startY = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientY - startY) > 10) { const canvasY = (me.clientY - startY) / zoom; addGuide("horizontal", canvasY); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); } }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [zoom, addGuide], ); if (!showRulers) return null; return ( ); } // --------------------------------------------------------------------------- // VerticalRuler // --------------------------------------------------------------------------- export function VerticalRuler() { const canvasRef = useRef(null); const zoom = useEditorStore((s) => s.zoom); const panOffset = useEditorStore((s) => s.panOffset); const canvasSize = useEditorStore((s) => s.canvasSize); const showRulers = useEditorStore((s) => s.rulersVisible); const addGuide = useEditorStore((s) => s.addGuide); const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const w = RULER_SIZE; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = BG_COLOR; ctx.fillRect(0, 0, w, h); const { major, minor } = getTickInterval(zoom); const startPx = -panOffset.y / zoom; const endPx = (h - panOffset.y) / zoom; const startTick = Math.floor(startPx / minor) * minor; ctx.strokeStyle = TICK_COLOR; ctx.fillStyle = TEXT_COLOR; ctx.font = "9px sans-serif"; for (let t = startTick; t <= endPx + minor; t += minor) { const screenY = t * zoom + panOffset.y; if (screenY < 0 || screenY > h) continue; const isMajor = t % major === 0; const tickWidth = isMajor ? 12 : 6; ctx.beginPath(); ctx.moveTo(w, screenY); ctx.lineTo(w - tickWidth, screenY); ctx.lineWidth = isMajor ? 0.8 : 0.4; ctx.stroke(); if (isMajor) { ctx.save(); ctx.translate(3, screenY + 2); ctx.rotate(-Math.PI / 2); ctx.textBaseline = "top"; ctx.fillText(String(Math.round(t)), 0, 0); ctx.restore(); } } // Right border ctx.strokeStyle = TICK_COLOR; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(w - 0.5, 0); ctx.lineTo(w - 0.5, h); ctx.stroke(); void canvasSize; }, [zoom, panOffset, canvasSize]); useEffect(() => { draw(); }, [draw]); useEffect(() => { const handleResize = () => draw(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [draw]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { const startX = e.clientX; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - startX) > 10) { const canvasX = (me.clientX - startX) / zoom; addGuide("vertical", canvasX); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); } }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [zoom, addGuide], ); if (!showRulers) return null; return ( ); } export { RULER_SIZE };