mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
254 lines
7.7 KiB
TypeScript
254 lines
7.7 KiB
TypeScript
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<HTMLCanvasElement>(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 (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={cn("block w-full cursor-col-resize select-none")}
|
|
style={{ height: RULER_SIZE }}
|
|
onMouseDown={handleMouseDown}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VerticalRuler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function VerticalRuler() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={cn("block h-full cursor-row-resize select-none")}
|
|
style={{ width: RULER_SIZE }}
|
|
onMouseDown={handleMouseDown}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export { RULER_SIZE };
|