feat(phase-13): implement rulers and guides system for precise alignment
Add comprehensive rulers and guides system for canvas alignment and positioning. Features: - Guides store with full CRUD operations - Horizontal and vertical guide support - Ruler display with 50px intervals - Green guide lines with subtle glow - Toggle visibility for rulers and guides - Persistent storage of guides and settings - Snap distance configuration - Guide position in canvas pixels - Zoom-aware positioning Changes: - Created store/guides-store.ts with Guide interface - Added guides state management with persistence - Created components/canvas/rulers-and-guides.tsx - Rulers show measurements at 50px intervals - Guides rendered as 1px green lines with shadow - Exported guides store from store/index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
130
components/canvas/rulers-and-guides.tsx
Normal file
130
components/canvas/rulers-and-guides.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useGuidesStore } from '@/store';
|
||||
import { useCanvasStore } from '@/store';
|
||||
|
||||
interface RulersAndGuidesProps {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export function RulersAndGuides({ canvasWidth, canvasHeight }: RulersAndGuidesProps) {
|
||||
const { guides, showGuides, showRulers } = useGuidesStore();
|
||||
const { zoom, offsetX, offsetY } = useCanvasStore();
|
||||
|
||||
if (!showGuides && !showRulers) return null;
|
||||
|
||||
const rulerSize = 20;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Horizontal Ruler */}
|
||||
{showRulers && (
|
||||
<div
|
||||
className="absolute top-0 left-0 bg-card border-b border-border"
|
||||
style={{
|
||||
width: `${canvasWidth}px`,
|
||||
height: `${rulerSize}px`,
|
||||
marginLeft: `${rulerSize}px`,
|
||||
}}
|
||||
>
|
||||
<svg width={canvasWidth} height={rulerSize}>
|
||||
{Array.from({ length: Math.ceil(canvasWidth / (zoom * 50)) }).map((_, i) => {
|
||||
const x = i * 50 * zoom + offsetX;
|
||||
if (x < 0 || x > canvasWidth) return null;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={x}
|
||||
y1={rulerSize - 5}
|
||||
x2={x}
|
||||
y2={rulerSize}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<text
|
||||
x={x + 2}
|
||||
y={rulerSize - 8}
|
||||
fontSize="10"
|
||||
className="text-muted-foreground fill-current"
|
||||
>
|
||||
{i * 50}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vertical Ruler */}
|
||||
{showRulers && (
|
||||
<div
|
||||
className="absolute top-0 left-0 bg-card border-r border-border"
|
||||
style={{
|
||||
width: `${rulerSize}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
marginTop: `${rulerSize}px`,
|
||||
}}
|
||||
>
|
||||
<svg width={rulerSize} height={canvasHeight}>
|
||||
{Array.from({ length: Math.ceil(canvasHeight / (zoom * 50)) }).map((_, i) => {
|
||||
const y = i * 50 * zoom + offsetY;
|
||||
if (y < 0 || y > canvasHeight) return null;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={rulerSize - 5}
|
||||
y1={y}
|
||||
x2={rulerSize}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<text
|
||||
x={2}
|
||||
y={y - 2}
|
||||
fontSize="10"
|
||||
className="text-muted-foreground fill-current"
|
||||
transform={`rotate(-90 ${2} ${y - 2})`}
|
||||
>
|
||||
{i * 50}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guides */}
|
||||
{showGuides && guides.map((guide) => (
|
||||
<div
|
||||
key={guide.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={
|
||||
guide.type === 'horizontal'
|
||||
? {
|
||||
top: `${guide.position * zoom + offsetY}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
backgroundColor: '#00ff00',
|
||||
boxShadow: '0 0 2px rgba(0, 255, 0, 0.5)',
|
||||
}
|
||||
: {
|
||||
left: `${guide.position * zoom + offsetX}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1px',
|
||||
backgroundColor: '#00ff00',
|
||||
boxShadow: '0 0 2px rgba(0, 255, 0, 0.5)',
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
store/guides-store.ts
Normal file
91
store/guides-store.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface Guide {
|
||||
id: string;
|
||||
type: 'horizontal' | 'vertical';
|
||||
position: number; // In canvas pixels
|
||||
}
|
||||
|
||||
interface GuidesStore {
|
||||
/** All guides */
|
||||
guides: Guide[];
|
||||
/** Whether guides are visible */
|
||||
showGuides: boolean;
|
||||
/** Whether rulers are visible */
|
||||
showRulers: boolean;
|
||||
/** Snap to guides threshold in pixels */
|
||||
snapDistance: number;
|
||||
|
||||
/** Add a guide */
|
||||
addGuide: (type: 'horizontal' | 'vertical', position: number) => void;
|
||||
/** Remove a guide */
|
||||
removeGuide: (id: string) => void;
|
||||
/** Update guide position */
|
||||
updateGuide: (id: string, position: number) => void;
|
||||
/** Clear all guides */
|
||||
clearGuides: () => void;
|
||||
/** Toggle guides visibility */
|
||||
toggleGuides: () => void;
|
||||
/** Toggle rulers visibility */
|
||||
toggleRulers: () => void;
|
||||
/** Set snap distance */
|
||||
setSnapDistance: (distance: number) => void;
|
||||
}
|
||||
|
||||
export const useGuidesStore = create<GuidesStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
guides: [],
|
||||
showGuides: true,
|
||||
showRulers: true,
|
||||
snapDistance: 10,
|
||||
|
||||
addGuide: (type, position) => {
|
||||
const id = `guide-${Date.now()}-${Math.random()}`;
|
||||
set((state) => ({
|
||||
guides: [...state.guides, { id, type, position }],
|
||||
}));
|
||||
},
|
||||
|
||||
removeGuide: (id) => {
|
||||
set((state) => ({
|
||||
guides: state.guides.filter((g) => g.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
updateGuide: (id, position) => {
|
||||
set((state) => ({
|
||||
guides: state.guides.map((g) =>
|
||||
g.id === id ? { ...g, position } : g
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearGuides: () => {
|
||||
set({ guides: [] });
|
||||
},
|
||||
|
||||
toggleGuides: () => {
|
||||
set((state) => ({ showGuides: !state.showGuides }));
|
||||
},
|
||||
|
||||
toggleRulers: () => {
|
||||
set((state) => ({ showRulers: !state.showRulers }));
|
||||
},
|
||||
|
||||
setSnapDistance: (distance) => {
|
||||
set({ snapDistance: Math.max(0, Math.min(50, distance)) });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'guides-storage',
|
||||
partialize: (state) => ({
|
||||
guides: state.guides,
|
||||
showGuides: state.showGuides,
|
||||
showRulers: state.showRulers,
|
||||
snapDistance: state.snapDistance,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -11,3 +11,4 @@ export * from './text-store';
|
||||
export * from './ui-store';
|
||||
export * from './toast-store';
|
||||
export * from './context-menu-store';
|
||||
export * from './guides-store';
|
||||
|
||||
Reference in New Issue
Block a user