A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
'use client';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useEditorStore } from '@/lib/store/editor-store';
import { v4 as uuidv4 } from 'uuid';
export interface CustomGuide {
id: string;
type: 'horizontal' | 'vertical';
position: number; // Position in canvas coordinates
}
interface RulersProps {
containerWidth: number;
containerHeight: number;
customGuides: CustomGuide[];
onAddGuide: (guide: CustomGuide) => void;
onUpdateGuide: (id: string, position: number) => void;
onDeleteGuide: (id: string) => void;
}
const RULER_SIZE = 20;
const MAJOR_TICK_INTERVAL = 100;
const MINOR_TICK_INTERVAL = 10;
export function Rulers({
containerWidth,
containerHeight,
customGuides,
onAddGuide,
onUpdateGuide,
onDeleteGuide
}: RulersProps) {
const horizontalCanvasRef = useRef<HTMLCanvasElement>(null);
const verticalCanvasRef = useRef<HTMLCanvasElement>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [isDraggingGuide, setIsDraggingGuide] = useState<{
type: 'horizontal' | 'vertical';
id: string | null; // null means creating new guide
startPos: number;
} | null>(null);
const { canvas, showGuides } = useEditorStore();
// Don't render if guides are disabled
if (!showGuides) return null;
// Draw horizontal ruler
useEffect(() => {
const ctx = horizontalCanvasRef.current?.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const canvasEl = horizontalCanvasRef.current!;
canvasEl.width = containerWidth * dpr;
canvasEl.height = RULER_SIZE * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, containerWidth, RULER_SIZE);
// Draw ticks
ctx.fillStyle = '#666';
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
const startX = -canvas.pan.x / canvas.zoom;
const endX = startX + containerWidth / canvas.zoom;
// Round to nearest interval
const firstTick = Math.floor(startX / MINOR_TICK_INTERVAL) * MINOR_TICK_INTERVAL;
for (let x = firstTick; x <= endX; x += MINOR_TICK_INTERVAL) {
const screenX = (x * canvas.zoom) + canvas.pan.x;
if (screenX < 0 || screenX > containerWidth) continue;
const isMajor = x % MAJOR_TICK_INTERVAL === 0;
const tickHeight = isMajor ? 10 : 5;
ctx.fillStyle = isMajor ? '#888' : '#444';
ctx.fillRect(screenX, RULER_SIZE - tickHeight, 1, tickHeight);
if (isMajor && x >= 0) {
ctx.fillStyle = '#888';
ctx.fillText(String(x), screenX, 10);
}
}
// Draw mouse position indicator
if (mousePos) {
ctx.fillStyle = '#f0abfc';
ctx.fillRect(mousePos.x, 0, 1, RULER_SIZE);
}
// Border
ctx.strokeStyle = '#333';
ctx.strokeRect(0, 0, containerWidth, RULER_SIZE);
}, [containerWidth, canvas.zoom, canvas.pan.x, mousePos]);
// Draw vertical ruler
useEffect(() => {
const ctx = verticalCanvasRef.current?.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const canvasEl = verticalCanvasRef.current!;
canvasEl.width = RULER_SIZE * dpr;
canvasEl.height = containerHeight * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, RULER_SIZE, containerHeight);
// Draw ticks
ctx.fillStyle = '#666';
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const startY = -canvas.pan.y / canvas.zoom;
const endY = startY + containerHeight / canvas.zoom;
const firstTick = Math.floor(startY / MINOR_TICK_INTERVAL) * MINOR_TICK_INTERVAL;
for (let y = firstTick; y <= endY; y += MINOR_TICK_INTERVAL) {
const screenY = (y * canvas.zoom) + canvas.pan.y;
if (screenY < 0 || screenY > containerHeight) continue;
const isMajor = y % MAJOR_TICK_INTERVAL === 0;
const tickWidth = isMajor ? 10 : 5;
ctx.fillStyle = isMajor ? '#888' : '#444';
ctx.fillRect(RULER_SIZE - tickWidth, screenY, tickWidth, 1);
if (isMajor && y >= 0) {
ctx.save();
ctx.translate(10, screenY);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = '#888';
ctx.textAlign = 'center';
ctx.fillText(String(y), 0, 0);
ctx.restore();
}
}
// Draw mouse position indicator
if (mousePos) {
ctx.fillStyle = '#f0abfc';
ctx.fillRect(0, mousePos.y, RULER_SIZE, 1);
}
// Border
ctx.strokeStyle = '#333';
ctx.strokeRect(0, 0, RULER_SIZE, containerHeight);
}, [containerHeight, canvas.zoom, canvas.pan.y, mousePos]);
// Track mouse position
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setMousePos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseLeave = () => {
setMousePos(null);
};
// Convert screen position to canvas position
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
return {
x: (screenX - canvas.pan.x) / canvas.zoom,
y: (screenY - canvas.pan.y) / canvas.zoom,
};
}, [canvas.pan.x, canvas.pan.y, canvas.zoom]);
// Convert canvas position to screen position
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
return {
x: canvasX * canvas.zoom + canvas.pan.x,
y: canvasY * canvas.zoom + canvas.pan.y,
};
}, [canvas.pan.x, canvas.pan.y, canvas.zoom]);
// Handle ruler mouse down (start creating guide)
const handleHorizontalRulerMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const canvasPos = screenToCanvas(screenX, 0);
setIsDraggingGuide({
type: 'vertical',
id: null,
startPos: canvasPos.x,
});
};
const handleVerticalRulerMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const screenY = e.clientY - rect.top;
const canvasPos = screenToCanvas(0, screenY);
setIsDraggingGuide({
type: 'horizontal',
id: null,
startPos: canvasPos.y,
});
};
// Handle global mouse move/up for guide dragging
useEffect(() => {
if (!isDraggingGuide) return;
const handleGlobalMouseMove = (e: MouseEvent) => {
// Update guide position while dragging
// This is handled by the parent component via the dragging line
};
const handleGlobalMouseUp = (e: MouseEvent) => {
if (!isDraggingGuide) return;
// Get the final position
const container = document.querySelector('.canvas-container');
if (container) {
const rect = container.getBoundingClientRect();
const screenPos = isDraggingGuide.type === 'vertical'
? e.clientX - rect.left - RULER_SIZE
: e.clientY - rect.top - RULER_SIZE;
const canvasPos = isDraggingGuide.type === 'vertical'
? screenToCanvas(screenPos, 0).x
: screenToCanvas(0, screenPos).y;
// Only create guide if it's within canvas bounds
if (canvasPos >= 0 && canvasPos <= (isDraggingGuide.type === 'vertical' ? canvas.width : canvas.height)) {
const newGuide: CustomGuide = {
id: uuidv4(),
type: isDraggingGuide.type,
position: canvasPos,
};
onAddGuide(newGuide);
}
}
setIsDraggingGuide(null);
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [isDraggingGuide, screenToCanvas, canvas.width, canvas.height, onAddGuide]);
// Handle guide drag start (for existing guides)
const handleGuideDragStart = (guide: CustomGuide, e: React.MouseEvent) => {
e.stopPropagation();
setIsDraggingGuide({
type: guide.type,
id: guide.id,
startPos: guide.position,
});
};
// Handle guide double-click to delete
const handleGuideDoubleClick = (guide: CustomGuide, e: React.MouseEvent) => {
e.stopPropagation();
onDeleteGuide(guide.id);
};
return (
<>
{/* Corner square */}
<div
className="absolute top-0 left-0 bg-neutral-900 border-r border-b border-neutral-700 z-20"
style={{ width: RULER_SIZE, height: RULER_SIZE }}
/>
{/* Horizontal ruler (drag down to create vertical guide) */}
<div
className="absolute top-0 z-10 cursor-col-resize"
style={{
left: RULER_SIZE,
width: containerWidth - RULER_SIZE,
height: RULER_SIZE,
}}
onMouseDown={handleHorizontalRulerMouseDown}
>
<canvas
ref={horizontalCanvasRef}
className="pointer-events-none"
style={{
width: '100%',
height: '100%',
}}
/>
</div>
{/* Vertical ruler (drag right to create horizontal guide) */}
<div
className="absolute left-0 z-10 cursor-row-resize"
style={{
top: RULER_SIZE,
width: RULER_SIZE,
height: containerHeight - RULER_SIZE,
}}
onMouseDown={handleVerticalRulerMouseDown}
>
<canvas
ref={verticalCanvasRef}
className="pointer-events-none"
style={{
width: '100%',
height: '100%',
}}
/>
</div>
{/* Custom guides */}
{customGuides.map((guide) => {
const screenPos = guide.type === 'vertical'
? canvasToScreen(guide.position, 0).x + RULER_SIZE
: canvasToScreen(0, guide.position).y + RULER_SIZE;
return (
<div
key={guide.id}
className={`absolute ${guide.type === 'vertical' ? 'cursor-col-resize' : 'cursor-row-resize'}`}
style={
guide.type === 'vertical'
? {
left: screenPos,
top: RULER_SIZE,
width: 1,
height: containerHeight - RULER_SIZE,
backgroundColor: '#f0abfc',
zIndex: 15,
}
: {
left: RULER_SIZE,
top: screenPos,
width: containerWidth - RULER_SIZE,
height: 1,
backgroundColor: '#f0abfc',
zIndex: 15,
}
}
onMouseDown={(e) => handleGuideDragStart(guide, e)}
onDoubleClick={(e) => handleGuideDoubleClick(guide, e)}
title="Drag to move, double-click to delete"
>
{/* Wider hit area */}
<div
className="absolute"
style={
guide.type === 'vertical'
? { left: -4, width: 9, height: '100%' }
: { top: -4, height: 9, width: '100%' }
}
/>
</div>
);
})}
{/* Mouse tracking overlay (invisible) */}
<div
className="absolute pointer-events-none"
style={{ left: RULER_SIZE, top: RULER_SIZE, right: 0, bottom: 0 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
</>
);
}
export const RULER_SIZE_EXPORT = RULER_SIZE;
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks