feat: add branding watermark feature with customizable options and inspector panel

This commit is contained in:
2026-01-07 18:48:21 +02:00
parent 07a48e67f4
commit 5b5646ed78
5 changed files with 412 additions and 0 deletions
+80
View File
@@ -180,6 +180,85 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
);
}, [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;
// 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);
}
if (branding.showSocial && branding.social) {
const socialParts: string[] = [];
if (branding.social.twitter) socialParts.push(branding.social.twitter);
if (branding.social.linkedin) socialParts.push(branding.social.linkedin);
if (branding.social.instagram) socialParts.push(branding.social.instagram);
if (branding.social.github) socialParts.push(branding.social.github);
if (branding.social.youtube) socialParts.push(branding.social.youtube);
if (branding.social.tiktok) socialParts.push(branding.social.tiktok);
if (socialParts.length > 0) {
lines.push(socialParts.join(' • '));
}
}
if (lines.length === 0) return null;
const text = lines.join('\n');
const textHeight = lines.length * lineHeight;
// 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 - textHeight;
align = 'left';
break;
case 'bottom-right':
x = width - padding;
y = height - padding - textHeight;
align = 'right';
break;
}
return (
<Text
x={align === 'right' ? 0 : x}
y={y}
width={align === 'right' ? x : width - padding}
text={text}
fontSize={fontSize}
fontFamily={branding.fontFamily || 'Inter'}
fill={branding.color || '#ffffff'}
opacity={branding.opacity || 0.8}
align={align}
lineHeight={1.5}
/>
);
}, [background.branding, width, height]);
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
return (
@@ -201,6 +280,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
<Layer>
{renderBackground()}
{brandStripElement}
{brandingElement}
{gridLines}
{snap.elements.map((element) => {
+6
View File
@@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } from 'react';
import { useCanvasStore } from '../store/canvasStore';
import type { CodeElement, TextElement, ArrowElement } from '../types';
import BackgroundPanel from './inspector/BackgroundPanel';
import BrandingPanel from './inspector/BrandingPanel';
import CodeInspector from './inspector/CodeInspector';
import TextInspector from './inspector/TextInspector';
import ArrowInspector from './inspector/ArrowInspector';
@@ -119,6 +120,11 @@ const Inspector: React.FC = () => {
<div className="space-y-6">
<h3 className="text-white font-semibold text-sm uppercase tracking-wider">Canvas Settings</h3>
<BackgroundPanel />
<div className="h-px bg-white/5 w-full" />
<h3 className="text-white font-semibold text-sm uppercase tracking-wider">Branding</h3>
<BrandingPanel />
</div>
)}
</div>
+285
View File
@@ -0,0 +1,285 @@
import React from 'react';
import { useCanvasStore } from '../../store/canvasStore';
import { FONT_FAMILIES } from '../../types';
const POSITION_OPTIONS = [
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' },
] as const;
const SOCIAL_PLATFORMS = [
{ key: 'twitter', label: 'X (Twitter)', placeholder: '@username' },
{ key: 'linkedin', label: 'LinkedIn', placeholder: '/in/username' },
{ key: 'instagram', label: 'Instagram', placeholder: '@username' },
{ key: 'github', label: 'GitHub', placeholder: 'username' },
{ key: 'youtube', label: 'YouTube', placeholder: '@channel' },
{ key: 'tiktok', label: 'TikTok', placeholder: '@username' },
] as const;
const BrandingPanel: React.FC = () => {
const { snap, setBackground } = useCanvasStore();
// Default branding values for backwards compatibility
const defaultBranding = {
enabled: false,
position: 'bottom-right' as const,
name: '',
website: '',
social: {},
showName: true,
showWebsite: true,
showSocial: true,
fontSize: 14,
fontFamily: 'Inter',
color: '#ffffff',
opacity: 0.8,
padding: 24,
};
const branding = snap.background.branding || defaultBranding;
const updateBranding = (updates: Partial<typeof branding>) => {
setBackground({
branding: { ...branding, ...updates },
});
};
const updateSocial = (platform: string, value: string) => {
setBackground({
branding: {
...branding,
social: { ...(branding.social || {}), [platform]: value },
},
});
};
return (
<div className="space-y-6">
{/* Enable Branding */}
<div className="flex items-center justify-between">
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
Branding Watermark
</label>
<button
onClick={() => updateBranding({ enabled: !branding.enabled })}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
branding.enabled ? 'bg-blue-600' : 'bg-white/10'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
branding.enabled ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</div>
{branding.enabled && (
<div className="space-y-5">
{/* Position */}
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">
Position
</label>
<div className="grid grid-cols-2 gap-2">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => updateBranding({ position: opt.value })}
className={`py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
branding.position === opt.value
? 'bg-blue-600/20 text-blue-400 border-blue-500/50'
: 'bg-white/5 text-neutral-400 hover:text-white hover:bg-white/10 border-white/5'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
Name
</label>
<button
onClick={() => updateBranding({ showName: !branding.showName })}
className={`text-xs px-2 py-0.5 rounded ${
branding.showName
? 'bg-blue-600/20 text-blue-400'
: 'bg-white/5 text-neutral-500'
}`}
>
{branding.showName ? 'Show' : 'Hide'}
</button>
</div>
<input
type="text"
value={branding.name}
onChange={(e) => updateBranding({ name: e.target.value })}
placeholder="Your Name"
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>
{/* Website */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
Website
</label>
<button
onClick={() => updateBranding({ showWebsite: !branding.showWebsite })}
className={`text-xs px-2 py-0.5 rounded ${
branding.showWebsite
? 'bg-blue-600/20 text-blue-400'
: 'bg-white/5 text-neutral-500'
}`}
>
{branding.showWebsite ? 'Show' : 'Hide'}
</button>
</div>
<input
type="text"
value={branding.website}
onChange={(e) => updateBranding({ website: e.target.value })}
placeholder="yourwebsite.com"
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>
{/* Social Media */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
Social Media
</label>
<button
onClick={() => updateBranding({ showSocial: !branding.showSocial })}
className={`text-xs px-2 py-0.5 rounded ${
branding.showSocial
? 'bg-blue-600/20 text-blue-400'
: 'bg-white/5 text-neutral-500'
}`}
>
{branding.showSocial ? 'Show' : 'Hide'}
</button>
</div>
<div className="space-y-2">
{SOCIAL_PLATFORMS.map((platform) => (
<div key={platform.key} className="flex items-center gap-2">
<span className="text-xs text-neutral-500 w-20 shrink-0">
{platform.label}
</span>
<input
type="text"
value={branding.social[platform.key as keyof typeof branding.social] || ''}
onChange={(e) => updateSocial(platform.key, e.target.value)}
placeholder={platform.placeholder}
className="flex-1 bg-white/5 text-white px-2 py-1.5 rounded-md text-xs border border-white/5 focus:border-blue-500/50 focus:outline-none"
/>
</div>
))}
</div>
</div>
{/* Styling */}
<div className="pt-4 border-t border-white/5">
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-3">
Styling
</label>
{/* Font Family */}
<div className="mb-4">
<label className="block text-xs text-neutral-500 mb-2">Font</label>
<select
value={branding.fontFamily}
onChange={(e) => updateBranding({ 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.text.map((font) => (
<option key={font} value={font} style={{ fontFamily: font }}>
{font}
</option>
))}
</select>
</div>
{/* Font Size */}
<div className="mb-4">
<label className="block text-xs text-neutral-500 mb-2">
Font Size: {branding.fontSize}px
</label>
<input
type="range"
min="10"
max="24"
value={branding.fontSize}
onChange={(e) => updateBranding({ fontSize: parseInt(e.target.value) })}
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* Color */}
<div className="mb-4">
<label className="block text-xs text-neutral-500 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-6 h-6 rounded overflow-hidden relative border border-white/10 shrink-0">
<input
type="color"
value={branding.color}
onChange={(e) => updateBranding({ color: e.target.value })}
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
/>
</div>
<input
type="text"
value={branding.color}
onChange={(e) => updateBranding({ color: e.target.value })}
className="w-full bg-transparent text-white text-xs focus:outline-none font-mono"
/>
</div>
</div>
{/* Opacity */}
<div className="mb-4">
<label className="block text-xs text-neutral-500 mb-2">
Opacity: {Math.round(branding.opacity * 100)}%
</label>
<input
type="range"
min="0.1"
max="1"
step="0.05"
value={branding.opacity}
onChange={(e) => updateBranding({ opacity: parseFloat(e.target.value) })}
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* Padding */}
<div>
<label className="block text-xs text-neutral-500 mb-2">
Padding: {branding.padding}px
</label>
<input
type="range"
min="8"
max="48"
value={branding.padding}
onChange={(e) => updateBranding({ padding: 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>
);
};
export default BrandingPanel;
+15
View File
@@ -74,6 +74,21 @@ const defaultSnap: Snap = {
fontSize: 16,
fontFamily: 'Inter',
},
branding: {
enabled: false,
position: 'bottom-right',
name: '',
website: '',
social: {},
showName: true,
showWebsite: true,
showSocial: true,
fontSize: 14,
fontFamily: 'Inter',
color: '#ffffff',
opacity: 0.8,
padding: 24,
},
},
elements: [],
};
+26
View File
@@ -10,6 +10,31 @@ export interface GradientBackground {
angle: number;
}
export interface SocialMedia {
twitter?: string;
linkedin?: string;
instagram?: string;
github?: string;
youtube?: string;
tiktok?: string;
}
export interface Branding {
enabled: boolean;
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
name: string;
website: string;
social: SocialMedia;
showName: boolean;
showWebsite: boolean;
showSocial: boolean;
fontSize: number;
fontFamily: string;
color: string;
opacity: number;
padding: number;
}
export interface BrandStrip {
enabled: boolean;
position: 'top' | 'bottom';
@@ -26,6 +51,7 @@ export interface Background {
solid: SolidBackground;
gradient: GradientBackground;
brandStrip: BrandStrip;
branding: Branding;
}
export interface Shadow {