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:
2026-01-07 17:07:55 +02:00
parent 6510dac3bc
commit 84b7a6a80b
12 changed files with 1044 additions and 162 deletions
+298 -53
View File
@@ -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>
);
};