feat: RefBoard v0.4.0 — collaborative reference board with layers, groups & polished UI
Full-featured PureRef-style collaborative canvas for game dev teams: - Layer panel with visibility, lock, drag reorder, group/ungroup (Ctrl+G/Shift+G) - Arrangement tools (grid, row, column) via right-click context menu - Copy to system clipboard (Ctrl+C writes PNG for external paste in Paint etc.) - Number shortcuts (1-5) for tool selection with visible shortcut badges - Premium dark UI across all pages (Login, Collections, Boards, Editor) - Socket.IO rooms for cursors, transforms, and presence notifications - MinIO image storage with backend proxy, drag/drop and paste upload
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import React from 'react';
|
||||
import { ToolType } from '../canvas/tools';
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
interface OnlineUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
activeTool: ToolType;
|
||||
onToolChange: (tool: ToolType) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
strokeWidth: number;
|
||||
onStrokeWidthChange: (width: number) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
zoom: number;
|
||||
onFitAll: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onlineUsers: OnlineUser[];
|
||||
onShareClick?: () => void;
|
||||
onToggleLayers?: () => void;
|
||||
showLayers?: boolean;
|
||||
boardName?: string;
|
||||
}
|
||||
|
||||
// SVG icon components
|
||||
function IconSelect() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 2L3 13L7 9.5L11 13.5L13 11.5L9 7.5L13 4L3 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPan() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 1v14M1 8h14M4 4L8 1L12 4M4 12L8 15L12 12M1 4L4 8L1 12M15 4L12 8L15 12" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPen() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 14L3.5 8.5L11 1L15 5L7.5 12.5L2 14Z" strokeLinejoin="round" />
|
||||
<path d="M3.5 8.5L7.5 12.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconText() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M3 3h10M8 3v11M5 14h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEraser() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6.5 14H14M2 10l4.5-4.5 4 4L6 14H3l-1-1v-3z" strokeLinejoin="round" />
|
||||
<path d="M6.5 5.5L14 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconUndo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h6a3 3 0 010 6H7" strokeLinecap="round" />
|
||||
<path d="M5 3L3 5L5 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconRedo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M11 5H5a3 3 0 000 6h2" strokeLinecap="round" />
|
||||
<path d="M9 3L11 5L9 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFit() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<path d="M1 5V1h4M9 1h4v4M13 9v4H9M5 13H1V9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLayers() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M7 1L1 4.5L7 8L13 4.5L7 1Z" strokeLinejoin="round" />
|
||||
<path d="M1 7l6 3.5L13 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1 9.5L7 13l6-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [
|
||||
{ tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect },
|
||||
{ tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan },
|
||||
{ tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen },
|
||||
{ tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText },
|
||||
{ tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser },
|
||||
];
|
||||
|
||||
export default function Toolbar({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
color,
|
||||
onColorChange,
|
||||
strokeWidth,
|
||||
onStrokeWidthChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
zoom,
|
||||
onFitAll,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onlineUsers,
|
||||
onShareClick,
|
||||
onToggleLayers,
|
||||
showLayers,
|
||||
}: ToolbarProps) {
|
||||
const showStroke = activeTool === ToolType.PEN;
|
||||
const showFontSize = activeTool === ToolType.TEXT;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
flexShrink: 0,
|
||||
height: '44px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Tool buttons */}
|
||||
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
||||
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
||||
const active = activeTool === tool;
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
onClick={() => onToolChange(tool)}
|
||||
title={`${label} (${shortcut} or ${numKey})`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '4px', height: '32px', padding: '0 10px',
|
||||
background: active ? 'linear-gradient(135deg, #4a9eff, #3d7dd8)' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#fff' : '#777',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: active ? '0 1px 4px rgba(74,158,255,0.3)' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = '#2a2a2a'; e.currentTarget.style.color = '#bbb'; } }}
|
||||
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#777'; } }}
|
||||
>
|
||||
<Icon />
|
||||
<span style={{ fontSize: '11px', fontWeight: active ? 600 : 400, letterSpacing: '0.2px' }}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: '9px', color: active ? 'rgba(255,255,255,0.5)' : '#555',
|
||||
background: active ? 'rgba(255,255,255,0.1)' : '#1a1a1a',
|
||||
padding: '1px 4px', borderRadius: '3px', fontWeight: 500,
|
||||
lineHeight: '14px', minWidth: '14px', textAlign: 'center',
|
||||
}}>
|
||||
{numKey}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Color */}
|
||||
<ColorPicker color={color} onChange={onColorChange} />
|
||||
|
||||
{/* Stroke width (pen) */}
|
||||
{showStroke && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Width</span>
|
||||
<input
|
||||
type="range" min={2} max={20} value={strokeWidth}
|
||||
onChange={(e) => onStrokeWidthChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{strokeWidth}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Font size (text) */}
|
||||
{showFontSize && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Size</span>
|
||||
<input
|
||||
type="range" min={12} max={72} value={fontSize}
|
||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{fontSize}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ActionBtn onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)"><IconUndo /></ActionBtn>
|
||||
<ActionBtn onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)"><IconRedo /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Zoom */}
|
||||
{onZoomOut && (
|
||||
<ActionBtn onClick={onZoomOut} title="Zoom out (Ctrl+-)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#888', minWidth: '40px', textAlign: 'center',
|
||||
userSelect: 'none', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
{onZoomIn && (
|
||||
<ActionBtn onClick={onZoomIn} title="Zoom in (Ctrl+=)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
<line x1="7" y1="3" x2="7" y2="11" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<ActionBtn onClick={onFitAll} title="Fit all (Ctrl+0)"><IconFit /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Layers toggle */}
|
||||
{onToggleLayers && (
|
||||
<ActionBtn onClick={onToggleLayers} title="Layers panel"
|
||||
active={showLayers}>
|
||||
<IconLayers />
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Online users */}
|
||||
{onlineUsers.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '-4px', marginRight: '8px' }}
|
||||
title={onlineUsers.map((u) => u.displayName).join(', ')}>
|
||||
{onlineUsers.slice(0, 5).map((u, i) => (
|
||||
<div key={u.userId} style={{
|
||||
width: '24px', height: '24px', borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '10px', fontWeight: 700, color: '#fff',
|
||||
border: '2px solid #1a1a1a',
|
||||
marginLeft: i > 0 ? '-6px' : '0',
|
||||
zIndex: 5 - i,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{(u.displayName || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{onlineUsers.length > 5 && (
|
||||
<span style={{ fontSize: '10px', color: '#666', marginLeft: '4px' }}>+{onlineUsers.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
{onShareClick && (
|
||||
<button onClick={onShareClick} style={{
|
||||
padding: '5px 14px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: '#fff', fontSize: '11px', fontWeight: 600,
|
||||
cursor: 'pointer', letterSpacing: '0.3px',
|
||||
boxShadow: '0 1px 4px rgba(74,158,255,0.3)',
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ width: '1px', height: '20px', background: '#2a2a2a', margin: '0 6px', flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
function ActionBtn({ onClick, disabled, title, children, active }: {
|
||||
onClick: () => void; disabled?: boolean; title: string; children: React.ReactNode; active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick} disabled={disabled} title={title}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '28px', height: '28px', background: active ? '#2a3a50' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#4a9eff' : disabled ? '#333' : '#777',
|
||||
cursor: disabled ? 'default' : 'pointer', padding: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) { e.currentTarget.style.background = active ? '#2a3a50' : '#2a2a2a'; } }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = active ? '#2a3a50' : 'transparent'; }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user