diff --git a/src/App.tsx b/src/App.tsx index b60b2fa..7d54885 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import Canvas from './components/Canvas'; import TopBar from './components/TopBar'; import Toolbar from './components/Toolbar'; import Inspector from './components/Inspector'; +import LayersPanel from './components/LayersPanel'; +import FontLoader from './components/FontLoader'; import { useCanvasStore } from './store/canvasStore'; function App() { @@ -105,8 +107,10 @@ function App() { return (
+
+ diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 92d6c03..a92f366 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { Stage, Layer, Rect, Line } from 'react-konva'; +import { Stage, Layer, Rect, Line, Text } from 'react-konva'; import type Konva from 'konva'; import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore'; import CodeBlock from './elements/CodeBlock'; @@ -145,6 +145,41 @@ const Canvas: React.FC = ({ stageRef }) => { return <>{lines}; }; + // Brand strip + const renderBrandStrip = () => { + const brandStrip = background.brandStrip; + if (!brandStrip?.enabled) return null; + + const stripHeight = brandStrip.height || 60; + const stripY = brandStrip.position === 'top' ? 0 : height - stripHeight; + + return ( + <> + + {brandStrip.text && ( + + )} + + ); + }; + const stagePosition = getStagePosition(); return ( @@ -165,6 +200,7 @@ const Canvas: React.FC = ({ stageRef }) => { > {renderBackground()} + {renderBrandStrip()} {renderGrid()} {snap.elements.map((element) => { diff --git a/src/components/FontLoader.tsx b/src/components/FontLoader.tsx new file mode 100644 index 0000000..ebe2174 --- /dev/null +++ b/src/components/FontLoader.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { loadFont } from '../utils/fontLoader'; + +interface FontLoaderProps { + fonts?: string[]; +} + +const FontLoader: React.FC = ({ fonts }) => { + useEffect(() => { + if (fonts && fonts.length > 0) { + fonts.forEach(loadFont); + } else { + // Load essential fonts by default + loadFont('Inter'); + loadFont('JetBrains Mono'); + loadFont('Fira Code'); + } + }, [fonts]); + + return null; +}; + +export default FontLoader; diff --git a/src/components/LayersPanel.tsx b/src/components/LayersPanel.tsx new file mode 100644 index 0000000..25387b4 --- /dev/null +++ b/src/components/LayersPanel.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { useCanvasStore } from '../store/canvasStore'; +import type { CanvasElement } from '../types'; + +const LayersPanel: React.FC = () => { + const { + snap, + selectedElementId, + selectElement, + updateElement, + moveElementUp, + moveElementDown, + deleteElement, + } = useCanvasStore(); + + const elements = [...snap.elements].reverse(); // Show top layers first + + const getElementIcon = (type: CanvasElement['type']) => { + switch (type) { + case 'code': + return ( + + + + ); + case 'text': + return ( + + + + ); + case 'arrow': + return ( + + + + ); + } + }; + + const getElementLabel = (element: CanvasElement) => { + switch (element.type) { + case 'code': + return `Code Block`; + case 'text': + return element.props.text.slice(0, 20) + (element.props.text.length > 20 ? '...' : ''); + case 'arrow': + return `Arrow`; + } + }; + + return ( +
+
+

Layers

+
+ +
+ {elements.length === 0 ? ( +
+ No elements yet. +
+ Click on the canvas to add elements. +
+ ) : ( +
+ {elements.map((element) => ( +
selectElement(element.id)} + className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${ + selectedElementId === element.id + ? 'bg-blue-600/20 border border-blue-500/50' + : 'hover:bg-white/5 border border-transparent' + }`} + > + {/* Element type icon */} +
+ {getElementIcon(element.type)} +
+ + {/* Element name */} + + {getElementLabel(element)} + + + {/* Action buttons */} +
+ {/* Lock toggle */} + + + {/* Visibility toggle */} + +
+
+ ))} +
+ )} +
+ + {/* Layer actions footer */} + {selectedElementId && ( +
+
+
+ + +
+ +
+
+ )} +
+ ); +}; + +export default LayersPanel; diff --git a/src/components/elements/Arrow.tsx b/src/components/elements/Arrow.tsx index 1ecd55f..3c9871d 100644 --- a/src/components/elements/Arrow.tsx +++ b/src/components/elements/Arrow.tsx @@ -1,5 +1,5 @@ -import React, { useRef } from 'react'; -import { Group, Arrow as KonvaArrow, Circle } from 'react-konva'; +import React, { useRef, useMemo } from 'react'; +import { Group, Arrow as KonvaArrow, Circle, Line, Text } from 'react-konva'; import type Konva from 'konva'; import type { ArrowElement } from '../../types'; @@ -10,12 +10,111 @@ interface ArrowProps { onChange: (updates: Partial) => void; } +// Calculate bezier curve points for smooth curved arrows +const getBezierPoints = ( + start: { x: number; y: number }, + end: { x: number; y: number }, + controlPoints: { x: number; y: number }[] = [] +): number[] => { + const numSegments = 50; + const points: number[] = []; + + if (controlPoints.length === 0) { + // No control points, use auto-calculated curve + const midX = (start.x + end.x) / 2; + const midY = (start.y + end.y) / 2; + const dx = end.x - start.x; + const dy = end.y - start.y; + // Perpendicular offset for natural curve + const cp = { + x: midX - dy * 0.3, + y: midY + dx * 0.3, + }; + controlPoints = [cp]; + } + + if (controlPoints.length === 1) { + // Quadratic bezier + const cp = controlPoints[0]; + for (let t = 0; t <= 1; t += 1 / numSegments) { + const x = Math.pow(1 - t, 2) * start.x + 2 * (1 - t) * t * cp.x + Math.pow(t, 2) * end.x; + const y = Math.pow(1 - t, 2) * start.y + 2 * (1 - t) * t * cp.y + Math.pow(t, 2) * end.y; + points.push(x, y); + } + } else if (controlPoints.length >= 2) { + // Cubic bezier + const cp1 = controlPoints[0]; + const cp2 = controlPoints[1]; + for (let t = 0; t <= 1; t += 1 / numSegments) { + const x = Math.pow(1 - t, 3) * start.x + 3 * Math.pow(1 - t, 2) * t * cp1.x + 3 * (1 - t) * Math.pow(t, 2) * cp2.x + Math.pow(t, 3) * end.x; + const y = Math.pow(1 - t, 3) * start.y + 3 * Math.pow(1 - t, 2) * t * cp1.y + 3 * (1 - t) * Math.pow(t, 2) * cp2.y + Math.pow(t, 3) * end.y; + points.push(x, y); + } + } + + return points; +}; + +// Get point along bezier curve at parameter t (0-1) +const getPointOnBezier = ( + start: { x: number; y: number }, + end: { x: number; y: number }, + controlPoints: { x: number; y: number }[], + t: number +): { x: number; y: number } => { + if (controlPoints.length === 1) { + const cp = controlPoints[0]; + return { + x: Math.pow(1 - t, 2) * start.x + 2 * (1 - t) * t * cp.x + Math.pow(t, 2) * end.x, + y: Math.pow(1 - t, 2) * start.y + 2 * (1 - t) * t * cp.y + Math.pow(t, 2) * end.y, + }; + } else if (controlPoints.length >= 2) { + const cp1 = controlPoints[0]; + const cp2 = controlPoints[1]; + return { + x: Math.pow(1 - t, 3) * start.x + 3 * Math.pow(1 - t, 2) * t * cp1.x + 3 * (1 - t) * Math.pow(t, 2) * cp2.x + Math.pow(t, 3) * end.x, + y: Math.pow(1 - t, 3) * start.y + 3 * Math.pow(1 - t, 2) * t * cp1.y + 3 * (1 - t) * Math.pow(t, 2) * cp2.y + Math.pow(t, 3) * end.y, + }; + } + // Linear + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; +}; + const Arrow: React.FC = ({ element, isSelected, onSelect, onChange }) => { const arrowRef = useRef(null); const { points, props } = element; + const start = points[0]; + const end = points[points.length - 1]; - // Flatten points for Konva - const flatPoints = points.flatMap(p => [p.x, p.y]); + // Get control points (either from props or auto-generate for curved) + const controlPoints = useMemo(() => { + if (props.style === 'curved') { + if (props.controlPoints && props.controlPoints.length > 0) { + return props.controlPoints; + } + // Auto-generate a control point + const midX = (start.x + end.x) / 2; + const midY = (start.y + end.y) / 2; + const dx = end.x - start.x; + const dy = end.y - start.y; + return [{ + x: midX - dy * 0.3, + y: midY + dx * 0.3, + }]; + } + return []; + }, [props.style, props.controlPoints, start, end]); + + // Calculate points for rendering + const flatPoints = useMemo(() => { + if (props.style === 'curved') { + return getBezierPoints(start, end, controlPoints); + } + return points.flatMap(p => [p.x, p.y]); + }, [props.style, points, start, end, controlPoints]); const handlePointDrag = (index: number, e: Konva.KonvaEventObject) => { const newPoints = [...points]; @@ -26,62 +125,208 @@ const Arrow: React.FC = ({ element, isSelected, onSelect, onChange } onChange({ points: newPoints }); }; + const handleControlPointDrag = (index: number, e: Konva.KonvaEventObject) => { + const newControlPoints = [...controlPoints]; + newControlPoints[index] = { + x: e.target.x(), + y: e.target.y(), + }; + onChange({ props: { ...props, controlPoints: newControlPoints } }); + }; + // Modern arrow head calculations - // Make the head slightly sleeker const pointerLength = props.head === 'none' ? 0 : Math.max(props.thickness * 3, 12); const pointerWidth = props.head === 'none' ? 0 : Math.max(props.thickness * 2.5, 12); + // Calculate label position + const labelPosition = props.labelPosition ?? 0.5; + const labelPoint = props.style === 'curved' + ? getPointOnBezier(start, end, controlPoints, labelPosition) + : { x: start.x + (end.x - start.x) * labelPosition, y: start.y + (end.y - start.y) * labelPosition }; + return ( - - - {/* Control points when selected */} - {isSelected && points.map((point, index) => ( - handlePointDrag(index, e)} - onDragEnd={(e) => handlePointDrag(index, e)} - onMouseEnter={(e) => { - const container = e.target.getStage()?.container(); - if (container) container.style.cursor = 'grab'; - e.target.scale({ x: 1.5, y: 1.5 }); - }} - onMouseLeave={(e) => { - const container = e.target.getStage()?.container(); - if (container) container.style.cursor = 'default'; - e.target.scale({ x: 1, y: 1 }); - }} + {/* For curved arrows, use Line with many points */} + {props.style === 'curved' ? ( + - ))} + ) : ( + + )} + + {/* Arrow head for curved arrows (drawn separately) */} + {props.style === 'curved' && props.head !== 'none' && ( + + )} + + {/* Label text */} + {props.label && ( + + )} + + {/* Control points when selected (for curved arrows) */} + {isSelected && props.style === 'curved' && ( + <> + {/* Control point lines */} + {controlPoints.map((cp, index) => ( + + + {index === controlPoints.length - 1 && ( + + )} + + ))} + + {/* Control point handles */} + {controlPoints.map((cp, index) => ( + handleControlPointDrag(index, e)} + onDragEnd={(e) => handleControlPointDrag(index, e)} + onMouseEnter={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'move'; + e.target.scale({ x: 1.3, y: 1.3 }); + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'default'; + e.target.scale({ x: 1, y: 1 }); + }} + /> + ))} + + )} + + {/* Endpoint control points when selected */} + {isSelected && ( + <> + {/* Start point */} + handlePointDrag(0, e)} + onDragEnd={(e) => handlePointDrag(0, e)} + onMouseEnter={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'grab'; + e.target.scale({ x: 1.5, y: 1.5 }); + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'default'; + e.target.scale({ x: 1, y: 1 }); + }} + /> + {/* End point */} + handlePointDrag(points.length - 1, e)} + onDragEnd={(e) => handlePointDrag(points.length - 1, e)} + onMouseEnter={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'grab'; + e.target.scale({ x: 1.5, y: 1.5 }); + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = 'default'; + e.target.scale({ x: 1, y: 1 }); + }} + /> + + )} ); }; diff --git a/src/components/inspector/ArrowInspector.tsx b/src/components/inspector/ArrowInspector.tsx index 4e10909..8b91a41 100644 --- a/src/components/inspector/ArrowInspector.tsx +++ b/src/components/inspector/ArrowInspector.tsx @@ -17,28 +17,57 @@ const ArrowInspector: React.FC = ({ element }) => { update({ props: { ...element.props, ...props } }); }; + const addControlPoint = () => { + const start = element.points[0]; + const end = element.points[element.points.length - 1]; + const currentControlPoints = element.props.controlPoints || []; + + if (currentControlPoints.length < 2) { + const midX = (start.x + end.x) / 2; + const midY = (start.y + end.y) / 2; + const dx = end.x - start.x; + const dy = end.y - start.y; + + const newPoint = currentControlPoints.length === 0 + ? { x: midX - dy * 0.3, y: midY + dx * 0.3 } + : { x: midX + dy * 0.3, y: midY - dx * 0.3 }; + + updateProps({ controlPoints: [...currentControlPoints, newPoint] }); + } + }; + + const removeControlPoint = (index: number) => { + const currentControlPoints = element.props.controlPoints || []; + const newControlPoints = currentControlPoints.filter((_, i) => i !== index); + updateProps({ controlPoints: newControlPoints }); + }; + + const resetControlPoints = () => { + updateProps({ controlPoints: [] }); + }; + return (
{/* Style */}
- -
+ +
+ {/* Control points for curved arrows */} + {element.props.style === 'curved' && ( +
+ +
+
+ + +
+ {(element.props.controlPoints || []).length > 0 && ( +
+ {(element.props.controlPoints || []).map((cp, index) => ( +
+ + Point {index + 1}: ({Math.round(cp.x)}, {Math.round(cp.y)}) + + +
+ ))} +
+ )} +

+ Drag the blue handles on the canvas to adjust the curve shape. +

+
+
+ )} + {/* Color */}
- -
- updateProps({ color: e.target.value })} - className="w-10 h-10 rounded cursor-pointer bg-transparent" - /> + +
+
+ updateProps({ color: e.target.value })} + className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer p-0 m-0 border-none" + /> +
updateProps({ color: e.target.value })} - className="flex-1 bg-neutral-700 text-white px-3 py-2 rounded text-sm" + className="flex-1 bg-transparent text-white text-sm focus:outline-none font-mono" />
{/* Thickness */}
-
)} + + {/* Brand Strip Section */} +
+
+ + +
+ + {background.brandStrip?.enabled && ( +
+ {/* Position */} +
+ +
+ + +
+
+ + {/* Height */} +
+ + setBackground({ + brandStrip: { ...background.brandStrip, height: parseInt(e.target.value) } + })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ + {/* Strip Color */} +
+ +
+
+ setBackground({ + brandStrip: { ...background.brandStrip, color: e.target.value } + })} + className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer" + /> +
+ setBackground({ + brandStrip: { ...background.brandStrip, color: e.target.value } + })} + className="w-full bg-transparent text-white text-xs focus:outline-none font-mono" + /> +
+
+ + {/* Brand Text */} +
+ + setBackground({ + brandStrip: { ...background.brandStrip, text: e.target.value } + })} + placeholder="@yourhandle" + className="w-full bg-white/5 text-white px-3 py-2 rounded-lg text-sm border border-white/5 focus:border-blue-500/50 focus:outline-none" + /> +
+ + {/* Text Color */} +
+ +
+
+ setBackground({ + brandStrip: { ...background.brandStrip, textColor: e.target.value } + })} + className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer" + /> +
+ setBackground({ + brandStrip: { ...background.brandStrip, textColor: e.target.value } + })} + className="w-full bg-transparent text-white text-xs focus:outline-none font-mono" + /> +
+
+ + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+ + setBackground({ + brandStrip: { ...background.brandStrip, fontSize: parseInt(e.target.value) } + })} + className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+
+ )} +
); }; diff --git a/src/components/inspector/CodeInspector.tsx b/src/components/inspector/CodeInspector.tsx index e8ecbc6..727b67c 100644 --- a/src/components/inspector/CodeInspector.tsx +++ b/src/components/inspector/CodeInspector.tsx @@ -3,6 +3,7 @@ import { useCanvasStore } from '../../store/canvasStore'; import type { CodeElement, LineHighlight } from '../../types'; import { LANGUAGES, FONT_FAMILIES, CODE_THEMES } from '../../types'; import { detectLanguage } from '../../utils/highlighter'; +import { loadFont } from '../../utils/fontLoader'; interface CodeInspectorProps { element: CodeElement; @@ -116,18 +117,30 @@ const CodeInspector: React.FC = ({ element }) => { {/* Font */}
- - +
{/* Font size & Line height */} diff --git a/src/components/inspector/TextInspector.tsx b/src/components/inspector/TextInspector.tsx index df4a9f3..fd461c4 100644 --- a/src/components/inspector/TextInspector.tsx +++ b/src/components/inspector/TextInspector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useCanvasStore } from '../../store/canvasStore'; import type { TextElement } from '../../types'; import { FONT_FAMILIES } from '../../types'; +import { loadFont } from '../../utils/fontLoader'; interface TextInspectorProps { element: TextElement; @@ -18,43 +19,59 @@ const TextInspector: React.FC = ({ element }) => { update({ props: { ...element.props, ...props } }); }; + const handleFontChange = (fontFamily: string) => { + loadFont(fontFamily); + updateProps({ fontFamily }); + }; + return (
{/* Text */}
- +