Files
yvcodesnap/src/components/Canvas.tsx
T

548 lines
18 KiB
TypeScript

import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react';
import { Stage, Layer, Rect, Line, Text, Group, Path, Circle } from 'react-konva';
import type Konva from 'konva';
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
import CodeBlock from './elements/CodeBlock';
import TextBlock from './elements/TextBlock';
import Arrow from './elements/Arrow';
import { SOCIAL_ICON_PATHS, SOCIAL_PLATFORMS_CONFIG } from './elements/SocialIcons';
import type { CodeElement, TextElement, ArrowElement } from '../types';
interface CanvasProps {
stageRef: React.RefObject<Konva.Stage | null>;
}
const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight - 120 });
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const {
snap,
zoom,
showGrid,
tool,
selectedElementId,
selectElement,
addElement,
updateElement,
setZoom,
} = useCanvasStore();
const { width, height } = snap.meta;
const { background } = snap;
const [brandingAvatar, setBrandingAvatar] = useState<HTMLImageElement | null>(null);
// Handle resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
useEffect(() => {
const url = background.branding?.avatarUrl || '';
if (!url) {
setBrandingAvatar(null);
return;
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => setBrandingAvatar(img);
img.onerror = () => setBrandingAvatar(null);
img.src = url;
return () => {
img.onload = null;
img.onerror = null;
};
}, [background.branding?.avatarUrl]);
// Calculate stage position to center the canvas
const getStagePosition = useCallback(() => {
const scaledWidth = width * zoom;
const scaledHeight = height * zoom;
return {
x: Math.max(20, (dimensions.width - scaledWidth) / 2) + panOffset.x,
y: Math.max(20, (dimensions.height - scaledHeight) / 2) + panOffset.y,
};
}, [width, height, zoom, dimensions, panOffset]);
// Reset pan offset when zoom changes significantly or canvas is re-centered
const resetPan = useCallback(() => {
setPanOffset({ x: 0, y: 0 });
}, []);
const handleStageClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const clickedOnEmpty = e.target === e.target.getStage() || e.target.name() === 'background';
if (clickedOnEmpty) {
const stage = e.target.getStage();
if (!stage) return;
const pos = stage.getPointerPosition();
if (!pos) return;
// Convert screen position to canvas position
const stagePos = getStagePosition();
const canvasX = (pos.x - stagePos.x) / zoom;
const canvasY = (pos.y - stagePos.y) / zoom;
if (tool === 'code') {
addElement(createCodeElement(canvasX - 300, canvasY - 150));
} else if (tool === 'text') {
addElement(createTextElement(canvasX, canvasY));
} else if (tool === 'arrow') {
addElement(createArrowElement(canvasX, canvasY));
} else {
selectElement(null);
}
}
};
// Background gradient
const renderBackground = () => {
if (background.type === 'gradient') {
return (
<Rect
name="background"
x={0}
y={0}
width={width}
height={height}
fillLinearGradientStartPoint={{ x: 0, y: 0 }}
fillLinearGradientEndPoint={{
x: width * Math.cos((background.gradient.angle * Math.PI) / 180),
y: height * Math.sin((background.gradient.angle * Math.PI) / 180)
}}
fillLinearGradientColorStops={[0, background.gradient.from, 1, background.gradient.to]}
/>
);
}
return (
<Rect
name="background"
x={0}
y={0}
width={width}
height={height}
fill={background.solid.color}
/>
);
};
// Grid overlay - memoized for performance
const gridLines = useMemo(() => {
if (!showGrid) return null;
const gridSize = 50;
const lines = [];
// Vertical lines
for (let i = 0; i <= width; i += gridSize) {
lines.push(
<Line
key={`v-${i}`}
points={[i, 0, i, height]}
stroke="rgba(255,255,255,0.1)"
strokeWidth={1}
/>
);
}
// Horizontal lines
for (let i = 0; i <= height; i += gridSize) {
lines.push(
<Line
key={`h-${i}`}
points={[0, i, width, i]}
stroke="rgba(255,255,255,0.1)"
strokeWidth={1}
/>
);
}
return <>{lines}</>;
}, [showGrid, width, height]);
// Brand strip - memoized
const brandStripElement = useMemo(() => {
const brandStrip = background.brandStrip;
if (!brandStrip?.enabled) return null;
const stripHeight = brandStrip.height || 60;
const stripY = brandStrip.position === 'top' ? 0 : height - stripHeight;
return (
<>
<Rect
x={0}
y={stripY}
width={width}
height={stripHeight}
fill={brandStrip.color || '#000000'}
/>
{brandStrip.text && (
<Text
x={0}
y={stripY}
width={width}
height={stripHeight}
text={brandStrip.text}
fontSize={brandStrip.fontSize || 16}
fontFamily={brandStrip.fontFamily || 'Inter'}
fill={brandStrip.textColor || '#ffffff'}
align="center"
verticalAlign="middle"
/>
)}
</>
);
}, [background.brandStrip, width, height]);
// Branding watermark - memoized
const brandingElement = useMemo(() => {
const branding = background.branding;
if (!branding?.enabled) return null;
const padding = branding.padding || 24;
const fontSize = branding.fontSize || 14;
const lineHeight = fontSize * 1.5;
const iconSize = branding.socialIconSize || 20;
const socialLayout = branding.socialLayout || 'horizontal';
const iconGap = 16;
const iconTextGap = 6; // Gap between icon and its text
const avatarSize = branding.avatarSize || 56;
const avatarGap = 14;
const hasAvatar = Boolean(branding.showAvatar && branding.avatarUrl && brandingAvatar);
// Build branding text lines
const lines: string[] = [];
if (branding.showName && branding.name) {
lines.push(branding.name);
}
if (branding.showWebsite && branding.website) {
lines.push(branding.website);
}
// Get active social platforms with their values
const activeSocialPlatforms = branding.showSocial && branding.social
? SOCIAL_PLATFORMS_CONFIG.filter(p => branding.social[p.key as keyof typeof branding.social])
.map(p => ({
...p,
value: branding.social[p.key as keyof typeof branding.social] || ''
}))
: [];
const hasTextContent = lines.length > 0;
const hasSocialIcons = activeSocialPlatforms.length > 0;
if (!hasTextContent && !hasSocialIcons && !hasAvatar) return null;
const textHeight = lines.length * lineHeight;
// Calculate social section dimensions (icon + text for each platform)
const socialItemHeight = Math.max(iconSize, fontSize);
const socialHeight = socialLayout === 'vertical'
? activeSocialPlatforms.length * socialItemHeight + (activeSocialPlatforms.length - 1) * (iconGap - 4)
: socialItemHeight;
// Calculate total content height
const contentHeight = (hasTextContent || hasSocialIcons)
? textHeight + (hasTextContent && hasSocialIcons ? 16 : 0) + (hasSocialIcons ? socialHeight : 0)
: 0;
const blockHeight = Math.max(contentHeight, hasAvatar ? avatarSize : 0);
// Calculate position based on setting
let x = padding;
let y = padding;
let align: 'left' | 'right' = 'left';
switch (branding.position) {
case 'top-left':
x = padding;
y = padding;
align = 'left';
break;
case 'top-right':
x = width - padding;
y = padding;
align = 'right';
break;
case 'bottom-left':
x = padding;
y = height - padding - blockHeight;
align = 'left';
break;
case 'bottom-right':
x = width - padding;
y = height - padding - blockHeight;
align = 'right';
break;
}
const totalHeight = blockHeight;
const contentTop = contentHeight > 0 ? y + (totalHeight - contentHeight) / 2 : y;
const avatarY = hasAvatar ? y + (totalHeight - avatarSize) / 2 : 0;
const avatarCenterX = align === 'right' ? x - avatarSize / 2 : x + avatarSize / 2;
const avatarCenterY = avatarY + avatarSize / 2;
const avatarPatternScale = hasAvatar && brandingAvatar && brandingAvatar.width && brandingAvatar.height
? Math.max(avatarSize / brandingAvatar.width, avatarSize / brandingAvatar.height)
: 1;
const avatarOffset = hasAvatar ? avatarSize + avatarGap : 0;
const textStartXBase = align === 'right' ? x - avatarOffset : x + avatarOffset;
const textX = align === 'right' ? 0 : textStartXBase;
const textWidth = align === 'right'
? Math.max(textStartXBase, 0)
: Math.max(width - padding - textStartXBase, 0);
// Calculate icon positions
const iconScale = iconSize / 24; // SVG viewBox is 24x24
const socialStartY = contentTop + textHeight + (hasTextContent ? 16 : 0);
const anchorX = textStartXBase;
return (
<Group opacity={branding.opacity || 0.8}>
{/* Avatar */}
{hasAvatar && brandingAvatar && (
<Circle
x={avatarCenterX}
y={avatarCenterY}
radius={avatarSize / 2}
fillPatternImage={brandingAvatar}
fillPatternScaleX={avatarPatternScale}
fillPatternScaleY={avatarPatternScale}
fillPatternOffsetX={brandingAvatar.width / 2}
fillPatternOffsetY={brandingAvatar.height / 2}
listening={false}
/>
)}
{/* Text content */}
{hasTextContent && (
<Text
x={textX}
y={contentTop}
width={textWidth}
text={lines.join('\n')}
fontSize={fontSize}
fontFamily={branding.fontFamily || 'Inter'}
fill={branding.color || '#ffffff'}
align={align}
lineHeight={1.5}
/>
)}
{/* Social icons with text */}
{hasSocialIcons && activeSocialPlatforms.map((platform, index) => {
const path = SOCIAL_ICON_PATHS[platform.key];
if (!path) return null;
// Calculate position for this social item
let itemX: number;
let itemY: number;
if (socialLayout === 'vertical') {
itemY = socialStartY + index * (socialItemHeight + iconGap - 4);
itemX = anchorX;
} else {
itemY = socialStartY;
// For horizontal layout, we need to calculate cumulative width
// This is simplified - for perfect alignment we'd need to measure text
const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => {
const textWidthEstimate = (p.value.length * fontSize * 0.5); // Approximate text width
return acc + iconSize + iconTextGap + textWidthEstimate + iconGap;
}, 0);
itemX = align === 'right' ? anchorX - prevItemsWidth : anchorX + prevItemsWidth;
}
// Icon vertical centering within item
const iconY = itemY + (socialItemHeight - iconSize) / 2;
// Text vertical centering
const textY = itemY + (socialItemHeight - fontSize) / 2;
if (align === 'right') {
// For right alignment: text first, then icon
const textWidthEstimate = platform.value.length * fontSize * 0.5; // Approximate
return (
<Group key={platform.key}>
<Path
x={itemX - iconSize}
y={iconY}
data={path}
fill={branding.color || '#ffffff'}
scaleX={iconScale}
scaleY={iconScale}
/>
<Text
x={itemX - iconSize - iconTextGap - textWidthEstimate}
y={textY}
text={platform.value}
fontSize={fontSize}
fontFamily={branding.fontFamily || 'Inter'}
fill={branding.color || '#ffffff'}
/>
</Group>
);
} else {
// For left alignment: icon first, then text
return (
<Group key={platform.key}>
<Path
x={itemX}
y={iconY}
data={path}
fill={branding.color || '#ffffff'}
scaleX={iconScale}
scaleY={iconScale}
/>
<Text
x={itemX + iconSize + iconTextGap}
y={textY}
text={platform.value}
fontSize={fontSize}
fontFamily={branding.fontFamily || 'Inter'}
fill={branding.color || '#ffffff'}
/>
</Group>
);
}
})}
</Group>
);
}, [background.branding, width, height, brandingAvatar]);
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
// Handle wheel/pinch zoom and pan on canvas
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
// Check if it's a pinch zoom gesture (ctrlKey is true for pinch-to-zoom)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const scaleBy = 1.05;
const minZoom = 0.1;
const maxZoom = 3;
// Determine zoom direction
const direction = e.deltaY > 0 ? -1 : 1;
const newZoom = direction > 0 ? zoom * scaleBy : zoom / scaleBy;
// Clamp zoom between min and max
const clampedZoom = Math.min(Math.max(newZoom, minZoom), maxZoom);
setZoom(clampedZoom);
} else {
// Two-finger scroll for panning
e.preventDefault();
e.stopPropagation();
setPanOffset(prev => ({
x: prev.x - e.deltaX,
y: prev.y - e.deltaY,
}));
}
}, [zoom, setZoom]);
// Prevent default browser zoom behavior on the container
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventDefaultZoom = (e: WheelEvent) => {
// Prevent default for both zoom (ctrl/meta) and pan (regular scroll)
e.preventDefault();
};
// Use passive: false to allow preventDefault
container.addEventListener('wheel', preventDefaultZoom, { passive: false });
return () => {
container.removeEventListener('wheel', preventDefaultZoom);
};
}, []);
return (
<div
ref={containerRef}
className="flex-1 bg-neutral-900 overflow-hidden relative"
style={{ cursor: tool !== 'select' ? 'crosshair' : 'default' }}
onWheel={handleWheel}
onDoubleClick={(e) => {
// Double-click on empty area to reset pan
if (e.target === containerRef.current) {
resetPan();
}
}}
>
<Stage
ref={stageRef}
width={dimensions.width}
height={dimensions.height}
onClick={handleStageClick}
x={stagePosition.x}
y={stagePosition.y}
scaleX={zoom}
scaleY={zoom}
>
<Layer>
{renderBackground()}
{brandStripElement}
{brandingElement}
{gridLines}
{snap.elements.map((element) => {
if (!element.visible) return null;
switch (element.type) {
case 'code':
return (
<CodeBlock
key={element.id}
element={element as CodeElement}
isSelected={selectedElementId === element.id}
onSelect={() => selectElement(element.id)}
onChange={(updates: Partial<CodeElement>) => updateElement(element.id, updates)}
/>
);
case 'text':
return (
<TextBlock
key={element.id}
element={element as TextElement}
isSelected={selectedElementId === element.id}
onSelect={() => selectElement(element.id)}
onChange={(updates: Partial<TextElement>) => updateElement(element.id, updates)}
/>
);
case 'arrow':
return (
<Arrow
key={element.id}
element={element as ArrowElement}
isSelected={selectedElementId === element.id}
onSelect={() => selectElement(element.id)}
onChange={(updates: Partial<ArrowElement>) => updateElement(element.id, updates)}
/>
);
default:
return null;
}
})}
</Layer>
</Stage>
</div>
);
};
export default memo(Canvas);