feat: add brand strip customization to BackgroundPanel
- Implemented a new section in BackgroundPanel for configuring a brand strip. - Added options for enabling/disabling the brand strip, setting its position, height, color, text, text color, font family, and font size. - Integrated FONT_FAMILIES for font selection in the brand strip. - Updated canvasStore to include default values for the brand strip. - Enhanced TextInspector and CodeInspector to allow font loading and selection with previews. - Created a FontLoader component to manage font loading from Google Fonts. - Added LayersPanel for managing canvas elements with improved UI and functionality. - Introduced fontLoader utility to handle dynamic font loading.
This commit is contained in:
@@ -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 (
|
||||
<div className="h-screen flex flex-col bg-neutral-900 text-white">
|
||||
<FontLoader />
|
||||
<TopBar stageRef={stageRef} />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LayersPanel />
|
||||
<Toolbar />
|
||||
<Canvas stageRef={stageRef} />
|
||||
<Inspector />
|
||||
|
||||
@@ -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<CanvasProps> = ({ 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 (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const stagePosition = getStagePosition();
|
||||
|
||||
return (
|
||||
@@ -165,6 +200,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
>
|
||||
<Layer>
|
||||
{renderBackground()}
|
||||
{renderBrandStrip()}
|
||||
{renderGrid()}
|
||||
|
||||
{snap.elements.map((element) => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { loadFont } from '../utils/fontLoader';
|
||||
|
||||
interface FontLoaderProps {
|
||||
fonts?: string[];
|
||||
}
|
||||
|
||||
const FontLoader: React.FC<FontLoaderProps> = ({ 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;
|
||||
@@ -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 (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
);
|
||||
case 'arrow':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="w-56 bg-[#09090b] border-r border-white/5 flex flex-col h-full">
|
||||
<div className="p-4 border-b border-white/5">
|
||||
<h3 className="text-white font-semibold text-sm uppercase tracking-wider">Layers</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{elements.length === 0 ? (
|
||||
<div className="text-neutral-500 text-xs text-center py-8">
|
||||
No elements yet.
|
||||
<br />
|
||||
Click on the canvas to add elements.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{elements.map((element) => (
|
||||
<div
|
||||
key={element.id}
|
||||
onClick={() => 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 */}
|
||||
<div className={`shrink-0 ${selectedElementId === element.id ? 'text-blue-400' : 'text-neutral-400'}`}>
|
||||
{getElementIcon(element.type)}
|
||||
</div>
|
||||
|
||||
{/* Element name */}
|
||||
<span className={`flex-1 text-sm truncate ${
|
||||
selectedElementId === element.id ? 'text-white' : 'text-neutral-300'
|
||||
} ${!element.visible ? 'opacity-50' : ''}`}>
|
||||
{getElementLabel(element)}
|
||||
</span>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Lock toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateElement(element.id, { locked: !element.locked });
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.locked ? 'text-yellow-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
title={element.locked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{element.locked ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateElement(element.id, { visible: !element.visible });
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.visible ? 'text-neutral-500 hover:text-neutral-300' : 'text-red-400'
|
||||
}`}
|
||||
title={element.visible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{element.visible ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer actions footer */}
|
||||
{selectedElementId && (
|
||||
<div className="p-3 border-t border-white/5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => moveElementDown(selectedElementId)}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||
title="Move Down (Back)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveElementUp(selectedElementId)}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||
title="Move Up (Front)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteElement(selectedElementId)}
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-neutral-400 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayersPanel;
|
||||
@@ -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<ArrowElement>) => 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<ArrowProps> = ({ element, isSelected, onSelect, onChange }) => {
|
||||
const arrowRef = useRef<Konva.Arrow>(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<DragEvent>) => {
|
||||
const newPoints = [...points];
|
||||
@@ -26,62 +125,208 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
|
||||
onChange({ points: newPoints });
|
||||
};
|
||||
|
||||
const handleControlPointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
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 (
|
||||
<Group>
|
||||
<KonvaArrow
|
||||
ref={arrowRef}
|
||||
points={flatPoints}
|
||||
stroke={props.color}
|
||||
strokeWidth={props.thickness}
|
||||
fill={props.head === 'filled' ? props.color : 'transparent'}
|
||||
pointerLength={pointerLength}
|
||||
pointerWidth={pointerWidth}
|
||||
tension={props.style === 'curved' ? 0.4 : 0}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
// Add subtle glow/shadow for modern feel
|
||||
shadowColor={props.color}
|
||||
shadowBlur={8}
|
||||
shadowOpacity={0.2}
|
||||
shadowOffset={{ x: 0, y: 0 }}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
hitStrokeWidth={20}
|
||||
/>
|
||||
|
||||
{/* Control points when selected */}
|
||||
{isSelected && points.map((point, index) => (
|
||||
<Circle
|
||||
key={index}
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
radius={5}
|
||||
fill="#ffffff"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
shadowColor="rgba(0,0,0,0.15)"
|
||||
shadowBlur={4}
|
||||
shadowOffset={{ x: 0, y: 1 }}
|
||||
draggable={!element.locked}
|
||||
onDragMove={(e) => 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' ? (
|
||||
<Line
|
||||
points={flatPoints}
|
||||
stroke={props.color}
|
||||
strokeWidth={props.thickness}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
shadowColor={props.color}
|
||||
shadowBlur={8}
|
||||
shadowOpacity={0.2}
|
||||
shadowOffset={{ x: 0, y: 0 }}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
hitStrokeWidth={20}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<KonvaArrow
|
||||
ref={arrowRef}
|
||||
points={flatPoints}
|
||||
stroke={props.color}
|
||||
strokeWidth={props.thickness}
|
||||
fill={props.head === 'filled' ? props.color : 'transparent'}
|
||||
pointerLength={pointerLength}
|
||||
pointerWidth={pointerWidth}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
shadowColor={props.color}
|
||||
shadowBlur={8}
|
||||
shadowOpacity={0.2}
|
||||
shadowOffset={{ x: 0, y: 0 }}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
hitStrokeWidth={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Arrow head for curved arrows (drawn separately) */}
|
||||
{props.style === 'curved' && props.head !== 'none' && (
|
||||
<KonvaArrow
|
||||
points={[
|
||||
flatPoints[flatPoints.length - 4] || start.x,
|
||||
flatPoints[flatPoints.length - 3] || start.y,
|
||||
end.x,
|
||||
end.y,
|
||||
]}
|
||||
stroke={props.color}
|
||||
strokeWidth={props.thickness}
|
||||
fill={props.head === 'filled' ? props.color : 'transparent'}
|
||||
pointerLength={pointerLength}
|
||||
pointerWidth={pointerWidth}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Label text */}
|
||||
{props.label && (
|
||||
<Text
|
||||
x={labelPoint.x}
|
||||
y={labelPoint.y - 20}
|
||||
text={props.label}
|
||||
fontSize={14}
|
||||
fill={props.color}
|
||||
fontFamily="Inter, sans-serif"
|
||||
align="center"
|
||||
offsetX={props.label.length * 3.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Control points when selected (for curved arrows) */}
|
||||
{isSelected && props.style === 'curved' && (
|
||||
<>
|
||||
{/* Control point lines */}
|
||||
{controlPoints.map((cp, index) => (
|
||||
<React.Fragment key={`line-${index}`}>
|
||||
<Line
|
||||
points={index === 0 ? [start.x, start.y, cp.x, cp.y] : [controlPoints[index - 1].x, controlPoints[index - 1].y, cp.x, cp.y]}
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
opacity={0.5}
|
||||
/>
|
||||
{index === controlPoints.length - 1 && (
|
||||
<Line
|
||||
points={[cp.x, cp.y, end.x, end.y]}
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Control point handles */}
|
||||
{controlPoints.map((cp, index) => (
|
||||
<Circle
|
||||
key={`cp-${index}`}
|
||||
x={cp.x}
|
||||
y={cp.y}
|
||||
radius={6}
|
||||
fill="#60a5fa"
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
shadowColor="rgba(0,0,0,0.3)"
|
||||
shadowBlur={4}
|
||||
shadowOffset={{ x: 0, y: 1 }}
|
||||
draggable={!element.locked}
|
||||
onDragMove={(e) => 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 */}
|
||||
<Circle
|
||||
x={start.x}
|
||||
y={start.y}
|
||||
radius={5}
|
||||
fill="#ffffff"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
shadowColor="rgba(0,0,0,0.15)"
|
||||
shadowBlur={4}
|
||||
shadowOffset={{ x: 0, y: 1 }}
|
||||
draggable={!element.locked}
|
||||
onDragMove={(e) => 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 */}
|
||||
<Circle
|
||||
x={end.x}
|
||||
y={end.y}
|
||||
radius={5}
|
||||
fill="#ffffff"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
shadowColor="rgba(0,0,0,0.15)"
|
||||
shadowBlur={4}
|
||||
shadowOffset={{ x: 0, y: 1 }}
|
||||
draggable={!element.locked}
|
||||
onDragMove={(e) => 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 });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,28 +17,57 @@ const ArrowInspector: React.FC<ArrowInspectorProps> = ({ 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 (
|
||||
<div className="space-y-4">
|
||||
{/* Style */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Style</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Style</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => updateProps({ style: 'straight' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
element.props.style === 'straight'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Straight
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ style: 'curved' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
element.props.style === 'curved'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Curved
|
||||
@@ -46,35 +75,86 @@ const ArrowInspector: React.FC<ArrowInspectorProps> = ({ element }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control points for curved arrows */}
|
||||
{element.props.style === 'curved' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Control Points ({(element.props.controlPoints || []).length}/2)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addControlPoint}
|
||||
disabled={(element.props.controlPoints || []).length >= 2}
|
||||
className="flex-1 py-2 px-3 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+ Add Point
|
||||
</button>
|
||||
<button
|
||||
onClick={resetControlPoints}
|
||||
disabled={(element.props.controlPoints || []).length === 0}
|
||||
className="py-2 px-3 rounded-lg text-xs font-medium bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{(element.props.controlPoints || []).length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{(element.props.controlPoints || []).map((cp, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-1.5 px-2 bg-white/5 rounded-md">
|
||||
<span className="text-xs text-neutral-400">
|
||||
Point {index + 1}: ({Math.round(cp.x)}, {Math.round(cp.y)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeControlPoint(index)}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
Drag the blue handles on the canvas to adjust the curve shape.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.color}
|
||||
onChange={(e) => updateProps({ color: e.target.value })}
|
||||
className="w-10 h-10 rounded cursor-pointer bg-transparent"
|
||||
/>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Color</label>
|
||||
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="w-8 h-8 rounded overflow-hidden relative border border-white/10 shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.color}
|
||||
onChange={(e) => updateProps({ color: e.target.value })}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer p-0 m-0 border-none"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={element.props.color}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thickness */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Thickness: {element.props.thickness}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
value={element.props.thickness}
|
||||
onChange={(e) => updateProps({ thickness: parseInt(e.target.value) })}
|
||||
className="w-full"
|
||||
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
min={1}
|
||||
max={12}
|
||||
/>
|
||||
@@ -82,34 +162,34 @@ const ArrowInspector: React.FC<ArrowInspectorProps> = ({ element }) => {
|
||||
|
||||
{/* Arrow head */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Arrow Head</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Arrow Head</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => updateProps({ head: 'filled' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
element.props.head === 'filled'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Filled
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ head: 'outline' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
element.props.head === 'outline'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Outline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ head: 'none' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
element.props.head === 'none'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
None
|
||||
@@ -117,11 +197,39 @@ const ArrowInspector: React.FC<ArrowInspectorProps> = ({ element }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points info */}
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Points</label>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Label (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={element.props.label || ''}
|
||||
onChange={(e) => updateProps({ label: e.target.value || undefined })}
|
||||
placeholder="Add a label..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label position */}
|
||||
{element.props.label && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Label Position: {Math.round((element.props.labelPosition || 0.5) * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
value={(element.props.labelPosition || 0.5) * 100}
|
||||
onChange={(e) => updateProps({ labelPosition: parseInt(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Points info */}
|
||||
<div className="pt-2 border-t border-white/5">
|
||||
<p className="text-xs text-neutral-500">
|
||||
Drag the blue handles on the canvas to adjust arrow points.
|
||||
Drag the white handles on the canvas to move the arrow endpoints.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { FONT_FAMILIES } from '../../types';
|
||||
|
||||
const GRADIENT_PRESETS = [
|
||||
{ from: '#101022', to: '#1f1f3a', name: 'Midnight' },
|
||||
@@ -152,6 +153,178 @@ const BackgroundPanel: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Brand Strip Section */}
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">Brand Strip</label>
|
||||
<button
|
||||
onClick={() => setBackground({
|
||||
brandStrip: { ...background.brandStrip, enabled: !background.brandStrip?.enabled }
|
||||
})}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
background.brandStrip?.enabled ? 'bg-blue-600' : 'bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||
background.brandStrip?.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{background.brandStrip?.enabled && (
|
||||
<div className="space-y-4">
|
||||
{/* Position */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Position</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => setBackground({
|
||||
brandStrip: { ...background.brandStrip, position: 'top' }
|
||||
})}
|
||||
className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
background.brandStrip.position === 'top'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBackground({
|
||||
brandStrip: { ...background.brandStrip, position: 'bottom' }
|
||||
})}
|
||||
className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
background.brandStrip.position === 'bottom'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Bottom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Height: {background.brandStrip.height || 60}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="30"
|
||||
max="120"
|
||||
value={background.brandStrip.height || 60}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Strip Color */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Strip Color</label>
|
||||
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="w-6 h-6 rounded overflow-hidden relative border border-white/10 shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={background.brandStrip.color || '#000000'}
|
||||
onChange={(e) => setBackground({
|
||||
brandStrip: { ...background.brandStrip, color: e.target.value }
|
||||
})}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={background.brandStrip.color || '#000000'}
|
||||
onChange={(e) => setBackground({
|
||||
brandStrip: { ...background.brandStrip, color: e.target.value }
|
||||
})}
|
||||
className="w-full bg-transparent text-white text-xs focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Text */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={background.brandStrip.text || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Text Color</label>
|
||||
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="w-6 h-6 rounded overflow-hidden relative border border-white/10 shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={background.brandStrip.textColor || '#ffffff'}
|
||||
onChange={(e) => setBackground({
|
||||
brandStrip: { ...background.brandStrip, textColor: e.target.value }
|
||||
})}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={background.brandStrip.textColor || '#ffffff'}
|
||||
onChange={(e) => setBackground({
|
||||
brandStrip: { ...background.brandStrip, textColor: e.target.value }
|
||||
})}
|
||||
className="w-full bg-transparent text-white text-xs focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Font</label>
|
||||
<select
|
||||
value={background.brandStrip.fontFamily || 'Inter'}
|
||||
onChange={(e) => setBackground({
|
||||
brandStrip: { ...background.brandStrip, fontFamily: e.target.value }
|
||||
})}
|
||||
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"
|
||||
>
|
||||
{FONT_FAMILIES.brand.map((font) => (
|
||||
<option key={font} value={font} style={{ fontFamily: font }}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Font Size: {background.brandStrip.fontSize || 16}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="32"
|
||||
value={background.brandStrip.fontSize || 16}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<CodeInspectorProps> = ({ element }) => {
|
||||
|
||||
{/* Font */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Font</label>
|
||||
<select
|
||||
value={element.props.fontFamily}
|
||||
onChange={(e) => updateProps({ fontFamily: e.target.value })}
|
||||
className="w-full bg-neutral-700 text-white px-3 py-2 rounded text-sm"
|
||||
>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Font Family</label>
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
{FONT_FAMILIES.code.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
<button
|
||||
key={font}
|
||||
onClick={() => {
|
||||
loadFont(font);
|
||||
updateProps({ fontFamily: font });
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm text-left transition-all ${
|
||||
element.props.fontFamily === font
|
||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30'
|
||||
: 'text-neutral-300 hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span style={{ fontFamily: font }}>{font}</span>
|
||||
{element.props.fontFamily === font && (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size & Line height */}
|
||||
|
||||
@@ -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<TextInspectorProps> = ({ element }) => {
|
||||
update({ props: { ...element.props, ...props } });
|
||||
};
|
||||
|
||||
const handleFontChange = (fontFamily: string) => {
|
||||
loadFont(fontFamily);
|
||||
updateProps({ fontFamily });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Text */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Text</label>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Text</label>
|
||||
<textarea
|
||||
value={element.props.text}
|
||||
onChange={(e) => updateProps({ text: e.target.value })}
|
||||
onBlur={saveToHistory}
|
||||
className="w-full h-24 bg-neutral-900 text-white text-sm p-3 rounded resize-y"
|
||||
className="w-full h-24 bg-white/5 text-white text-sm p-3 rounded-lg resize-y border border-white/5 focus:border-blue-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
{/* Font Family with Preview */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Font</label>
|
||||
<select
|
||||
value={element.props.fontFamily}
|
||||
onChange={(e) => updateProps({ fontFamily: e.target.value })}
|
||||
className="w-full bg-neutral-700 text-white px-3 py-2 rounded text-sm"
|
||||
>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Font Family</label>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
{FONT_FAMILIES.text.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
<button
|
||||
key={font}
|
||||
onClick={() => handleFontChange(font)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm text-left transition-all ${
|
||||
element.props.fontFamily === font
|
||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30'
|
||||
: 'text-neutral-300 hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span style={{ fontFamily: font }}>{font}</span>
|
||||
{element.props.fontFamily === font && (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Size: {element.props.fontSize}</label>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
|
||||
Size: {element.props.fontSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
value={element.props.fontSize}
|
||||
onChange={(e) => updateProps({ fontSize: parseInt(e.target.value) })}
|
||||
className="w-full"
|
||||
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
min={12}
|
||||
max={96}
|
||||
/>
|
||||
@@ -62,53 +79,55 @@ const TextInspector: React.FC<TextInspectorProps> = ({ element }) => {
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.color}
|
||||
onChange={(e) => updateProps({ color: e.target.value })}
|
||||
className="w-10 h-10 rounded cursor-pointer bg-transparent"
|
||||
/>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Color</label>
|
||||
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="w-8 h-8 rounded overflow-hidden relative border border-white/10 shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.color}
|
||||
onChange={(e) => updateProps({ color: e.target.value })}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer p-0 m-0 border-none"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={element.props.color}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style buttons */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Style</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Style</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => updateProps({ bold: !element.props.bold })}
|
||||
className={`flex-1 py-2 rounded text-sm font-bold ${
|
||||
className={`flex-1 py-2 rounded-md text-sm font-bold transition-all ${
|
||||
element.props.bold
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ italic: !element.props.italic })}
|
||||
className={`flex-1 py-2 rounded text-sm italic ${
|
||||
className={`flex-1 py-2 rounded-md text-sm italic transition-all ${
|
||||
element.props.italic
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ underline: !element.props.underline })}
|
||||
className={`flex-1 py-2 rounded text-sm underline ${
|
||||
className={`flex-1 py-2 rounded-md text-sm underline transition-all ${
|
||||
element.props.underline
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
U
|
||||
@@ -118,37 +137,43 @@ const TextInspector: React.FC<TextInspectorProps> = ({ element }) => {
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Alignment</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">Alignment</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => updateProps({ align: 'left' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-sm transition-all ${
|
||||
element.props.align === 'left'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Left
|
||||
<svg className="w-4 h-4 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h10M4 18h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ align: 'center' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-sm transition-all ${
|
||||
element.props.align === 'center'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Center
|
||||
<svg className="w-4 h-4 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M7 12h10M5 18h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ align: 'right' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
className={`flex-1 py-2 rounded-md text-sm transition-all ${
|
||||
element.props.align === 'right'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Right
|
||||
<svg className="w-4 h-4 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M10 12h10M6 18h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,37 +181,39 @@ const TextInspector: React.FC<TextInspectorProps> = ({ element }) => {
|
||||
{/* Background */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-neutral-400">Background</label>
|
||||
<label className="text-xs font-medium text-neutral-500 uppercase tracking-wider">Background</label>
|
||||
<button
|
||||
onClick={() => updateProps({
|
||||
background: element.props.background
|
||||
? null
|
||||
: { color: 'rgba(0,0,0,0.5)' }
|
||||
})}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
element.props.background ? 'bg-blue-600' : 'bg-neutral-600'
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
element.props.background ? 'bg-blue-600' : 'bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
element.props.background ? 'translate-x-6' : 'translate-x-0.5'
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||
element.props.background ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{element.props.background && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.background.color.substring(0, 7)}
|
||||
onChange={(e) => updateProps({ background: { color: e.target.value } })}
|
||||
className="w-10 h-10 rounded cursor-pointer bg-transparent"
|
||||
/>
|
||||
<div className="flex gap-2 items-center p-2 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="w-6 h-6 rounded overflow-hidden relative border border-white/10 shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={element.props.background.color.substring(0, 7)}
|
||||
onChange={(e) => updateProps({ background: { color: e.target.value } })}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={element.props.background.color}
|
||||
onChange={(e) => updateProps({ background: { 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-xs focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -64,6 +64,16 @@ const defaultSnap: Snap = {
|
||||
type: 'gradient',
|
||||
solid: { color: '#101022' },
|
||||
gradient: { from: '#101022', to: '#1f1f3a', angle: 135 },
|
||||
brandStrip: {
|
||||
enabled: false,
|
||||
position: 'bottom',
|
||||
height: 60,
|
||||
color: '#000000',
|
||||
text: '',
|
||||
textColor: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
},
|
||||
},
|
||||
elements: [],
|
||||
};
|
||||
|
||||
+35
-2
@@ -10,10 +10,22 @@ export interface GradientBackground {
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface BrandStrip {
|
||||
enabled: boolean;
|
||||
position: 'top' | 'bottom';
|
||||
height: number;
|
||||
color: string;
|
||||
text: string;
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}
|
||||
|
||||
export interface Background {
|
||||
type: BackgroundType;
|
||||
solid: SolidBackground;
|
||||
gradient: GradientBackground;
|
||||
brandStrip: BrandStrip;
|
||||
}
|
||||
|
||||
export interface Shadow {
|
||||
@@ -61,6 +73,9 @@ export interface ArrowProps {
|
||||
color: string;
|
||||
thickness: number;
|
||||
head: 'filled' | 'outline' | 'none';
|
||||
controlPoints?: { x: number; y: number }[]; // For curved arrows - bezier control points
|
||||
label?: string; // Optional text label
|
||||
labelPosition?: number; // 0-1, position along the arrow
|
||||
}
|
||||
|
||||
export type ElementType = 'code' | 'text' | 'arrow';
|
||||
@@ -180,6 +195,24 @@ export const LANGUAGES = [
|
||||
] as const;
|
||||
|
||||
export const FONT_FAMILIES = {
|
||||
code: ['JetBrains Mono', 'Fira Code', 'Source Code Pro', 'monospace'],
|
||||
text: ['Inter', 'Roboto', 'Open Sans', 'sans-serif'],
|
||||
code: ['JetBrains Mono', 'Fira Code', 'Source Code Pro', 'IBM Plex Mono', 'Cascadia Code', 'monospace'],
|
||||
text: ['Inter', 'Roboto', 'Open Sans', 'Poppins', 'Montserrat', 'Lato', 'Nunito', 'Raleway', 'sans-serif'],
|
||||
brand: ['Inter', 'Roboto', 'Poppins', 'Montserrat', 'sans-serif'],
|
||||
};
|
||||
|
||||
// Google Fonts URLs for custom fonts
|
||||
export const GOOGLE_FONTS = [
|
||||
{ name: 'Inter', url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Roboto', url: 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap' },
|
||||
{ name: 'Poppins', url: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Montserrat', url: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Lato', url: 'https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap' },
|
||||
{ name: 'Nunito', url: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap' },
|
||||
{ name: 'Raleway', url: 'https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Open Sans', url: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap' },
|
||||
{ name: 'JetBrains Mono', url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap' },
|
||||
{ name: 'Fira Code', url: 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;700&display=swap' },
|
||||
{ name: 'Source Code Pro', url: 'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;700&display=swap' },
|
||||
{ name: 'IBM Plex Mono', url: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap' },
|
||||
{ name: 'Cascadia Code', url: 'https://fonts.googleapis.com/css2?family=Cascadia+Code&display=swap' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GOOGLE_FONTS } from '../types';
|
||||
|
||||
// Track loaded fonts to avoid duplicate loading
|
||||
const loadedFonts = new Set<string>();
|
||||
|
||||
export const loadFont = (fontName: string) => {
|
||||
if (loadedFonts.has(fontName)) return;
|
||||
|
||||
const fontConfig = GOOGLE_FONTS.find(f => f.name === fontName);
|
||||
if (!fontConfig) return;
|
||||
|
||||
// Check if link already exists
|
||||
const existingLink = document.querySelector(`link[href="${fontConfig.url}"]`);
|
||||
if (existingLink) {
|
||||
loadedFonts.add(fontName);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.href = fontConfig.url;
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
loadedFonts.add(fontName);
|
||||
};
|
||||
|
||||
export const loadAllFonts = () => {
|
||||
GOOGLE_FONTS.forEach(font => loadFont(font.name));
|
||||
};
|
||||
Reference in New Issue
Block a user