Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 403aae6f77 | |||
| 254887ada8 | |||
| 5588fdb989 | |||
| b9da4dd56b | |||
| 7dbba818db | |||
| 5b5646ed78 | |||
| 07a48e67f4 | |||
| d642b7b5da | |||
| 955d54d2f2 | |||
| 84b7a6a80b |
@@ -1,73 +1,53 @@
|
||||
# React + TypeScript + Vite
|
||||
# YvCode
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
A lightweight code-canvas editor to create export-ready images for social media (code blocks, text, arrows, backgrounds, and branding).
|
||||
|
||||
Currently, two official plugins are available:
|
||||

|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
## Features
|
||||
|
||||
## React Compiler
|
||||
- Canvas editor built on Konva (move/resize/rotate elements)
|
||||
- Elements: Code block, Text, Arrow
|
||||
- Backgrounds: solid + gradient, optional brand strip
|
||||
- Branding overlay (name/title/avatar + social handles)
|
||||
- Layers panel (reorder, lock, hide/show)
|
||||
- Export image: PNG or JPEG (2x)
|
||||
- Project files: export/import as JSON
|
||||
- Recent projects list (local)
|
||||
- Undo/Redo + keyboard shortcuts
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
## Keyboard shortcuts
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
- New canvas: `⌘N`
|
||||
- Open project JSON: `⌘O`
|
||||
- Export project JSON: `⌘S`
|
||||
- Undo / Redo: `⌘Z` / `⇧⌘Z`
|
||||
- Duplicate selected: `⌘D`
|
||||
- Delete selected: `Backspace` / `Delete`
|
||||
- Zoom in/out: `⌘+` / `⌘-`
|
||||
- Toggle grid: `⌘;`
|
||||
- Tools: Select `V`, Code `C`, Text `T`, Arrow `A`
|
||||
- Deselect: `Esc`
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
## Getting started
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
Requirements: Node.js (recommended: latest LTS)
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
Then open the URL printed by Vite.
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
## Scripts
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
- `npm run dev` — start the dev server
|
||||
- `npm run build` — typecheck and build for production
|
||||
- `npm run preview` — preview the production build
|
||||
- `npm run lint` — run ESLint
|
||||
|
||||
## Docs
|
||||
|
||||
- Product spec: [docs/spec.md](docs/spec.md)
|
||||
- UI design notes: [docs/design.md](docs/design.md)
|
||||
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
# UI Design Improvement Guide for YvCode
|
||||
|
||||
> A comprehensive analysis and recommendations to modernize and enhance the user interface of YvCode - a lightweight code canvas for social media images.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State Analysis
|
||||
|
||||
### Strengths
|
||||
- ✅ Clean dark theme foundation with good contrast
|
||||
- ✅ Modern glassmorphism effects on the toolbar
|
||||
- ✅ Well-organized component structure (TopBar, Layers, Canvas, Inspector)
|
||||
- ✅ Good use of subtle borders (`border-white/5`) and backgrounds (`bg-white/5`)
|
||||
- ✅ Proper iconography with consistent stroke-based SVGs
|
||||
- ✅ Keyboard shortcuts implemented throughout
|
||||
|
||||
### Areas for Improvement
|
||||
- ⚠️ Limited visual hierarchy and depth
|
||||
- ⚠️ Inconsistent spacing and component sizing
|
||||
- ⚠️ Missing micro-interactions and animations
|
||||
- ⚠️ Outdated form control styling
|
||||
- ⚠️ No onboarding or empty states
|
||||
- ⚠️ Missing visual feedback for user actions
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System Recommendations
|
||||
|
||||
### 1. Color Palette Enhancement
|
||||
|
||||
**Current:** Basic neutral grays with blue accents
|
||||
|
||||
**Recommended:** Expanded semantic color palette
|
||||
|
||||
```css
|
||||
/* Primary Colors */
|
||||
--primary-50: #eff6ff;
|
||||
--primary-100: #dbeafe;
|
||||
--primary-400: #60a5fa;
|
||||
--primary-500: #3b82f6;
|
||||
--primary-600: #2563eb;
|
||||
|
||||
/* Surface Colors (for layered depth) */
|
||||
--surface-0: #09090b; /* Deepest - sidebars */
|
||||
--surface-1: #0f0f12; /* Base canvas area */
|
||||
--surface-2: #18181b; /* Elevated cards */
|
||||
--surface-3: #27272a; /* Interactive elements */
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #06b6d4;
|
||||
|
||||
/* Glass Effects */
|
||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.08);
|
||||
```
|
||||
|
||||
### 2. Typography Scale
|
||||
|
||||
**Recommended Font System:**
|
||||
|
||||
```css
|
||||
/* Headings */
|
||||
--text-2xl: 1.5rem; /* Panel titles */
|
||||
--text-xl: 1.25rem; /* Section headers */
|
||||
--text-lg: 1.125rem; /* Subheadings */
|
||||
|
||||
/* Body */
|
||||
--text-base: 0.875rem; /* Default UI text */
|
||||
--text-sm: 0.8125rem; /* Secondary text */
|
||||
--text-xs: 0.75rem; /* Labels, hints */
|
||||
|
||||
/* Line Heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
```
|
||||
|
||||
### 3. Spacing System
|
||||
|
||||
Adopt an 8px grid system for consistent spacing:
|
||||
|
||||
```css
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Component-Specific Improvements
|
||||
|
||||
### TopBar
|
||||
|
||||
**Current Issues:**
|
||||
- Fixed positioning with `h-16` may feel cramped
|
||||
- Logo and title area lacks breathing room
|
||||
- Export dropdown uses hover instead of click
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Increase Height & Visual Weight**
|
||||
```tsx
|
||||
// Change from h-16 to h-14 with better internal spacing
|
||||
<div className="h-14 bg-surface-0/80 backdrop-blur-xl border-b border-white/[0.04]">
|
||||
```
|
||||
|
||||
2. **Add Breadcrumb Navigation**
|
||||
```tsx
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-neutral-500">Projects</span>
|
||||
<ChevronRight className="w-3 h-3 text-neutral-600" />
|
||||
<span className="text-white font-medium">{snap.meta.title}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Convert Export to Click-Triggered Dropdown with Animation**
|
||||
- Use Radix UI or Headless UI for accessible dropdowns
|
||||
- Add scale and fade animations on open/close
|
||||
|
||||
4. **Add Status Indicator**
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs text-neutral-500">Auto-saved</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Toolbar (Floating)
|
||||
|
||||
**Current Issues:**
|
||||
- Good glassmorphism base but could be more refined
|
||||
- Tool icons lack visual distinction when inactive
|
||||
- Zoom controls feel disconnected
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Enhanced Glass Effect**
|
||||
```tsx
|
||||
<div className="
|
||||
absolute bottom-6 left-1/2 -translate-x-1/2
|
||||
flex items-center gap-3 px-4 py-3
|
||||
rounded-2xl
|
||||
bg-gradient-to-b from-white/[0.08] to-white/[0.04]
|
||||
backdrop-blur-2xl
|
||||
border border-white/[0.08]
|
||||
shadow-[0_8px_32px_rgba(0,0,0,0.4),_inset_0_1px_0_rgba(255,255,255,0.1)]
|
||||
">
|
||||
```
|
||||
|
||||
2. **Animated Tool Selection**
|
||||
```tsx
|
||||
// Add spring animation for tool selection indicator
|
||||
<motion.div
|
||||
layoutId="tool-indicator"
|
||||
className="absolute inset-0 bg-primary-500 rounded-xl"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
```
|
||||
|
||||
3. **Tooltips with Shortcuts**
|
||||
- Add tooltips that show on hover with keyboard shortcut badges
|
||||
- Use consistent tooltip styling across the app
|
||||
|
||||
4. **Group Visual Separators**
|
||||
- Use subtle vertical dividers with gradient fade
|
||||
|
||||
### Layers Panel
|
||||
|
||||
**Current Issues:**
|
||||
- Minimal visual hierarchy
|
||||
- Layer items lack drag handles or reordering indication
|
||||
- Empty state is too plain
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Add Drag & Drop Reordering**
|
||||
- Use `@dnd-kit/sortable` for accessible drag-and-drop
|
||||
- Add ghost element preview during drag
|
||||
- Show insertion indicator line
|
||||
|
||||
2. **Enhanced Layer Item Design**
|
||||
```tsx
|
||||
<div className="
|
||||
group relative
|
||||
flex items-center gap-3 px-3 py-2.5
|
||||
rounded-lg
|
||||
bg-gradient-to-r from-transparent to-transparent
|
||||
hover:from-white/[0.03] hover:to-transparent
|
||||
border border-transparent
|
||||
hover:border-white/[0.06]
|
||||
cursor-pointer
|
||||
transition-all duration-200
|
||||
">
|
||||
{/* Drag handle - visible on hover */}
|
||||
<div className="
|
||||
opacity-0 group-hover:opacity-100
|
||||
cursor-grab active:cursor-grabbing
|
||||
text-neutral-600 hover:text-neutral-400
|
||||
">
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
{/* Layer thumbnail preview */}
|
||||
<div className="w-8 h-8 rounded bg-white/5 flex items-center justify-center">
|
||||
{/* Mini preview of element */}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-neutral-200 truncate block">
|
||||
{element.name}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{element.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Beautiful Empty State**
|
||||
```tsx
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-white/[0.06] to-white/[0.02] flex items-center justify-center mb-4">
|
||||
<Layers className="w-8 h-8 text-neutral-600" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-neutral-400 mb-1">No layers yet</h4>
|
||||
<p className="text-xs text-neutral-600 text-center">
|
||||
Click on the canvas or use<br />
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 text-neutral-400">C</kbd>
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 text-neutral-400 mx-1">T</kbd>
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white/5 text-neutral-400">A</kbd>
|
||||
to add elements
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Inspector Panel
|
||||
|
||||
**Current Issues:**
|
||||
- Dense information without clear sections
|
||||
- Form controls need modernization
|
||||
- No collapsible sections
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Collapsible Accordion Sections**
|
||||
```tsx
|
||||
<Accordion type="multiple" defaultValue={['appearance', 'style']}>
|
||||
<AccordionItem value="appearance">
|
||||
<AccordionTrigger className="
|
||||
flex items-center justify-between w-full py-3
|
||||
text-xs font-semibold uppercase tracking-wider text-neutral-500
|
||||
hover:text-neutral-300
|
||||
">
|
||||
Appearance
|
||||
<ChevronDown className="w-4 h-4 transition-transform duration-200" />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{/* Content */}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
2. **Modern Input Fields**
|
||||
```tsx
|
||||
// Enhanced number input with stepper
|
||||
<div className="
|
||||
flex items-center
|
||||
bg-white/[0.03] hover:bg-white/[0.05]
|
||||
border border-white/[0.06] hover:border-white/[0.1]
|
||||
rounded-lg
|
||||
overflow-hidden
|
||||
focus-within:ring-2 focus-within:ring-primary-500/30 focus-within:border-primary-500/50
|
||||
transition-all
|
||||
">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 bg-transparent px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
<div className="flex flex-col border-l border-white/[0.06]">
|
||||
<button className="px-2 py-1 hover:bg-white/[0.05]">
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="px-2 py-1 hover:bg-white/[0.05] border-t border-white/[0.06]">
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Enhanced Color Picker**
|
||||
```tsx
|
||||
// Modern color picker with swatches and opacity
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="
|
||||
relative w-10 h-10 rounded-lg overflow-hidden
|
||||
border-2 border-white/10
|
||||
shadow-lg shadow-black/20
|
||||
">
|
||||
{/* Checkerboard background for transparency */}
|
||||
<div className="absolute inset-0 bg-checkerboard" />
|
||||
<input type="color" className="absolute inset-0 w-full h-full cursor-pointer opacity-0" />
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-lg px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent colors */}
|
||||
<div className="flex gap-1.5">
|
||||
{recentColors.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
className="w-6 h-6 rounded-md border border-white/10 hover:scale-110 transition-transform"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
4. **Slider with Value Display**
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-xs font-medium text-neutral-500">Border Radius</label>
|
||||
<span className="text-xs text-neutral-400 tabular-nums">{value}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="
|
||||
w-full h-1.5 rounded-full
|
||||
bg-white/[0.06]
|
||||
appearance-none
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-4
|
||||
[&::-webkit-slider-thumb]:h-4
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:shadow-lg
|
||||
[&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-webkit-slider-thumb]:hover:scale-110
|
||||
[&::-webkit-slider-thumb]:transition-transform
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Canvas Area
|
||||
|
||||
**Current Issues:**
|
||||
- Basic checkered or solid background
|
||||
- No visual indicators for safe zones
|
||||
- Selection handles could be more refined
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Subtle Canvas Background Pattern**
|
||||
```tsx
|
||||
// Add subtle dot grid pattern to canvas workspace
|
||||
<div className="
|
||||
absolute inset-0
|
||||
bg-[radial-gradient(circle_at_1px_1px,_rgba(255,255,255,0.03)_1px,_transparent_1px)]
|
||||
bg-[size:24px_24px]
|
||||
" />
|
||||
```
|
||||
|
||||
2. **Enhanced Selection Handles**
|
||||
```tsx
|
||||
// Modern corner handles
|
||||
<div className="
|
||||
absolute w-3 h-3
|
||||
bg-white
|
||||
rounded-full
|
||||
border-2 border-primary-500
|
||||
shadow-lg shadow-primary-500/30
|
||||
cursor-nwse-resize
|
||||
hover:scale-125
|
||||
transition-transform
|
||||
" />
|
||||
```
|
||||
|
||||
3. **Canvas Info Badge**
|
||||
```tsx
|
||||
// Show canvas size in corner
|
||||
<div className="
|
||||
absolute bottom-4 right-4
|
||||
px-2 py-1
|
||||
bg-black/50 backdrop-blur-sm
|
||||
rounded-md
|
||||
text-xs text-neutral-400
|
||||
font-mono
|
||||
">
|
||||
{width} × {height}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Micro-Interactions & Animations
|
||||
|
||||
### 1. Button Press Effects
|
||||
|
||||
```tsx
|
||||
// Add satisfying press feedback
|
||||
<button className="
|
||||
transform
|
||||
active:scale-95
|
||||
transition-transform duration-75
|
||||
">
|
||||
```
|
||||
|
||||
### 2. Panel Transitions
|
||||
|
||||
```tsx
|
||||
// Smooth panel content transitions
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
```
|
||||
|
||||
### 3. Loading States
|
||||
|
||||
```tsx
|
||||
// Skeleton loading for async operations
|
||||
<div className="
|
||||
animate-pulse
|
||||
bg-gradient-to-r from-white/[0.03] via-white/[0.06] to-white/[0.03]
|
||||
bg-[length:200%_100%]
|
||||
animate-shimmer
|
||||
" />
|
||||
```
|
||||
|
||||
### 4. Success Feedback
|
||||
|
||||
```tsx
|
||||
// Toast notification for exports
|
||||
<div className="
|
||||
fixed bottom-6 right-6
|
||||
flex items-center gap-3
|
||||
px-4 py-3
|
||||
bg-green-500/10 backdrop-blur-xl
|
||||
border border-green-500/20
|
||||
rounded-xl
|
||||
shadow-2xl
|
||||
">
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
<span className="text-sm text-green-200">Image exported successfully!</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Feature Enhancements
|
||||
|
||||
### 1. Onboarding Experience
|
||||
|
||||
**First-time user tooltip tour:**
|
||||
- Highlight each major area with spotlight effect
|
||||
- Short, contextual tips
|
||||
- Skip option
|
||||
|
||||
```tsx
|
||||
<div className="
|
||||
absolute inset-0
|
||||
bg-black/60 backdrop-blur-sm
|
||||
z-50
|
||||
">
|
||||
{/* Spotlight cutout */}
|
||||
<div className="
|
||||
absolute
|
||||
w-64 h-16
|
||||
bg-transparent
|
||||
rounded-2xl
|
||||
ring-4 ring-primary-500
|
||||
shadow-[0_0_0_9999px_rgba(0,0,0,0.6)]
|
||||
" style={{ top: 0, left: 100 }}>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute top-full mt-4 left-0 w-64 p-4 bg-surface-2 rounded-xl">
|
||||
<h4 className="font-semibold text-white mb-1">Create & Export</h4>
|
||||
<p className="text-sm text-neutral-400">Start a new project or export your creation in multiple formats.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Command Palette (⌘K)
|
||||
|
||||
Add a spotlight-style command palette for power users:
|
||||
|
||||
```tsx
|
||||
<div className="
|
||||
fixed inset-0
|
||||
flex items-start justify-center
|
||||
pt-[20vh]
|
||||
bg-black/50 backdrop-blur-sm
|
||||
z-50
|
||||
">
|
||||
<div className="
|
||||
w-full max-w-lg
|
||||
bg-surface-1
|
||||
border border-white/10
|
||||
rounded-2xl
|
||||
shadow-2xl
|
||||
overflow-hidden
|
||||
">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type a command or search..."
|
||||
className="w-full px-5 py-4 bg-transparent text-white placeholder-neutral-500 outline-none"
|
||||
/>
|
||||
<div className="border-t border-white/5 max-h-80 overflow-auto">
|
||||
{/* Command results */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Quick Actions Contextual Menu
|
||||
|
||||
Right-click context menus with modern styling:
|
||||
|
||||
```tsx
|
||||
<div className="
|
||||
min-w-[200px]
|
||||
bg-surface-2/95 backdrop-blur-xl
|
||||
border border-white/10
|
||||
rounded-xl
|
||||
shadow-2xl
|
||||
py-1
|
||||
overflow-hidden
|
||||
">
|
||||
<button className="
|
||||
w-full px-3 py-2
|
||||
flex items-center gap-3
|
||||
text-sm text-neutral-300
|
||||
hover:bg-white/5 hover:text-white
|
||||
transition-colors
|
||||
">
|
||||
<Copy className="w-4 h-4" />
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-neutral-500">⌘D</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Preview Mode
|
||||
|
||||
Full-screen distraction-free preview:
|
||||
|
||||
```tsx
|
||||
<div className="
|
||||
fixed inset-0
|
||||
bg-black
|
||||
flex items-center justify-center
|
||||
z-50
|
||||
">
|
||||
<button className="
|
||||
absolute top-6 right-6
|
||||
p-2
|
||||
rounded-full
|
||||
bg-white/10 hover:bg-white/20
|
||||
text-white
|
||||
transition-colors
|
||||
">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Canvas preview at actual size */}
|
||||
<div className="shadow-2xl rounded-lg overflow-hidden">
|
||||
{/* Rendered canvas */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Considerations
|
||||
|
||||
### Breakpoint Strategy
|
||||
|
||||
```css
|
||||
/* Collapse side panels to bottom sheets on tablet */
|
||||
@media (max-width: 1024px) {
|
||||
.layers-panel,
|
||||
.inspector-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40vh;
|
||||
border-radius: 24px 24px 0 0;
|
||||
transform: translateY(calc(100% - 48px));
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide panels completely on mobile - show as modals */
|
||||
@media (max-width: 768px) {
|
||||
.side-panels {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
1. ✅ Update color palette and CSS variables
|
||||
2. ✅ Enhance button/input hover states
|
||||
3. ✅ Add micro-animations (scale, transitions)
|
||||
4. ✅ Improve empty states
|
||||
|
||||
### Phase 2: Component Polish (3-5 days)
|
||||
1. 🔲 Redesign Inspector panel with collapsible sections
|
||||
2. 🔲 Enhance Layers panel with drag-and-drop
|
||||
3. 🔲 Modernize all form controls
|
||||
4. 🔲 Add toast notifications
|
||||
|
||||
### Phase 3: Advanced Features (1-2 weeks)
|
||||
1. 🔲 Command palette (⌘K)
|
||||
2. 🔲 Onboarding tour
|
||||
3. 🔲 Context menus
|
||||
4. 🔲 Preview mode
|
||||
5. 🔲 Responsive adaptations
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Recommended Libraries
|
||||
|
||||
| Purpose | Library | Why |
|
||||
|---------|---------|-----|
|
||||
| Animations | `framer-motion` | Smooth, spring-based animations |
|
||||
| UI Primitives | `@radix-ui/react-*` | Accessible, unstyled components |
|
||||
| Drag & Drop | `@dnd-kit/core` | Modern, accessible DnD |
|
||||
| Icons | `lucide-react` | Consistent, customizable icons |
|
||||
| Tooltips | `@radix-ui/react-tooltip` | Accessible tooltips |
|
||||
| Toasts | `sonner` | Beautiful toast notifications |
|
||||
| Command Palette | `cmdk` | ⌘K style command menu |
|
||||
|
||||
---
|
||||
|
||||
## 📐 Visual Reference
|
||||
|
||||
### Inspiration Sources
|
||||
- [Figma](https://figma.com) - Panel organization, inspector design
|
||||
- [Linear](https://linear.app) - Command palette, animations
|
||||
- [Raycast](https://raycast.com) - Glass effects, dark theme
|
||||
- [Arc Browser](https://arc.net) - Sidebar design, animations
|
||||
- [Vercel Dashboard](https://vercel.com) - Cards, buttons, clean typography
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The current YvCode UI has a solid foundation with its dark theme and component organization. The key improvements focus on:
|
||||
|
||||
1. **Visual Polish** - Enhanced glass effects, refined colors, better shadows
|
||||
2. **Interaction Design** - Micro-animations, better feedback, modern controls
|
||||
3. **Information Architecture** - Collapsible sections, better empty states, contextual help
|
||||
4. **Power User Features** - Command palette, keyboard shortcuts display, context menus
|
||||
|
||||
Implementing these changes will transform YvCode into a professional-grade design tool that feels modern, responsive, and delightful to use.
|
||||
Generated
+26
@@ -16,6 +16,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-konva": "^19.2.1",
|
||||
"react-konva-utils": "^2.0.0",
|
||||
"shiki": "^3.21.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -3302,6 +3303,21 @@
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-konva-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-konva-utils/-/react-konva-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-pOb+TF13gFAjfPmUqsE42J4GJ+xhUS97qS32p0NRTqSeqtamWyKJikGa1XeVvV5yItu9SWDo7onL79GGPG96HQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-image": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"konva": "^8.3.5 || ^9.0.0 || ^10.0.0",
|
||||
"react": "^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || ^19.0.0",
|
||||
"react-konva": "^18.2.14 || ^19.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
@@ -3745,6 +3761,16 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-image": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.4.tgz",
|
||||
"integrity": "sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-konva": "^19.2.1",
|
||||
"react-konva-utils": "^2.0.0",
|
||||
"shiki": "^3.21.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 652 KiB |
+79
-2
@@ -1,14 +1,18 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import type Konva from 'konva';
|
||||
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';
|
||||
import { useRecentSnapsStore } from './store/recentSnapsStore';
|
||||
|
||||
function App() {
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const {
|
||||
snap,
|
||||
deleteElement,
|
||||
duplicateElement,
|
||||
selectedElementId,
|
||||
@@ -20,13 +24,84 @@ function App() {
|
||||
showGrid,
|
||||
setTool,
|
||||
selectElement,
|
||||
newSnap,
|
||||
importSnap,
|
||||
exportSnap,
|
||||
saveToHistory,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { addRecentSnap } = useRecentSnapsStore();
|
||||
|
||||
// Handle file import
|
||||
const handleImportFile = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// Save current snap to recent before importing
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
saveToHistory();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const json = evt.target?.result as string;
|
||||
importSnap(json);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [snap, addRecentSnap, saveToHistory, importSnap]);
|
||||
|
||||
// Handle file export
|
||||
const handleExportFile = useCallback(() => {
|
||||
const json = exportSnap();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = `${snap.meta.title || 'canvas'}.json`;
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addRecentSnap(snap);
|
||||
}, [exportSnap, snap, addRecentSnap]);
|
||||
|
||||
// Handle new snap
|
||||
const handleNewSnap = useCallback(() => {
|
||||
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
|
||||
}
|
||||
}, [snap, addRecentSnap, newSnap]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
|
||||
// New snap (⌘N)
|
||||
if (isMeta && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
handleNewSnap();
|
||||
}
|
||||
|
||||
// Open file (⌘O)
|
||||
if (isMeta && e.key === 'o') {
|
||||
e.preventDefault();
|
||||
handleImportFile();
|
||||
}
|
||||
|
||||
// Save/Export (⌘S)
|
||||
if (isMeta && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleExportFile();
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
if (selectedElementId && !isInputFocused()) {
|
||||
@@ -101,12 +176,14 @@ function App() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement]);
|
||||
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement, handleNewSnap, handleImportFile, handleExportFile]);
|
||||
|
||||
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 />
|
||||
|
||||
+279
-8
@@ -1,10 +1,11 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { Stage, Layer, Rect, Line } from 'react-konva';
|
||||
import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { Stage, Layer, Rect, Line, Text, Group, Path, Circle } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
||||
import CodeBlock from './elements/CodeBlock';
|
||||
import TextBlock from './elements/TextBlock';
|
||||
import Arrow from './elements/Arrow';
|
||||
import { SOCIAL_ICON_PATHS, SOCIAL_PLATFORMS_CONFIG } from './elements/SocialIcons';
|
||||
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
||||
|
||||
interface CanvasProps {
|
||||
@@ -27,6 +28,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
|
||||
const { width, height } = snap.meta;
|
||||
const { background } = snap;
|
||||
const [brandingAvatar, setBrandingAvatar] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
@@ -44,6 +46,25 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const url = background.branding?.avatarUrl || '';
|
||||
if (!url) {
|
||||
setBrandingAvatar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => setBrandingAvatar(img);
|
||||
img.onerror = () => setBrandingAvatar(null);
|
||||
img.src = url;
|
||||
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [background.branding?.avatarUrl]);
|
||||
|
||||
// Calculate stage position to center the canvas
|
||||
const getStagePosition = useCallback(() => {
|
||||
const scaledWidth = width * zoom;
|
||||
@@ -112,8 +133,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Grid overlay
|
||||
const renderGrid = () => {
|
||||
// Grid overlay - memoized for performance
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return null;
|
||||
const gridSize = 50;
|
||||
const lines = [];
|
||||
@@ -143,9 +164,257 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
}
|
||||
|
||||
return <>{lines}</>;
|
||||
};
|
||||
}, [showGrid, width, height]);
|
||||
|
||||
const stagePosition = getStagePosition();
|
||||
// Brand strip - memoized
|
||||
const brandStripElement = useMemo(() => {
|
||||
const brandStrip = background.brandStrip;
|
||||
if (!brandStrip?.enabled) return null;
|
||||
|
||||
const stripHeight = brandStrip.height || 60;
|
||||
const stripY = brandStrip.position === 'top' ? 0 : height - stripHeight;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rect
|
||||
x={0}
|
||||
y={stripY}
|
||||
width={width}
|
||||
height={stripHeight}
|
||||
fill={brandStrip.color || '#000000'}
|
||||
/>
|
||||
{brandStrip.text && (
|
||||
<Text
|
||||
x={0}
|
||||
y={stripY}
|
||||
width={width}
|
||||
height={stripHeight}
|
||||
text={brandStrip.text}
|
||||
fontSize={brandStrip.fontSize || 16}
|
||||
fontFamily={brandStrip.fontFamily || 'Inter'}
|
||||
fill={brandStrip.textColor || '#ffffff'}
|
||||
align="center"
|
||||
verticalAlign="middle"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [background.brandStrip, width, height]);
|
||||
|
||||
// Branding watermark - memoized
|
||||
const brandingElement = useMemo(() => {
|
||||
const branding = background.branding;
|
||||
if (!branding?.enabled) return null;
|
||||
|
||||
const padding = branding.padding || 24;
|
||||
const fontSize = branding.fontSize || 14;
|
||||
const lineHeight = fontSize * 1.5;
|
||||
const iconSize = branding.socialIconSize || 20;
|
||||
const socialLayout = branding.socialLayout || 'horizontal';
|
||||
const iconGap = 16;
|
||||
const iconTextGap = 6; // Gap between icon and its text
|
||||
const avatarSize = branding.avatarSize || 56;
|
||||
const avatarGap = 14;
|
||||
const hasAvatar = Boolean(branding.showAvatar && branding.avatarUrl && brandingAvatar);
|
||||
|
||||
// Build branding text lines
|
||||
const lines: string[] = [];
|
||||
if (branding.showName && branding.name) {
|
||||
lines.push(branding.name);
|
||||
}
|
||||
if (branding.showWebsite && branding.website) {
|
||||
lines.push(branding.website);
|
||||
}
|
||||
|
||||
// Get active social platforms with their values
|
||||
const activeSocialPlatforms = branding.showSocial && branding.social
|
||||
? SOCIAL_PLATFORMS_CONFIG.filter(p => branding.social[p.key as keyof typeof branding.social])
|
||||
.map(p => ({
|
||||
...p,
|
||||
value: branding.social[p.key as keyof typeof branding.social] || ''
|
||||
}))
|
||||
: [];
|
||||
|
||||
const hasTextContent = lines.length > 0;
|
||||
const hasSocialIcons = activeSocialPlatforms.length > 0;
|
||||
|
||||
if (!hasTextContent && !hasSocialIcons && !hasAvatar) return null;
|
||||
|
||||
const textHeight = lines.length * lineHeight;
|
||||
|
||||
// Calculate social section dimensions (icon + text for each platform)
|
||||
const socialItemHeight = Math.max(iconSize, fontSize);
|
||||
const socialHeight = socialLayout === 'vertical'
|
||||
? activeSocialPlatforms.length * socialItemHeight + (activeSocialPlatforms.length - 1) * (iconGap - 4)
|
||||
: socialItemHeight;
|
||||
|
||||
// Calculate total content height
|
||||
const contentHeight = (hasTextContent || hasSocialIcons)
|
||||
? textHeight + (hasTextContent && hasSocialIcons ? 16 : 0) + (hasSocialIcons ? socialHeight : 0)
|
||||
: 0;
|
||||
const blockHeight = Math.max(contentHeight, hasAvatar ? avatarSize : 0);
|
||||
|
||||
// Calculate position based on setting
|
||||
let x = padding;
|
||||
let y = padding;
|
||||
let align: 'left' | 'right' = 'left';
|
||||
|
||||
switch (branding.position) {
|
||||
case 'top-left':
|
||||
x = padding;
|
||||
y = padding;
|
||||
align = 'left';
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width - padding;
|
||||
y = padding;
|
||||
align = 'right';
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = padding;
|
||||
y = height - padding - blockHeight;
|
||||
align = 'left';
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width - padding;
|
||||
y = height - padding - blockHeight;
|
||||
align = 'right';
|
||||
break;
|
||||
}
|
||||
|
||||
const totalHeight = blockHeight;
|
||||
const contentTop = contentHeight > 0 ? y + (totalHeight - contentHeight) / 2 : y;
|
||||
const avatarY = hasAvatar ? y + (totalHeight - avatarSize) / 2 : 0;
|
||||
const avatarCenterX = align === 'right' ? x - avatarSize / 2 : x + avatarSize / 2;
|
||||
const avatarCenterY = avatarY + avatarSize / 2;
|
||||
const avatarPatternScale = hasAvatar && brandingAvatar && brandingAvatar.width && brandingAvatar.height
|
||||
? Math.max(avatarSize / brandingAvatar.width, avatarSize / brandingAvatar.height)
|
||||
: 1;
|
||||
|
||||
const avatarOffset = hasAvatar ? avatarSize + avatarGap : 0;
|
||||
const textStartXBase = align === 'right' ? x - avatarOffset : x + avatarOffset;
|
||||
const textX = align === 'right' ? 0 : textStartXBase;
|
||||
const textWidth = align === 'right'
|
||||
? Math.max(textStartXBase, 0)
|
||||
: Math.max(width - padding - textStartXBase, 0);
|
||||
|
||||
// Calculate icon positions
|
||||
const iconScale = iconSize / 24; // SVG viewBox is 24x24
|
||||
const socialStartY = contentTop + textHeight + (hasTextContent ? 16 : 0);
|
||||
const anchorX = textStartXBase;
|
||||
|
||||
return (
|
||||
<Group opacity={branding.opacity || 0.8}>
|
||||
{/* Avatar */}
|
||||
{hasAvatar && brandingAvatar && (
|
||||
<Circle
|
||||
x={avatarCenterX}
|
||||
y={avatarCenterY}
|
||||
radius={avatarSize / 2}
|
||||
fillPatternImage={brandingAvatar}
|
||||
fillPatternScaleX={avatarPatternScale}
|
||||
fillPatternScaleY={avatarPatternScale}
|
||||
fillPatternOffsetX={brandingAvatar.width / 2}
|
||||
fillPatternOffsetY={brandingAvatar.height / 2}
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text content */}
|
||||
{hasTextContent && (
|
||||
<Text
|
||||
x={textX}
|
||||
y={contentTop}
|
||||
width={textWidth}
|
||||
text={lines.join('\n')}
|
||||
fontSize={fontSize}
|
||||
fontFamily={branding.fontFamily || 'Inter'}
|
||||
fill={branding.color || '#ffffff'}
|
||||
align={align}
|
||||
lineHeight={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Social icons with text */}
|
||||
{hasSocialIcons && activeSocialPlatforms.map((platform, index) => {
|
||||
const path = SOCIAL_ICON_PATHS[platform.key];
|
||||
if (!path) return null;
|
||||
|
||||
// Calculate position for this social item
|
||||
let itemX: number;
|
||||
let itemY: number;
|
||||
|
||||
if (socialLayout === 'vertical') {
|
||||
itemY = socialStartY + index * (socialItemHeight + iconGap - 4);
|
||||
itemX = anchorX;
|
||||
} else {
|
||||
itemY = socialStartY;
|
||||
// For horizontal layout, we need to calculate cumulative width
|
||||
// This is simplified - for perfect alignment we'd need to measure text
|
||||
const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => {
|
||||
const textWidthEstimate = (p.value.length * fontSize * 0.5); // Approximate text width
|
||||
return acc + iconSize + iconTextGap + textWidthEstimate + iconGap;
|
||||
}, 0);
|
||||
itemX = align === 'right' ? anchorX - prevItemsWidth : anchorX + prevItemsWidth;
|
||||
}
|
||||
|
||||
// Icon vertical centering within item
|
||||
const iconY = itemY + (socialItemHeight - iconSize) / 2;
|
||||
// Text vertical centering
|
||||
const textY = itemY + (socialItemHeight - fontSize) / 2;
|
||||
|
||||
if (align === 'right') {
|
||||
// For right alignment: text first, then icon
|
||||
const textWidthEstimate = platform.value.length * fontSize * 0.5; // Approximate
|
||||
return (
|
||||
<Group key={platform.key}>
|
||||
<Path
|
||||
x={itemX - iconSize}
|
||||
y={iconY}
|
||||
data={path}
|
||||
fill={branding.color || '#ffffff'}
|
||||
scaleX={iconScale}
|
||||
scaleY={iconScale}
|
||||
/>
|
||||
<Text
|
||||
x={itemX - iconSize - iconTextGap - textWidthEstimate}
|
||||
y={textY}
|
||||
text={platform.value}
|
||||
fontSize={fontSize}
|
||||
fontFamily={branding.fontFamily || 'Inter'}
|
||||
fill={branding.color || '#ffffff'}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
// For left alignment: icon first, then text
|
||||
return (
|
||||
<Group key={platform.key}>
|
||||
<Path
|
||||
x={itemX}
|
||||
y={iconY}
|
||||
data={path}
|
||||
fill={branding.color || '#ffffff'}
|
||||
scaleX={iconScale}
|
||||
scaleY={iconScale}
|
||||
/>
|
||||
<Text
|
||||
x={itemX + iconSize + iconTextGap}
|
||||
y={textY}
|
||||
text={platform.value}
|
||||
fontSize={fontSize}
|
||||
fontFamily={branding.fontFamily || 'Inter'}
|
||||
fill={branding.color || '#ffffff'}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}, [background.branding, width, height, brandingAvatar]);
|
||||
|
||||
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -165,7 +434,9 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
>
|
||||
<Layer>
|
||||
{renderBackground()}
|
||||
{renderGrid()}
|
||||
{brandStripElement}
|
||||
{brandingElement}
|
||||
{gridLines}
|
||||
|
||||
{snap.elements.map((element) => {
|
||||
if (!element.visible) return null;
|
||||
@@ -211,4 +482,4 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Canvas;
|
||||
export default memo(Canvas);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { memo, useMemo, useCallback, useState, useRef, useEffect } 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';
|
||||
|
||||
const DEFAULT_WIDTH = 320;
|
||||
const MIN_WIDTH = 260;
|
||||
const MAX_WIDTH = 520;
|
||||
const EXPANDED_WIDTH = 420;
|
||||
|
||||
const Inspector: React.FC = () => {
|
||||
const [width, setWidth] = useState<number>(DEFAULT_WIDTH);
|
||||
const {
|
||||
snap,
|
||||
selectedElementId,
|
||||
@@ -16,11 +23,99 @@ const Inspector: React.FC = () => {
|
||||
moveElementDown,
|
||||
} = useCanvasStore();
|
||||
|
||||
const selectedElement = snap.elements.find(el => el.id === selectedElementId);
|
||||
const dragStateRef = useRef({
|
||||
startX: 0,
|
||||
startWidth: DEFAULT_WIDTH,
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
const selectedElement = useMemo(
|
||||
() => snap.elements.find(el => el.id === selectedElementId),
|
||||
[snap.elements, selectedElementId]
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback((event: MouseEvent) => {
|
||||
if (!dragStateRef.current.isDragging) return;
|
||||
|
||||
const delta = dragStateRef.current.startX - event.clientX;
|
||||
const nextWidth = Math.min(
|
||||
Math.max(dragStateRef.current.startWidth + delta, MIN_WIDTH),
|
||||
MAX_WIDTH
|
||||
);
|
||||
|
||||
setWidth(nextWidth);
|
||||
}, []);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
if (!dragStateRef.current.isDragging) return;
|
||||
|
||||
dragStateRef.current.isDragging = false;
|
||||
window.removeEventListener('mousemove', handleResizeMove);
|
||||
window.removeEventListener('mouseup', stopDragging);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
dragStateRef.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: width,
|
||||
isDragging: true,
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleResizeMove);
|
||||
window.addEventListener('mouseup', stopDragging);
|
||||
}, [width, handleResizeMove, stopDragging]);
|
||||
|
||||
const handleHandleDoubleClick = useCallback(() => {
|
||||
setWidth(prev => (prev < EXPANDED_WIDTH ? EXPANDED_WIDTH : DEFAULT_WIDTH));
|
||||
}, []);
|
||||
|
||||
useEffect(() => (
|
||||
() => {
|
||||
stopDragging();
|
||||
}
|
||||
), [stopDragging]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
deleteElement(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, deleteElement]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
duplicateElement(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, duplicateElement]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
moveElementUp(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, moveElementUp]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
moveElementDown(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, moveElementDown]);
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-[#09090b] border-l border-white/5 overflow-y-auto h-full">
|
||||
<div className="p-6">
|
||||
<div
|
||||
className="relative flex-shrink-0 h-full bg-[#09090b] border-l border-white/5 transition-[width] duration-150 ease-out"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-2 cursor-col-resize group"
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={handleHandleDoubleClick}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize inspector"
|
||||
>
|
||||
<div className="mx-auto mt-1 h-full w-0.5 rounded-full bg-white/5 transition-all group-hover:bg-white/20" />
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto h-full">
|
||||
{selectedElement ? (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Title and Element Actions */}
|
||||
@@ -30,7 +125,7 @@ const Inspector: React.FC = () => {
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => duplicateElement(selectedElement.id)}
|
||||
onClick={handleDuplicate}
|
||||
className="p-1.5 hover:bg-white/10 rounded-md text-neutral-400 hover:text-white transition-colors"
|
||||
title="Duplicate (⌘D)"
|
||||
>
|
||||
@@ -40,7 +135,7 @@ const Inspector: React.FC = () => {
|
||||
</button>
|
||||
<div className="w-px h-3 bg-white/10 mx-1" />
|
||||
<button
|
||||
onClick={() => deleteElement(selectedElement.id)}
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 hover:bg-red-500/10 hover:text-red-500 rounded-md text-neutral-400 transition-colors"
|
||||
title="Delete (⌫)"
|
||||
>
|
||||
@@ -54,7 +149,7 @@ const Inspector: React.FC = () => {
|
||||
{/* Layer Controls */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => moveElementDown(selectedElement.id)}
|
||||
onClick={handleMoveDown}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -63,7 +158,7 @@ const Inspector: React.FC = () => {
|
||||
Send Backward
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveElementUp(selectedElement.id)}
|
||||
onClick={handleMoveUp}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -92,10 +187,15 @@ 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Inspector;
|
||||
export default memo(Inspector);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import type { CanvasElement } from '../types';
|
||||
|
||||
// Memoized layer item component for performance
|
||||
const LayerItem = memo(({
|
||||
element,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onToggleLock,
|
||||
onToggleVisibility
|
||||
}: {
|
||||
element: CanvasElement;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleLock: () => void;
|
||||
onToggleVisibility: () => void;
|
||||
}) => {
|
||||
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
|
||||
onClick={onSelect}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Element type icon */}
|
||||
<div className={`shrink-0 ${isSelected ? 'text-blue-400' : 'text-neutral-400'}`}>
|
||||
{getElementIcon(element.type)}
|
||||
</div>
|
||||
|
||||
{/* Element name */}
|
||||
<span className={`flex-1 text-sm truncate ${
|
||||
isSelected ? '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();
|
||||
onToggleLock();
|
||||
}}
|
||||
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();
|
||||
onToggleVisibility();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
LayerItem.displayName = 'LayerItem';
|
||||
|
||||
const LayersPanel: React.FC = () => {
|
||||
const {
|
||||
snap,
|
||||
selectedElementId,
|
||||
selectElement,
|
||||
updateElement,
|
||||
moveElementUp,
|
||||
moveElementDown,
|
||||
deleteElement,
|
||||
} = useCanvasStore();
|
||||
|
||||
const elements = useMemo(() => [...snap.elements].reverse(), [snap.elements]); // Show top layers first
|
||||
|
||||
const handleToggleLock = useCallback((id: string, locked: boolean) => {
|
||||
updateElement(id, { locked: !locked });
|
||||
}, [updateElement]);
|
||||
|
||||
const handleToggleVisibility = useCallback((id: string, visible: boolean) => {
|
||||
updateElement(id, { visible: !visible });
|
||||
}, [updateElement]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (selectedElementId) moveElementUp(selectedElementId);
|
||||
}, [selectedElementId, moveElementUp]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (selectedElementId) moveElementDown(selectedElementId);
|
||||
}, [selectedElementId, moveElementDown]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedElementId) deleteElement(selectedElementId);
|
||||
}, [selectedElementId, deleteElement]);
|
||||
|
||||
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) => (
|
||||
<LayerItem
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElementId === element.id}
|
||||
onSelect={() => selectElement(element.id)}
|
||||
onToggleLock={() => handleToggleLock(element.id, element.locked)}
|
||||
onToggleVisibility={() => handleToggleVisibility(element.id, element.visible)}
|
||||
/>
|
||||
))}
|
||||
</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={handleMoveDown}
|
||||
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={handleMoveUp}
|
||||
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={handleDelete}
|
||||
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 memo(LayersPanel);
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useRecentSnapsStore, formatRelativeTime, type RecentSnapEntry } from '../store/recentSnapsStore';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
|
||||
interface RecentSnapsDropdownProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
const RecentSnapItem = memo(({
|
||||
entry,
|
||||
onOpen,
|
||||
onDelete
|
||||
}: {
|
||||
entry: RecentSnapEntry;
|
||||
onOpen: (snap: RecentSnapEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onOpen(entry)}
|
||||
>
|
||||
{/* Thumbnail Preview */}
|
||||
<div
|
||||
className="w-12 h-9 rounded bg-neutral-800 border border-white/10 flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||
style={{
|
||||
background: entry.snap.background.type === 'gradient'
|
||||
? `linear-gradient(${entry.snap.background.gradient.angle}deg, ${entry.snap.background.gradient.from}, ${entry.snap.background.gradient.to})`
|
||||
: entry.snap.background.solid.color
|
||||
}}
|
||||
>
|
||||
<span className="text-[8px] text-white/40">{entry.snap.elements.length} el</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-neutral-200 truncate font-medium">{entry.title}</div>
|
||||
<div className="text-xs text-neutral-500">{formatRelativeTime(entry.savedAt)}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isHovered && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(entry.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
);
|
||||
});
|
||||
|
||||
RecentSnapItem.displayName = 'RecentSnapItem';
|
||||
|
||||
const RecentSnapsDropdown: React.FC<RecentSnapsDropdownProps> = ({ isOpen, onClose, anchorRef }) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { recentSnaps, removeRecentSnap, clearRecentSnaps } = useRecentSnapsStore();
|
||||
const { setSnap, saveToHistory } = useCanvasStore();
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
anchorRef.current &&
|
||||
!anchorRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose, anchorRef]);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleOpenSnap = useCallback((entry: RecentSnapEntry) => {
|
||||
saveToHistory();
|
||||
setSnap(entry.snap);
|
||||
onClose();
|
||||
}, [setSnap, saveToHistory, onClose]);
|
||||
|
||||
const handleDeleteSnap = useCallback((id: string) => {
|
||||
removeRecentSnap(id);
|
||||
}, [removeRecentSnap]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 top-full mt-2 w-72 bg-[#0a0a0c] border border-white/10 rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
>
|
||||
<div className="p-3 border-b border-white/5 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500 uppercase tracking-wider">Recent Projects</span>
|
||||
{recentSnaps.length > 0 && (
|
||||
<button
|
||||
onClick={clearRecentSnaps}
|
||||
className="text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{recentSnaps.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-white/5 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500">No recent projects</p>
|
||||
<p className="text-xs text-neutral-600 mt-1">Projects you save will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{recentSnaps.map((entry) => (
|
||||
<RecentSnapItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onOpen={handleOpenSnap}
|
||||
onDelete={handleDeleteSnap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RecentSnapsDropdown);
|
||||
+217
-109
@@ -1,13 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useCallback, memo } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import { useRecentSnapsStore } from '../store/recentSnapsStore';
|
||||
import { ASPECT_RATIOS } from '../types';
|
||||
import type Konva from 'konva';
|
||||
import RecentSnapsDropdown from './RecentSnapsDropdown';
|
||||
|
||||
interface TopBarProps {
|
||||
stageRef: React.RefObject<Konva.Stage | null>;
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
const [showRecentSnaps, setShowRecentSnaps] = useState(false);
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const recentButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
snap,
|
||||
updateMeta,
|
||||
@@ -17,15 +24,22 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
undo,
|
||||
redo,
|
||||
history,
|
||||
saveToHistory,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { addRecentSnap } = useRecentSnapsStore();
|
||||
|
||||
const handleNewSnap = () => {
|
||||
const handleNewSnap = useCallback(() => {
|
||||
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
|
||||
// Save current snap to recent before creating new
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
|
||||
}
|
||||
};
|
||||
}, [snap, addRecentSnap, newSnap]);
|
||||
|
||||
const handleAspectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const handleAspectChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const ratio = ASPECT_RATIOS.find(r => r.name === e.target.value);
|
||||
if (ratio) {
|
||||
updateMeta({
|
||||
@@ -34,9 +48,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
height: ratio.height,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [updateMeta]);
|
||||
|
||||
const handleExportImage = async (format: 'png' | 'jpeg', scale: number = 2) => {
|
||||
const handleExportImage = useCallback(async (format: 'png' | 'jpeg', scale: number = 2) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
@@ -64,9 +78,11 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
link.download = `${snap.meta.title || 'canvas'}.${format}`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
};
|
||||
setShowExportMenu(false);
|
||||
addRecentSnap(snap);
|
||||
}, [stageRef, snap, addRecentSnap]);
|
||||
|
||||
const handleExportJSON = () => {
|
||||
const handleExportJSON = useCallback(() => {
|
||||
const json = exportSnap();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -75,15 +91,24 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
setShowExportMenu(false);
|
||||
// Save to recent snaps when exporting
|
||||
addRecentSnap(snap);
|
||||
}, [exportSnap, snap, addRecentSnap]);
|
||||
|
||||
const handleImportJSON = () => {
|
||||
const handleImportJSON = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// Save current snap to recent before importing
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
saveToHistory();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const json = e.target?.result as string;
|
||||
@@ -93,61 +118,95 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
}, [snap, addRecentSnap, saveToHistory, importSnap]);
|
||||
|
||||
const toggleRecentSnaps = useCallback(() => {
|
||||
setShowRecentSnaps((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-16 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-40 fixed top-0 w-full">
|
||||
{/* Left section: Logo & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 pr-4 border-r border-white/5">
|
||||
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-blue-600 to-blue-700 shadow-lg shadow-blue-900/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<>
|
||||
{/* Overlay for closing export menu */}
|
||||
{showExportMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-transparent"
|
||||
onClick={() => setShowExportMenu(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-14 bg-[#09090b] border-b border-white/[0.08] flex items-center justify-between px-5 z-40 relative select-none">
|
||||
{/* Left section: Logo & Actions */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-3 pr-5 border-r border-white/[0.08]">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 shadow-lg shadow-blue-500/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-base tracking-tight text-white">
|
||||
YvCode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleNewSnap}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="New (⌘N)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportJSON}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="Open File (⌘O)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={recentButtonRef}
|
||||
onClick={toggleRecentSnaps}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${
|
||||
showRecentSnaps
|
||||
? 'text-white bg-white/10'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
title="Recent Projects"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<RecentSnapsDropdown
|
||||
isOpen={showRecentSnaps}
|
||||
onClose={() => setShowRecentSnaps(false)}
|
||||
anchorRef={recentButtonRef as React.RefObject<HTMLElement>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/60">
|
||||
YvCode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleNewSnap}
|
||||
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all active:scale-95"
|
||||
title="New (⌘N)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportJSON}
|
||||
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all active:scale-95"
|
||||
title="Open"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center section: Title & Tools */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Center section: Title & Tools */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={snap.meta.title}
|
||||
onChange={(e) => updateMeta({ title: e.target.value })}
|
||||
className="bg-transparent text-sm font-medium text-center text-neutral-200 focus:text-white px-2 py-1 outline-none rounded hover:bg-white/5 focus:bg-white/10 transition-colors placeholder-neutral-600 w-48"
|
||||
className="bg-transparent text-sm font-medium text-center text-neutral-200 focus:text-white px-3 py-1.5 outline-none rounded-md hover:bg-white/5 focus:bg-white/10 transition-colors placeholder-neutral-600 w-48 border border-transparent focus:border-white/10"
|
||||
placeholder="Untitled Project"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-neutral-600" />
|
||||
<div className="h-4 w-px bg-white/10" />
|
||||
<div className="relative group/aspect">
|
||||
<select
|
||||
value={snap.meta.aspect}
|
||||
onChange={handleAspectChange}
|
||||
className="bg-transparent text-[10px] items-center uppercase tracking-wider font-semibold text-neutral-500 hover:text-neutral-300 outline-none cursor-pointer appearance-none text-center"
|
||||
className="bg-transparent text-xs font-medium text-neutral-400 hover:text-white px-2 py-1 outline-none cursor-pointer appearance-none text-center transition-colors"
|
||||
>
|
||||
{ASPECT_RATIOS.map((ratio) => (
|
||||
<option key={ratio.name} value={ratio.name}>
|
||||
@@ -157,72 +216,121 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: History & Export */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0.5 p-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={history.past.length === 0}
|
||||
className="p-1.5 rounded text-neutral-400 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Undo (⌘Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={history.future.length === 0}
|
||||
className="p-1.5 rounded text-neutral-400 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Redo (⇧⌘Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-white/10" />
|
||||
|
||||
<div className="relative group">
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-black hover:bg-blue-50 text-sm font-semibold rounded-lg shadow-lg shadow-white/5 transition-all active:scale-95"
|
||||
>
|
||||
<span>Export</span>
|
||||
<svg className="w-4 h-4 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Right section: History & Export */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={history.past.length === 0}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-neutral-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:hover:bg-transparent active:scale-95"
|
||||
title="Undo (⌘Z)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={history.future.length === 0}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-neutral-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:hover:bg-transparent active:scale-95"
|
||||
title="Redo (⇧⌘Z)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-[#09090b] border border-white/10 rounded-xl shadow-2xl p-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50 transform origin-top-right">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">Format</div>
|
||||
<div className="h-6 w-px bg-white/10" />
|
||||
|
||||
<div className="relative z-50">
|
||||
<button
|
||||
onClick={() => handleExportImage('png', 2)}
|
||||
className="w-full text-left px-3 py-2 text-sm text-neutral-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors flex justify-between group/item"
|
||||
ref={exportButtonRef}
|
||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 border group ${
|
||||
showExportMenu
|
||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/50 shadow-[0_0_20px_rgba(37,99,235,0.3)]'
|
||||
: 'bg-white/5 text-neutral-300 hover:text-white hover:bg-white/10 border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<span>PNG Image</span>
|
||||
<span className="bg-white/10 px-1.5 py-0.5 rounded text-[10px] text-neutral-400 group-hover/item:text-white">2x</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExportImage('jpeg', 2)}
|
||||
className="w-full text-left px-3 py-2 text-sm text-neutral-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
JPEG Image
|
||||
</button>
|
||||
<div className="h-px bg-white/5 my-1" />
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="w-full text-left px-3 py-2 text-sm text-neutral-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Save Project JSON
|
||||
<span>Export</span>
|
||||
<div className={`p-0.5 rounded-md transition-all duration-200 ${showExportMenu ? 'bg-blue-500/20 text-blue-400' : 'bg-white/5 text-neutral-400 group-hover:text-white'}`}>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${showExportMenu ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full mt-3 w-72 bg-[#09090b]/95 backdrop-blur-2xl border border-white/10 rounded-xl shadow-2xl shadow-black/50 p-2 overflow-hidden animate-in fade-in zoom-in-95 duration-150 origin-top-right ring-1 ring-white/5 z-50">
|
||||
<div className="px-3 py-2">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest pl-1">Download Image</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 px-1">
|
||||
<button
|
||||
onClick={() => handleExportImage('png', 2)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.02] hover:border-white/10 transition-all group active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500/10 to-blue-600/10 border border-blue-500/20 text-blue-400 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<span className="font-bold text-[10px]">PNG</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-sm font-medium text-neutral-200 group-hover:text-white transition-colors">PNG Image</span>
|
||||
<span className="text-[10px] text-neutral-500">High quality (2x)</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleExportImage('jpeg', 2)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.02] hover:border-white/10 transition-all group active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500/10 to-purple-600/10 border border-purple-500/20 text-purple-400 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<span className="font-bold text-[10px]">JPG</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-sm font-medium text-neutral-200 group-hover:text-white transition-colors">JPEG Image</span>
|
||||
<span className="text-[10px] text-neutral-500">Standard quality</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-white/10 to-transparent my-2" />
|
||||
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest pl-1">Project File</span>
|
||||
</div>
|
||||
|
||||
<div className="px-1 pb-1">
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.02] hover:border-white/10 transition-all group active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-yellow-500/10 to-yellow-600/10 border border-yellow-500/20 text-yellow-400 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-sm font-medium text-neutral-200 group-hover:text-white transition-colors">Save Project</span>
|
||||
<span className="text-[10px] text-neutral-500">Edit later (.json)</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
export default memo(TopBar);
|
||||
|
||||
@@ -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,130 @@ 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]);
|
||||
const hasValidEndpoints =
|
||||
!!start &&
|
||||
!!end &&
|
||||
Number.isFinite(start.x) &&
|
||||
Number.isFinite(start.y) &&
|
||||
Number.isFinite(end.x) &&
|
||||
Number.isFinite(end.y);
|
||||
|
||||
const dx = hasValidEndpoints ? end.x - start.x : 0;
|
||||
const dy = hasValidEndpoints ? end.y - start.y : 0;
|
||||
const isDegenerate = !hasValidEndpoints || Math.hypot(dx, dy) < 0.5;
|
||||
|
||||
// Konva can throw when drawing shadows for 0x0 bounds (e.g. when start/end overlap).
|
||||
// Use a tiny, non-zero end point for rendering only.
|
||||
const renderStart = hasValidEndpoints ? start : { x: 0, y: 0 };
|
||||
const renderEnd = hasValidEndpoints
|
||||
? (isDegenerate ? { x: start.x + 1, y: start.y + 1 } : end)
|
||||
: { x: 1, y: 1 };
|
||||
|
||||
// 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 = (renderStart.x + renderEnd.x) / 2;
|
||||
const midY = (renderStart.y + renderEnd.y) / 2;
|
||||
const dx = renderEnd.x - renderStart.x;
|
||||
const dy = renderEnd.y - renderStart.y;
|
||||
return [{
|
||||
x: midX - dy * 0.3,
|
||||
y: midY + dx * 0.3,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}, [props.style, props.controlPoints, renderStart.x, renderStart.y, renderEnd.x, renderEnd.y]);
|
||||
|
||||
// Calculate points for rendering
|
||||
const flatPoints = useMemo(() => {
|
||||
if (props.style === 'curved') {
|
||||
return getBezierPoints(renderStart, renderEnd, controlPoints);
|
||||
}
|
||||
return points.flatMap(p => [p.x, p.y]);
|
||||
}, [props.style, points, renderStart, renderEnd, controlPoints]);
|
||||
|
||||
const handlePointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const newPoints = [...points];
|
||||
@@ -26,62 +144,258 @@ 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(renderStart, renderEnd, controlPoints, labelPosition)
|
||||
: { x: renderStart.x + (renderEnd.x - renderStart.x) * labelPosition, y: renderStart.y + (renderEnd.y - renderStart.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) => (
|
||||
<Group
|
||||
draggable={!element.locked}
|
||||
onDragEnd={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
|
||||
const node = e.target;
|
||||
const dx = node.x();
|
||||
const dy = node.y();
|
||||
|
||||
const newPoints = points.map((p) => ({
|
||||
x: p.x + dx,
|
||||
y: p.y + dy,
|
||||
}));
|
||||
|
||||
let newControlPoints = undefined;
|
||||
if (props.controlPoints && props.controlPoints.length > 0) {
|
||||
newControlPoints = props.controlPoints.map((p) => ({
|
||||
x: p.x + dx,
|
||||
y: p.y + dy,
|
||||
}));
|
||||
}
|
||||
|
||||
// Reset relative position
|
||||
node.x(0);
|
||||
node.y(0);
|
||||
|
||||
onChange({
|
||||
points: newPoints,
|
||||
props: {
|
||||
...props,
|
||||
controlPoints: newControlPoints || props.controlPoints,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* If endpoints overlap/invalid, render a small dot instead of a shadowed arrow to avoid Konva draw crashes. */}
|
||||
{isDegenerate && (
|
||||
<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 });
|
||||
}}
|
||||
x={renderStart.x}
|
||||
y={renderStart.y}
|
||||
radius={Math.max(6, props.thickness * 2)}
|
||||
fill={props.color}
|
||||
opacity={0.8}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
hitStrokeWidth={20}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* For curved arrows, use Line with many points */}
|
||||
{!isDegenerate && 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}
|
||||
/>
|
||||
) : (
|
||||
!isDegenerate && (
|
||||
<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) */}
|
||||
{!isDegenerate && props.style === 'curved' && props.head !== 'none' && (
|
||||
<KonvaArrow
|
||||
points={[
|
||||
flatPoints[flatPoints.length - 4] ?? renderStart.x,
|
||||
flatPoints[flatPoints.length - 3] ?? renderStart.y,
|
||||
renderEnd.x,
|
||||
renderEnd.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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Group, Rect, Text, Transformer } from 'react-konva';
|
||||
import { Html } from 'react-konva-utils';
|
||||
import type Konva from 'konva';
|
||||
import type { CodeElement } from '../../types';
|
||||
import { tokenizeCode, getThemeBackground, isLightTheme, type LineTokens } from '../../utils/highlighter';
|
||||
@@ -14,8 +15,11 @@ interface CodeBlockProps {
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, onChange }) => {
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
const trRef = useRef<Konva.Transformer>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { x, y, width, height, rotation, props } = element;
|
||||
const [tokenizedLines, setTokenizedLines] = useState<LineTokens[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(props.code);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && trRef.current && groupRef.current) {
|
||||
@@ -45,6 +49,166 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
// Sync editValue when code changes externally
|
||||
useEffect(() => {
|
||||
setEditValue(props.code);
|
||||
}, [props.code]);
|
||||
|
||||
// Focus textarea when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
// Move cursor to end
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// State for textarea position (computed when editing starts)
|
||||
const [textareaStyle, setTextareaStyle] = useState<React.CSSProperties>({ display: 'none' });
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setTextareaStyle({ display: 'none' });
|
||||
onChange({
|
||||
props: { ...props, code: editValue }
|
||||
});
|
||||
}, [editValue, onChange, props]);
|
||||
|
||||
// Close editing when deselecting
|
||||
useEffect(() => {
|
||||
if (!isSelected && isEditing) {
|
||||
handleSaveEdit();
|
||||
}
|
||||
}, [isSelected, isEditing, handleSaveEdit]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
// Prevent event from bubbling to avoid triggering canvas click
|
||||
e.cancelBubble = true;
|
||||
|
||||
if (!element.locked && groupRef.current) {
|
||||
const group = groupRef.current;
|
||||
const stage = group.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const scale = stage.scaleX();
|
||||
|
||||
// Calculate absolute position accounting for stage position and scale
|
||||
const textColor = isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5';
|
||||
const lineNumberWidth = props.lineNumbers ? 55 : 0;
|
||||
|
||||
// Set textarea style - position relative to the Html container
|
||||
setTextareaStyle({
|
||||
position: 'absolute',
|
||||
left: (props.padding + lineNumberWidth) * scale,
|
||||
top: props.padding * scale,
|
||||
width: (width - props.padding * 2 - lineNumberWidth) * scale,
|
||||
height: (height - props.padding * 2) * scale,
|
||||
fontSize: props.fontSize * scale,
|
||||
fontFamily: props.fontFamily,
|
||||
lineHeight: props.lineHeight,
|
||||
background: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
resize: 'none',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre',
|
||||
zIndex: 1000,
|
||||
transformOrigin: 'top left',
|
||||
tabSize: 2,
|
||||
caretColor: textColor,
|
||||
});
|
||||
|
||||
// Then enable editing
|
||||
setIsEditing(true);
|
||||
setEditValue(props.code);
|
||||
}
|
||||
}, [element.locked, props, width, height]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle Tab for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: Remove indentation
|
||||
const beforeCursor = value.substring(0, start);
|
||||
|
||||
// Find the start of the current line
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const linePrefix = value.substring(lineStart, start);
|
||||
|
||||
// Check if line starts with spaces or tab
|
||||
if (linePrefix.startsWith(' ')) {
|
||||
// Remove 2 spaces
|
||||
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 2);
|
||||
setEditValue(newValue);
|
||||
// Adjust cursor position
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 2);
|
||||
}, 0);
|
||||
} else if (linePrefix.startsWith('\t')) {
|
||||
// Remove tab
|
||||
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 1);
|
||||
setEditValue(newValue);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 1);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
// Tab: Add indentation (2 spaces)
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
||||
setEditValue(newValue);
|
||||
// Move cursor after the inserted spaces
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter for auto-indentation
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
// Find the current line's indentation
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const currentLine = value.substring(lineStart, start);
|
||||
const indentMatch = currentLine.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1] : '';
|
||||
|
||||
// Check if the line ends with { or : (for additional indentation)
|
||||
const trimmedLine = currentLine.trim();
|
||||
const needsExtraIndent = trimmedLine.endsWith('{') || trimmedLine.endsWith(':') || trimmedLine.endsWith('(');
|
||||
const extraIndent = needsExtraIndent ? ' ' : '';
|
||||
|
||||
const newValue = value.substring(0, start) + '\n' + indent + extraIndent + value.substring(end);
|
||||
setEditValue(newValue);
|
||||
|
||||
// Move cursor after the newline and indentation
|
||||
const newCursorPos = start + 1 + indent.length + extraIndent.length;
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Handle Escape to cancel/save
|
||||
if (e.key === 'Escape') {
|
||||
handleSaveEdit();
|
||||
}
|
||||
}, [handleSaveEdit]);
|
||||
|
||||
// Render code with syntax highlighting using tokens
|
||||
const renderCode = () => {
|
||||
const lines = tokenizedLines.length > 0 ? tokenizedLines :
|
||||
@@ -181,7 +345,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
width={width}
|
||||
height={height}
|
||||
rotation={rotation}
|
||||
draggable={!element.locked}
|
||||
draggable={!element.locked && !isEditing}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
onDragEnd={handleDragEnd}
|
||||
@@ -199,25 +363,51 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
shadowColor={props.shadow.color}
|
||||
/>
|
||||
|
||||
{/* Background */}
|
||||
{/* Background - handles double click for inline editing */}
|
||||
<Rect
|
||||
width={width}
|
||||
height={height}
|
||||
fill={bgColor}
|
||||
cornerRadius={props.cornerRadius}
|
||||
onDblClick={handleDoubleClick}
|
||||
onDblTap={handleDoubleClick}
|
||||
/>
|
||||
|
||||
{/* Code content */}
|
||||
<Group clipFunc={(ctx) => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, width, height, props.cornerRadius);
|
||||
ctx.closePath();
|
||||
}}>
|
||||
{renderCode()}
|
||||
{/* Code content - also handles double click */}
|
||||
<Group
|
||||
clipFunc={(ctx) => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, width, height, props.cornerRadius);
|
||||
ctx.closePath();
|
||||
}}
|
||||
onDblClick={handleDoubleClick}
|
||||
onDblTap={handleDoubleClick}
|
||||
>
|
||||
{!isEditing && renderCode()}
|
||||
</Group>
|
||||
|
||||
{/* Inline code editor */}
|
||||
{isEditing && (
|
||||
<Html
|
||||
divProps={{ style: { pointerEvents: 'auto' } }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
style={textareaStyle}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</Html>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isSelected && (
|
||||
{isSelected && !isEditing && (
|
||||
<Transformer
|
||||
ref={trRef}
|
||||
flipEnabled={false}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
// SVG path data for social media icons
|
||||
export const SOCIAL_ICON_PATHS: Record<string, string> = {
|
||||
twitter: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z',
|
||||
linkedin: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
|
||||
instagram: 'M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z',
|
||||
github: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12',
|
||||
youtube: 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z',
|
||||
tiktok: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
|
||||
};
|
||||
|
||||
// Social media platform order and colors
|
||||
export const SOCIAL_PLATFORMS_CONFIG = [
|
||||
{ key: 'twitter', label: 'X', color: '#000000' },
|
||||
{ key: 'linkedin', label: 'LinkedIn', color: '#0A66C2' },
|
||||
{ key: 'instagram', label: 'Instagram', color: '#E4405F' },
|
||||
{ key: 'github', label: 'GitHub', color: '#181717' },
|
||||
{ key: 'youtube', label: 'YouTube', color: '#FF0000' },
|
||||
{ key: 'tiktok', label: 'TikTok', color: '#000000' },
|
||||
] as const;
|
||||
|
||||
interface SocialIconProps {
|
||||
platform: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SocialIcon: React.FC<SocialIconProps> = ({
|
||||
platform,
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
className = ''
|
||||
}) => {
|
||||
const path = SOCIAL_ICON_PATHS[platform];
|
||||
if (!path) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color}
|
||||
className={className}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface SocialIconsGroupProps {
|
||||
social: {
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
instagram?: string;
|
||||
github?: string;
|
||||
youtube?: string;
|
||||
tiktok?: string;
|
||||
};
|
||||
size?: number;
|
||||
color?: string;
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
export const SocialIconsGroup: React.FC<SocialIconsGroupProps> = ({
|
||||
social,
|
||||
size = 20,
|
||||
color = '#ffffff',
|
||||
layout = 'horizontal',
|
||||
gap = 12,
|
||||
}) => {
|
||||
const activePlatforms = SOCIAL_PLATFORMS_CONFIG.filter(
|
||||
(p) => social[p.key as keyof typeof social]
|
||||
);
|
||||
|
||||
if (activePlatforms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: layout === 'horizontal' ? 'row' : 'column',
|
||||
gap: `${gap}px`,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{activePlatforms.map((platform) => (
|
||||
<SocialIcon
|
||||
key={platform.key}
|
||||
platform={platform.key}
|
||||
size={size}
|
||||
color={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialIcon;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Group, Rect, Text, Transformer } from 'react-konva';
|
||||
import { Html } from 'react-konva-utils';
|
||||
import type Konva from 'konva';
|
||||
import type { TextElement } from '../../types';
|
||||
|
||||
@@ -14,7 +15,11 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
const textRef = useRef<Konva.Text>(null);
|
||||
const trRef = useRef<Konva.Transformer>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [textDimensions, setTextDimensions] = useState({ width: 200, height: 30 });
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(element.props.text);
|
||||
const [textareaStyle, setTextareaStyle] = useState<React.CSSProperties>({ display: 'none' });
|
||||
const { x, y, rotation, props } = element;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +38,19 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
|
||||
}
|
||||
}, [props.text, props.fontSize, props.fontFamily, props.bold, props.italic]);
|
||||
|
||||
// Sync editValue when text changes externally
|
||||
useEffect(() => {
|
||||
setEditValue(props.text);
|
||||
}, [props.text]);
|
||||
|
||||
// Focus textarea when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
onChange({
|
||||
x: e.target.x(),
|
||||
@@ -54,6 +72,74 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setTextareaStyle({ display: 'none' });
|
||||
onChange({
|
||||
props: { ...props, text: editValue },
|
||||
});
|
||||
}, [editValue, onChange, props]);
|
||||
|
||||
// Close editing when deselecting
|
||||
useEffect(() => {
|
||||
if (!isSelected && isEditing) {
|
||||
handleSaveEdit();
|
||||
}
|
||||
}, [isSelected, isEditing, handleSaveEdit]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
onSelect();
|
||||
|
||||
if (element.locked || !groupRef.current) return;
|
||||
const group = groupRef.current;
|
||||
const stage = group.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const scale = stage.scaleX();
|
||||
|
||||
setTextareaStyle({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: Math.max(40, textDimensions.width) * scale,
|
||||
height: Math.max(24, textDimensions.height) * scale,
|
||||
fontSize: props.fontSize * scale,
|
||||
fontFamily: props.fontFamily,
|
||||
fontWeight: props.bold ? 'bold' : 'normal',
|
||||
fontStyle: props.italic ? 'italic' : 'normal',
|
||||
lineHeight: '1.2',
|
||||
background: 'transparent',
|
||||
color: props.color,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
resize: 'none',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'pre',
|
||||
textAlign: props.align,
|
||||
zIndex: 1000,
|
||||
transformOrigin: 'top left',
|
||||
caretColor: props.color,
|
||||
});
|
||||
|
||||
setIsEditing(true);
|
||||
setEditValue(props.text);
|
||||
}, [element.locked, onSelect, props, textDimensions.height, textDimensions.width]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
|
||||
if ((e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
}, [handleSaveEdit]);
|
||||
|
||||
const totalWidth = textDimensions.width + props.padding * 2;
|
||||
const totalHeight = textDimensions.height + props.padding * 2;
|
||||
|
||||
@@ -69,9 +155,11 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
|
||||
x={x}
|
||||
y={y}
|
||||
rotation={rotation}
|
||||
draggable={!element.locked}
|
||||
draggable={!element.locked && !isEditing}
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
onDblClick={handleDoubleClick}
|
||||
onDblTap={handleDoubleClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
>
|
||||
@@ -97,10 +185,31 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
|
||||
fill={props.color}
|
||||
align={props.align}
|
||||
textDecoration={props.underline ? 'underline' : ''}
|
||||
visible={!isEditing}
|
||||
/>
|
||||
|
||||
{/* Inline text editor */}
|
||||
{isEditing && (
|
||||
<Html
|
||||
divProps={{ style: { pointerEvents: 'auto' } }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
style={textareaStyle}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</Html>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isSelected && (
|
||||
{isSelected && !isEditing && (
|
||||
<Transformer
|
||||
ref={trRef}
|
||||
flipEnabled={false}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { useBrandingStore } from '../../store/brandingStore';
|
||||
import { FONT_FAMILIES } from '../../types';
|
||||
import { SocialIcon } from '../elements/SocialIcons';
|
||||
|
||||
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 MAX_AVATAR_DIMENSION = 192;
|
||||
const MAX_EMBEDDED_AVATAR_LENGTH = 350000;
|
||||
|
||||
const readFileAsDataUrl = (file: File) => new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Could not read file contents.'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Avatar read failed.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const loadImage = (src: string) => new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Avatar image could not be loaded.'));
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
const optimiseAvatarFile = async (file: File): Promise<string> => {
|
||||
const rawDataUrl = await readFileAsDataUrl(file);
|
||||
|
||||
if (rawDataUrl.length <= MAX_EMBEDDED_AVATAR_LENGTH) {
|
||||
return rawDataUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await loadImage(rawDataUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return rawDataUrl;
|
||||
}
|
||||
|
||||
let targetSize = MAX_AVATAR_DIMENSION;
|
||||
let optimised = rawDataUrl;
|
||||
|
||||
while (targetSize >= 96) {
|
||||
const maxSide = Math.max(image.width, image.height);
|
||||
const scale = maxSide > targetSize ? targetSize / maxSide : 1;
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
|
||||
optimised = canvas.toDataURL('image/png');
|
||||
if (optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH) {
|
||||
break;
|
||||
}
|
||||
|
||||
optimised = canvas.toDataURL('image/jpeg', 0.82);
|
||||
if (optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetSize = Math.floor(targetSize * 0.75);
|
||||
}
|
||||
|
||||
return optimised.length <= MAX_EMBEDDED_AVATAR_LENGTH ? optimised : rawDataUrl;
|
||||
} catch (error) {
|
||||
console.warn('Avatar optimisation failed, keeping the original image.', error);
|
||||
return rawDataUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const BrandingPanel: React.FC = () => {
|
||||
const { setBackground } = useCanvasStore();
|
||||
const { info, preferences, updateInfo, updateSocial, updatePreferences } = useBrandingStore();
|
||||
|
||||
// Sync branding store to canvas whenever it changes
|
||||
useEffect(() => {
|
||||
setBackground({
|
||||
branding: {
|
||||
...preferences,
|
||||
name: info.name,
|
||||
website: info.website,
|
||||
social: info.social,
|
||||
avatarUrl: info.avatarUrl,
|
||||
},
|
||||
});
|
||||
}, [info, preferences, setBackground]);
|
||||
|
||||
const handleUpdatePreferences = (updates: Partial<typeof preferences>) => {
|
||||
updatePreferences(updates);
|
||||
};
|
||||
|
||||
const handleUpdateInfo = (updates: Partial<typeof info>) => {
|
||||
updateInfo(updates);
|
||||
};
|
||||
|
||||
const handleUpdateSocial = (platform: string, value: string) => {
|
||||
updateSocial(platform, value);
|
||||
};
|
||||
|
||||
const handleAvatarUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
const resetInput = () => {
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
void optimiseAvatarFile(file)
|
||||
.then((dataUrl) => {
|
||||
handleUpdateInfo({ avatarUrl: dataUrl });
|
||||
handleUpdatePreferences({ showAvatar: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Avatar upload cancelled.', error);
|
||||
})
|
||||
.finally(resetInput);
|
||||
}, [handleUpdateInfo, handleUpdatePreferences]);
|
||||
|
||||
const handleAvatarClear = useCallback(() => {
|
||||
handleUpdateInfo({ avatarUrl: '' });
|
||||
}, [handleUpdateInfo]);
|
||||
|
||||
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={() => handleUpdatePreferences({ enabled: !preferences.enabled })}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
preferences.enabled ? 'bg-blue-600' : 'bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||
preferences.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preferences.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={() => handleUpdatePreferences({ position: opt.value })}
|
||||
className={`py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
||||
preferences.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>
|
||||
|
||||
{/* Avatar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Avatar
|
||||
</label>
|
||||
<button
|
||||
onClick={() => handleUpdatePreferences({ showAvatar: !preferences.showAvatar })}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
preferences.showAvatar
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'bg-white/5 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{preferences.showAvatar ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative w-16 h-16 rounded-full border border-white/10 bg-white/5 overflow-hidden cursor-pointer group">
|
||||
{info.avatarUrl ? (
|
||||
<img
|
||||
src={info.avatarUrl}
|
||||
alt="Brand avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-[10px] text-neutral-500 group-hover:text-neutral-300">
|
||||
<span>Upload</span>
|
||||
<span className="text-[9px] text-neutral-600">PNG/JPG</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
aria-label="Upload avatar"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1 text-[11px] text-neutral-500 space-y-1">
|
||||
<p>Use a square image for best results. Supported formats: PNG, JPG, SVG.</p>
|
||||
{info.avatarUrl && (
|
||||
<button
|
||||
onClick={handleAvatarClear}
|
||||
className="text-xs text-neutral-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Remove avatar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preferences.showAvatar && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs text-neutral-500 mb-2">
|
||||
Size: {preferences.avatarSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={32}
|
||||
max={120}
|
||||
value={preferences.avatarSize}
|
||||
onChange={(e) => handleUpdatePreferences({ avatarSize: parseInt(e.target.value) })}
|
||||
className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
</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={() => handleUpdatePreferences({ showName: !preferences.showName })}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
preferences.showName
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'bg-white/5 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{preferences.showName ? 'Show' : 'Hide'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={info.name}
|
||||
onChange={(e) => handleUpdateInfo({ 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={() => handleUpdatePreferences({ showWebsite: !preferences.showWebsite })}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
preferences.showWebsite
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'bg-white/5 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{preferences.showWebsite ? 'Show' : 'Hide'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={info.website}
|
||||
onChange={(e) => handleUpdateInfo({ 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={() => handleUpdatePreferences({ showSocial: !preferences.showSocial })}
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
preferences.showSocial
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'bg-white/5 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{preferences.showSocial ? 'Show' : 'Hide'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SOCIAL_PLATFORMS.map((platform) => (
|
||||
<div key={platform.key} className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 shrink-0 flex items-center justify-center text-neutral-400">
|
||||
<SocialIcon platform={platform.key} size={16} color="currentColor" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={info.social[platform.key as keyof typeof info.social] || ''}
|
||||
onChange={(e) => handleUpdateSocial(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>
|
||||
|
||||
{/* Social Icons Layout */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-neutral-500 mb-2">Layout</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdatePreferences({ socialLayout: 'horizontal' })}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
||||
preferences.socialLayout === 'horizontal'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
Horizontal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdatePreferences({ socialLayout: 'vertical' })}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-xs font-medium transition-all border ${
|
||||
preferences.socialLayout === 'vertical'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
Vertical
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-neutral-500 mb-2">
|
||||
Icon Size: {preferences.socialIconSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="14"
|
||||
max="32"
|
||||
value={preferences.socialIconSize}
|
||||
onChange={(e) => handleUpdatePreferences({ socialIconSize: 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>
|
||||
|
||||
{/* 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={preferences.fontFamily}
|
||||
onChange={(e) => handleUpdatePreferences({ 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: {preferences.fontSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="24"
|
||||
value={preferences.fontSize}
|
||||
onChange={(e) => handleUpdatePreferences({ 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={preferences.color}
|
||||
onChange={(e) => handleUpdatePreferences({ color: e.target.value })}
|
||||
className="absolute inset-[-4px] w-[200%] h-[200%] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={preferences.color}
|
||||
onChange={(e) => handleUpdatePreferences({ 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(preferences.opacity * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={preferences.opacity}
|
||||
onChange={(e) => handleUpdatePreferences({ 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: {preferences.padding}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="8"
|
||||
max="48"
|
||||
value={preferences.padding}
|
||||
onChange={(e) => handleUpdatePreferences({ 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;
|
||||
@@ -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;
|
||||
@@ -52,6 +53,72 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
|
||||
const totalLines = element.props.code.split('\n').length;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle Tab for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: Remove indentation
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const linePrefix = value.substring(lineStart, start);
|
||||
|
||||
if (linePrefix.startsWith(' ')) {
|
||||
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 2);
|
||||
handleCodeChange(newValue);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 2);
|
||||
}, 0);
|
||||
} else if (linePrefix.startsWith('\t')) {
|
||||
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 1);
|
||||
handleCodeChange(newValue);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 1);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
// Tab: Add indentation (2 spaces)
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
||||
handleCodeChange(newValue);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter for auto-indentation
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const currentLine = value.substring(lineStart, start);
|
||||
const indentMatch = currentLine.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1] : '';
|
||||
|
||||
const trimmedLine = currentLine.trim();
|
||||
const needsExtraIndent = trimmedLine.endsWith('{') || trimmedLine.endsWith(':') || trimmedLine.endsWith('(');
|
||||
const extraIndent = needsExtraIndent ? ' ' : '';
|
||||
|
||||
const newValue = value.substring(0, start) + '\n' + indent + extraIndent + value.substring(end);
|
||||
handleCodeChange(newValue);
|
||||
|
||||
const newCursorPos = start + 1 + indent.length + extraIndent.length;
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Code editor */}
|
||||
@@ -60,9 +127,11 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
<textarea
|
||||
value={element.props.code}
|
||||
onChange={(e) => handleCodeChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={saveToHistory}
|
||||
className="w-full h-32 bg-neutral-900 text-white text-sm font-mono p-3 rounded resize-y"
|
||||
spellCheck={false}
|
||||
style={{ tabSize: 2 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,18 +185,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>
|
||||
)}
|
||||
|
||||
+3
-20
@@ -1,25 +1,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface BrandingInfo {
|
||||
name: string;
|
||||
website: string;
|
||||
social: {
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
instagram?: string;
|
||||
github?: string;
|
||||
youtube?: string;
|
||||
tiktok?: string;
|
||||
};
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface BrandingPreferences {
|
||||
enabled: boolean;
|
||||
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
showName: boolean;
|
||||
showWebsite: boolean;
|
||||
showSocial: boolean;
|
||||
showAvatar: boolean;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
padding: number;
|
||||
socialIconSize: number;
|
||||
socialLayout: 'horizontal' | 'vertical';
|
||||
avatarSize: number;
|
||||
}
|
||||
|
||||
interface BrandingStore {
|
||||
info: BrandingInfo;
|
||||
preferences: BrandingPreferences;
|
||||
|
||||
// Actions
|
||||
updateInfo: (updates: Partial<BrandingInfo>) => void;
|
||||
updateSocial: (platform: string, value: string) => void;
|
||||
updatePreferences: (updates: Partial<BrandingPreferences>) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
const defaultInfo: BrandingInfo = {
|
||||
name: '',
|
||||
website: '',
|
||||
social: {},
|
||||
avatarUrl: '',
|
||||
};
|
||||
|
||||
const MAX_PERSISTED_AVATAR_LENGTH = 350000;
|
||||
|
||||
const defaultPreferences: BrandingPreferences = {
|
||||
enabled: false,
|
||||
position: 'bottom-right',
|
||||
showName: true,
|
||||
showWebsite: true,
|
||||
showSocial: true,
|
||||
showAvatar: false,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: '#ffffff',
|
||||
opacity: 0.8,
|
||||
padding: 24,
|
||||
socialIconSize: 20,
|
||||
socialLayout: 'horizontal',
|
||||
avatarSize: 56,
|
||||
};
|
||||
|
||||
export const useBrandingStore = create<BrandingStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
info: defaultInfo,
|
||||
preferences: defaultPreferences,
|
||||
|
||||
updateInfo: (updates) => set((state) => ({
|
||||
info: { ...state.info, ...updates },
|
||||
})),
|
||||
|
||||
updateSocial: (platform, value) => set((state) => ({
|
||||
info: {
|
||||
...state.info,
|
||||
social: { ...state.info.social, [platform]: value },
|
||||
},
|
||||
})),
|
||||
|
||||
updatePreferences: (updates) => set((state) => ({
|
||||
preferences: { ...state.preferences, ...updates },
|
||||
})),
|
||||
|
||||
resetToDefaults: () => set({
|
||||
info: defaultInfo,
|
||||
preferences: defaultPreferences,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'yvcode-branding',
|
||||
storage: createJSONStorage(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getItem: (name: string) => window.localStorage.getItem(name),
|
||||
setItem: (name: string, value: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(name, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist branding preferences (quota exceeded).', error);
|
||||
}
|
||||
},
|
||||
removeItem: (name: string) => window.localStorage.removeItem(name),
|
||||
};
|
||||
}),
|
||||
partialize: (state) => ({
|
||||
info: {
|
||||
...state.info,
|
||||
avatarUrl: state.info.avatarUrl && state.info.avatarUrl.length > MAX_PERSISTED_AVATAR_LENGTH
|
||||
? ''
|
||||
: state.info.avatarUrl,
|
||||
},
|
||||
preferences: state.preferences,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type {
|
||||
Snap,
|
||||
@@ -52,6 +52,8 @@ interface CanvasState {
|
||||
importSnap: (json: string) => void;
|
||||
}
|
||||
|
||||
const MAX_PERSISTED_AVATAR_LENGTH = 350000;
|
||||
|
||||
const defaultSnap: Snap = {
|
||||
version: '1.0.0',
|
||||
meta: {
|
||||
@@ -64,6 +66,36 @@ 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',
|
||||
},
|
||||
branding: {
|
||||
enabled: false,
|
||||
position: 'bottom-right',
|
||||
name: '',
|
||||
website: '',
|
||||
social: {},
|
||||
avatarUrl: '',
|
||||
showName: true,
|
||||
showWebsite: true,
|
||||
showSocial: true,
|
||||
showAvatar: false,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: '#ffffff',
|
||||
opacity: 0.8,
|
||||
padding: 24,
|
||||
socialIconSize: 20,
|
||||
socialLayout: 'horizontal',
|
||||
avatarSize: 56,
|
||||
},
|
||||
},
|
||||
elements: [],
|
||||
};
|
||||
@@ -218,7 +250,35 @@ export const useCanvasStore = create<CanvasState>()(
|
||||
})),
|
||||
{
|
||||
name: 'code-canvas-storage',
|
||||
partialize: (state) => ({ snap: state.snap }),
|
||||
storage: createJSONStorage(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getItem: (name: string) => window.localStorage.getItem(name),
|
||||
setItem: (name: string, value: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(name, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist canvas state (quota exceeded).', error);
|
||||
}
|
||||
},
|
||||
removeItem: (name: string) => window.localStorage.removeItem(name),
|
||||
};
|
||||
}),
|
||||
partialize: (state) => {
|
||||
const snap = JSON.parse(JSON.stringify(state.snap)) as Snap;
|
||||
if (snap.background?.branding?.avatarUrl && snap.background.branding.avatarUrl.length > MAX_PERSISTED_AVATAR_LENGTH) {
|
||||
snap.background.branding.avatarUrl = '';
|
||||
snap.background.branding.showAvatar = false;
|
||||
}
|
||||
return { snap };
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Snap } from '../types';
|
||||
|
||||
export interface RecentSnapEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnail?: string;
|
||||
savedAt: number;
|
||||
snap: Snap;
|
||||
}
|
||||
|
||||
interface RecentSnapsState {
|
||||
recentSnaps: RecentSnapEntry[];
|
||||
maxRecent: number;
|
||||
|
||||
// Actions
|
||||
addRecentSnap: (snap: Snap, thumbnail?: string) => void;
|
||||
removeRecentSnap: (id: string) => void;
|
||||
clearRecentSnaps: () => void;
|
||||
getRecentSnaps: () => RecentSnapEntry[];
|
||||
}
|
||||
|
||||
export const useRecentSnapsStore = create<RecentSnapsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
recentSnaps: [],
|
||||
maxRecent: 10,
|
||||
|
||||
addRecentSnap: (snap: Snap, thumbnail?: string) => {
|
||||
const id = `snap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const entry: RecentSnapEntry = {
|
||||
id,
|
||||
title: snap.meta.title || 'Untitled',
|
||||
thumbnail,
|
||||
savedAt: Date.now(),
|
||||
snap: JSON.parse(JSON.stringify(snap)), // Deep clone
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
// Remove duplicates with same title and similar content
|
||||
const filtered = state.recentSnaps.filter(
|
||||
(s) => s.title !== snap.meta.title
|
||||
);
|
||||
|
||||
// Add new entry at the beginning
|
||||
const updated = [entry, ...filtered];
|
||||
|
||||
// Keep only maxRecent entries
|
||||
return {
|
||||
recentSnaps: updated.slice(0, state.maxRecent),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeRecentSnap: (id: string) => {
|
||||
set((state) => ({
|
||||
recentSnaps: state.recentSnaps.filter((s) => s.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearRecentSnaps: () => {
|
||||
set({ recentSnaps: [] });
|
||||
},
|
||||
|
||||
getRecentSnaps: () => {
|
||||
return get().recentSnaps;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'code-canvas-recent-snaps',
|
||||
// Only persist essential data to keep storage size reasonable
|
||||
partialize: (state) => ({
|
||||
recentSnaps: state.recentSnaps.map((entry) => ({
|
||||
...entry,
|
||||
thumbnail: undefined, // Don't persist thumbnails to save space
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to format relative time
|
||||
export const formatRelativeTime = (timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
||||
}
|
||||
return 'Just now';
|
||||
};
|
||||
+66
-2
@@ -10,10 +10,53 @@ 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;
|
||||
avatarUrl?: string;
|
||||
showName: boolean;
|
||||
showWebsite: boolean;
|
||||
showSocial: boolean;
|
||||
showAvatar?: boolean;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
padding: number;
|
||||
socialIconSize?: number;
|
||||
socialLayout?: 'horizontal' | 'vertical';
|
||||
avatarSize?: 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;
|
||||
branding: Branding;
|
||||
}
|
||||
|
||||
export interface Shadow {
|
||||
@@ -61,6 +104,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 +226,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));
|
||||
};
|
||||
@@ -3,15 +3,22 @@ import { CODE_THEMES, type CodeThemeId } from '../types';
|
||||
|
||||
let highlighterReady = false;
|
||||
|
||||
function normalizeThemeId(theme: unknown): CodeThemeId {
|
||||
if (typeof theme !== 'string') return 'dracula';
|
||||
const found = CODE_THEMES.some((t) => t.id === theme);
|
||||
return (found ? theme : 'dracula') as CodeThemeId;
|
||||
}
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: CodeThemeId
|
||||
): Promise<string> {
|
||||
try {
|
||||
const safeTheme = normalizeThemeId(theme);
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: theme as BundledTheme,
|
||||
theme: safeTheme as BundledTheme,
|
||||
});
|
||||
highlighterReady = true;
|
||||
return html;
|
||||
@@ -36,9 +43,10 @@ export async function tokenizeCode(
|
||||
theme: CodeThemeId
|
||||
): Promise<LineTokens[]> {
|
||||
try {
|
||||
const safeTheme = normalizeThemeId(theme);
|
||||
const result = await codeToTokens(code, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: theme as BundledTheme,
|
||||
theme: safeTheme as BundledTheme,
|
||||
});
|
||||
highlighterReady = true;
|
||||
return result.tokens.map(line => ({
|
||||
@@ -56,12 +64,12 @@ export async function tokenizeCode(
|
||||
}
|
||||
}
|
||||
|
||||
export function getThemeBackground(themeId: CodeThemeId): string {
|
||||
export function getThemeBackground(themeId: string): string {
|
||||
const theme = CODE_THEMES.find(t => t.id === themeId);
|
||||
return theme?.bg || '#1e1e2e';
|
||||
}
|
||||
|
||||
export function isLightTheme(themeId: CodeThemeId): boolean {
|
||||
export function isLightTheme(themeId: string): boolean {
|
||||
const lightThemes = ['github-light', 'vitesse-light', 'min-light', 'solarized-light', 'light-plus'];
|
||||
return lightThemes.includes(themeId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user