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, useCallback, useState } from 'react';
import { Stage, Layer, Rect, Group, Transformer, Line, Text } from 'react-konva';
import type Konva from 'konva';
import { useEditorStore } from '@/lib/store/editor-store';
import { DeviceLayer } from './DeviceLayer';
import { TextLayer } from './TextLayer';
import { ShapeLayer } from './ShapeLayer';
import { ImageLayer } from './ImageLayer';
import { EffectLayer } from './EffectLayer';
import { calculateAlignmentGuides, type AlignmentGuide, type CustomGuideInput, type DistanceIndicator } from '@/lib/hooks/useAlignmentGuides';
import { Rulers, RULER_SIZE_EXPORT, type CustomGuide } from './Rulers';
import type { Layer as LayerType } from '@/types';
export function Canvas() {
const stageRef = useRef<Konva.Stage>(null);
const transformerRef = useRef<Konva.Transformer>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [stageSize, setStageSize] = React.useState({ width: 800, height: 600 });
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
const [distanceIndicators, setDistanceIndicators] = useState<DistanceIndicator[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isModifierPressed, setIsModifierPressed] = useState(false);
const [customGuides, setCustomGuides] = useState<CustomGuide[]>([]);
const {
canvas,
layers,
selectedLayerIds,
activeTool,
showGrid,
showGuides,
snapToGrid,
setZoom,
setPan,
selectLayer,
deselectAll,
updateLayer,
saveToHistory,
} = useEditorStore();
// Update stage size on mount and resize
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setStageSize({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
// Track modifier key for bypassing snap
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsModifierPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsModifierPressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// Update transformer when selection changes
useEffect(() => {
if (!transformerRef.current || !stageRef.current) return;
const selectedNodes = selectedLayerIds
.map((id) => stageRef.current?.findOne(`#layer-${id}`))
.filter(Boolean) as Konva.Node[];
transformerRef.current.nodes(selectedNodes);
transformerRef.current.getLayer()?.batchDraw();
}, [selectedLayerIds]);
// Handle wheel zoom
const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = stageRef.current;
if (!stage) return;
const oldScale = canvas.zoom;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const mousePointTo = {
x: (pointer.x - canvas.pan.x) / oldScale,
y: (pointer.y - canvas.pan.y) / oldScale,
};
const direction = e.evt.deltaY > 0 ? -1 : 1;
const newScale = direction > 0 ? oldScale * 1.1 : oldScale / 1.1;
const clampedScale = Math.max(0.1, Math.min(3, newScale));
setZoom(clampedScale);
setPan(
pointer.x - mousePointTo.x * clampedScale,
pointer.y - mousePointTo.y * clampedScale
);
},
[canvas.zoom, canvas.pan, setZoom, setPan]
);
// Handle stage click for deselection
const handleStageClick = useCallback(
(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
if (e.target === stageRef.current) {
deselectAll();
}
},
[deselectAll]
);
// Handle layer selection
const handleLayerClick = useCallback(
(layerId: string, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
e.cancelBubble = true;
selectLayer(layerId, e.evt.shiftKey);
},
[selectLayer]
);
// Handle transform end
const handleTransformEnd = useCallback(
(layerId: string, node: Konva.Node) => {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
updateLayer(layerId, {
position: { x: node.x(), y: node.y() },
size: {
width: Math.max(5, node.width() * scaleX),
height: Math.max(5, node.height() * scaleY),
},
rotation: node.rotation(),
});
node.scaleX(1);
node.scaleY(1);
saveToHistory('Transformed layer');
},
[updateLayer, saveToHistory]
);
// Handle drag start
const handleDragStart = useCallback(() => {
setIsDragging(true);
}, []);
// Handle drag move for alignment guides
const handleDragMove = useCallback(
(layerId: string, node: Konva.Node) => {
// If modifier key is pressed, bypass snapping entirely
if (isModifierPressed) {
setAlignmentGuides([]);
setDistanceIndicators([]);
return;
}
if (!showGuides) {
setAlignmentGuides([]);
setDistanceIndicators([]);
return;
}
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
const currentPosition = {
...layer,
position: { x: node.x(), y: node.y() },
};
const otherLayers = layers.filter((l) => l.id !== layerId);
// Convert custom guides to the format expected by calculateAlignmentGuides
const customGuideInputs: CustomGuideInput[] = customGuides.map((g) => ({
type: g.type,
position: g.position,
}));
const result = calculateAlignmentGuides(
currentPosition,
otherLayers,
canvas.width,
canvas.height,
snapToGrid,
customGuideInputs
);
setAlignmentGuides(result.guides);
setDistanceIndicators(result.distanceIndicators);
// Apply snapping if enabled (and modifier not pressed)
if (snapToGrid && !isModifierPressed && (result.x !== node.x() || result.y !== node.y())) {
node.x(result.x);
node.y(result.y);
}
},
[layers, canvas.width, canvas.height, showGuides, snapToGrid, isModifierPressed, customGuides]
);
// Handle drag end
const handleDragEnd = useCallback(
(layerId: string, node: Konva.Node) => {
setIsDragging(false);
setAlignmentGuides([]);
setDistanceIndicators([]);
updateLayer(layerId, {
position: { x: node.x(), y: node.y() },
});
saveToHistory('Moved layer');
},
[updateLayer, saveToHistory]
);
// Custom guide handlers
const handleAddGuide = useCallback((guide: CustomGuide) => {
setCustomGuides((prev) => [...prev, guide]);
}, []);
const handleUpdateGuide = useCallback((id: string, position: number) => {
setCustomGuides((prev) =>
prev.map((g) => (g.id === id ? { ...g, position } : g))
);
}, []);
const handleDeleteGuide = useCallback((id: string) => {
setCustomGuides((prev) => prev.filter((g) => g.id !== id));
}, []);
// Render layer based on type
const renderLayer = (layer: LayerType) => {
if (!layer.visible) return null;
const commonProps = {
id: `layer-${layer.id}`,
layer,
isSelected: selectedLayerIds.includes(layer.id),
onClick: (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => handleLayerClick(layer.id, e),
onTransformEnd: (node: Konva.Node) => handleTransformEnd(layer.id, node),
onDragStart: handleDragStart,
onDragMove: (node: Konva.Node) => handleDragMove(layer.id, node),
onDragEnd: (node: Konva.Node) => handleDragEnd(layer.id, node),
};
switch (layer.type) {
case 'device':
return <DeviceLayer key={layer.id} {...commonProps} />;
case 'text':
return <TextLayer key={layer.id} {...commonProps} />;
case 'shape':
return <ShapeLayer key={layer.id} {...commonProps} />;
case 'image':
case 'screenshot':
return <ImageLayer key={layer.id} {...commonProps} />;
case 'effect':
return <EffectLayer key={layer.id} {...commonProps} />;
default:
return null;
}
};
// Calculate background gradient if needed
const renderBackground = () => {
const { backgroundColor } = canvas;
if (typeof backgroundColor === 'string') {
return (
<Rect
x={0}
y={0}
width={canvas.width}
height={canvas.height}
fill={backgroundColor}
listening={false}
/>
);
}
// Gradient background
const { type, stops, angle = 0 } = backgroundColor;
const gradientStops = stops.flatMap((stop) => [stop.offset, stop.color]);
if (type === 'linear') {
const rad = (angle * Math.PI) / 180;
const x1 = canvas.width / 2 - (Math.cos(rad) * canvas.width) / 2;
const y1 = canvas.height / 2 - (Math.sin(rad) * canvas.height) / 2;
const x2 = canvas.width / 2 + (Math.cos(rad) * canvas.width) / 2;
const y2 = canvas.height / 2 + (Math.sin(rad) * canvas.height) / 2;
return (
<Rect
x={0}
y={0}
width={canvas.width}
height={canvas.height}
fillLinearGradientStartPoint={{ x: x1, y: y1 }}
fillLinearGradientEndPoint={{ x: x2, y: y2 }}
fillLinearGradientColorStops={gradientStops}
listening={false}
/>
);
}
return (
<Rect
x={0}
y={0}
width={canvas.width}
height={canvas.height}
fillRadialGradientStartPoint={{ x: canvas.width / 2, y: canvas.height / 2 }}
fillRadialGradientEndPoint={{ x: canvas.width / 2, y: canvas.height / 2 }}
fillRadialGradientStartRadius={0}
fillRadialGradientEndRadius={Math.max(canvas.width, canvas.height) / 2}
fillRadialGradientColorStops={gradientStops}
listening={false}
/>
);
};
// Grid pattern
const renderGrid = () => {
if (!showGrid) return null;
const gridSize = 50;
const lines = [];
for (let i = 0; i <= canvas.width; i += gridSize) {
lines.push(
<Rect
key={`v-${i}`}
x={i}
y={0}
width={1}
height={canvas.height}
fill="rgba(255, 255, 255, 0.1)"
listening={false}
/>
);
}
for (let i = 0; i <= canvas.height; i += gridSize) {
lines.push(
<Rect
key={`h-${i}`}
x={0}
y={i}
width={canvas.width}
height={1}
fill="rgba(255, 255, 255, 0.1)"
listening={false}
/>
);
}
return <>{lines}</>;
};
return (
<div
ref={containerRef}
className="flex-1 overflow-hidden bg-neutral-900 relative canvas-container"
style={{ cursor: activeTool === 'move' ? 'grab' : 'default' }}
>
{/* Rulers */}
<Rulers
containerWidth={stageSize.width}
containerHeight={stageSize.height}
customGuides={customGuides}
onAddGuide={handleAddGuide}
onUpdateGuide={handleUpdateGuide}
onDeleteGuide={handleDeleteGuide}
/>
{/* Checkerboard pattern for transparency */}
<div
className="absolute opacity-20"
style={{
left: RULER_SIZE_EXPORT,
top: RULER_SIZE_EXPORT,
right: 0,
bottom: 0,
backgroundImage: `
linear-gradient(45deg, #333 25%, transparent 25%),
linear-gradient(-45deg, #333 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #333 75%),
linear-gradient(-45deg, transparent 75%, #333 75%)
`,
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
}}
/>
<Stage
ref={stageRef}
width={stageSize.width - RULER_SIZE_EXPORT}
height={stageSize.height - RULER_SIZE_EXPORT}
scaleX={canvas.zoom}
scaleY={canvas.zoom}
x={canvas.pan.x}
y={canvas.pan.y}
onWheel={handleWheel}
onClick={handleStageClick}
draggable={activeTool === 'move'}
onDragEnd={() => {
if (stageRef.current) {
setPan(stageRef.current.x(), stageRef.current.y());
}
}}
style={{
position: 'absolute',
left: RULER_SIZE_EXPORT,
top: RULER_SIZE_EXPORT,
}}
>
<Layer>
{/* Canvas background */}
<Group>
{renderBackground()}
{renderGrid()}
</Group>
{/* Canvas border for visibility */}
<Rect
x={0}
y={0}
width={canvas.width}
height={canvas.height}
stroke="rgba(255, 255, 255, 0.2)"
strokeWidth={2 / canvas.zoom}
listening={false}
/>
{/* Render all layers */}
{layers.map(renderLayer)}
{/* Alignment guides */}
{showGuides && alignmentGuides.map((guide, index) => (
<Line
key={`guide-${index}`}
points={
guide.type === 'vertical'
? [guide.position, guide.start, guide.position, guide.end]
: [guide.start, guide.position, guide.end, guide.position]
}
stroke="#f0abfc"
strokeWidth={1 / canvas.zoom}
dash={[4 / canvas.zoom, 4 / canvas.zoom]}
listening={false}
/>
))}
{/* Distance indicators */}
{showGuides && distanceIndicators.map((indicator, index) => {
const midX = (indicator.start.x + indicator.end.x) / 2;
const midY = (indicator.start.y + indicator.end.y) / 2;
const labelPadding = 4 / canvas.zoom;
const fontSize = 11 / canvas.zoom;
return (
<Group key={`distance-${index}`}>
{/* Distance line */}
<Line
points={[
indicator.start.x,
indicator.start.y,
indicator.end.x,
indicator.end.y,
]}
stroke="#ef4444"
strokeWidth={1 / canvas.zoom}
listening={false}
/>
{/* End caps */}
{indicator.type === 'horizontal' ? (
<>
<Line
points={[
indicator.start.x,
indicator.start.y - 4 / canvas.zoom,
indicator.start.x,
indicator.start.y + 4 / canvas.zoom,
]}
stroke="#ef4444"
strokeWidth={1 / canvas.zoom}
listening={false}
/>
<Line
points={[
indicator.end.x,
indicator.end.y - 4 / canvas.zoom,
indicator.end.x,
indicator.end.y + 4 / canvas.zoom,
]}
stroke="#ef4444"
strokeWidth={1 / canvas.zoom}
listening={false}
/>
</>
) : (
<>
<Line
points={[
indicator.start.x - 4 / canvas.zoom,
indicator.start.y,
indicator.start.x + 4 / canvas.zoom,
indicator.start.y,
]}
stroke="#ef4444"
strokeWidth={1 / canvas.zoom}
listening={false}
/>
<Line
points={[
indicator.end.x - 4 / canvas.zoom,
indicator.end.y,
indicator.end.x + 4 / canvas.zoom,
indicator.end.y,
]}
stroke="#ef4444"
strokeWidth={1 / canvas.zoom}
listening={false}
/>
</>
)}
{/* Distance label background */}
<Rect
x={midX - 20 / canvas.zoom}
y={midY - fontSize / 2 - labelPadding}
width={40 / canvas.zoom}
height={fontSize + labelPadding * 2}
fill="#ef4444"
cornerRadius={2 / canvas.zoom}
listening={false}
/>
{/* Distance label text */}
<Text
x={midX - 20 / canvas.zoom}
y={midY - fontSize / 2}
width={40 / canvas.zoom}
height={fontSize + labelPadding}
text={`${indicator.distance}px`}
fontSize={fontSize}
fontFamily="Inter, system-ui, sans-serif"
fill="#ffffff"
align="center"
verticalAlign="middle"
listening={false}
/>
</Group>
);
})}
{/* Transformer for selected elements */}
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
rotateEnabled={true}
enabledAnchors={[
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'middle-left',
'middle-right',
'top-center',
'bottom-center',
]}
anchorStroke="#4f46e5"
anchorFill="#fff"
anchorSize={10}
anchorCornerRadius={2}
borderStroke="#4f46e5"
borderStrokeWidth={2}
/>
</Layer>
</Stage>
{/* Zoom indicator */}
<div className="absolute bottom-4 right-4 bg-black/50 backdrop-blur px-3 py-1.5 rounded-lg text-sm text-white/80">
{Math.round(canvas.zoom * 100)}%
</div>
</div>
);
}
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks