feat: RefBoard v0.5.0 — PureRef-style shortcuts, clipboard fix, zoom normalization
- Add 40+ keyboard shortcuts via declarative registry system (shortcut-definitions.ts) - Fix canvas tainting: patch crossOrigin before loadFromJSON in all paths (init, sync, undo/redo) - Fix clipboard copy-to-system with proper async blob handling and error toasts - Fix zoom wheel: normalize deltaY across mice/trackpads with consistent 8% step - Add operations module: alignment, distribution, normalization, arrangement, flip, grayscale, lock, overlay compare - Add breakSelection() to fix ActiveSelection relative coordinate bugs - Add shortcuts help overlay (? / F1) auto-generated from registry - Add grid overlay toggle (G key) - Fix 10 shortcut bugs: Ctrl+Y/P conflicts, ? key matching, arrow conflicts, Ctrl+S intercept, context menu stale objects
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ShortcutDef, formatShortcut } from '../canvas/shortcuts';
|
||||
|
||||
interface ShortcutsHelpProps {
|
||||
shortcuts: ShortcutDef[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
alignment: 'Alignment & Distribution',
|
||||
normalize: 'Normalize',
|
||||
arrangement: 'Arrangement',
|
||||
image: 'Image Manipulation',
|
||||
navigation: 'Navigation',
|
||||
editing: 'Editing',
|
||||
view: 'View',
|
||||
tools: 'Tools',
|
||||
};
|
||||
|
||||
const categoryOrder = ['editing', 'alignment', 'normalize', 'arrangement', 'image', 'navigation', 'view', 'tools'];
|
||||
|
||||
// Deduplicate shortcuts that share the same description (e.g. redo via Ctrl+Y and Ctrl+Shift+Z)
|
||||
function dedupe(defs: ShortcutDef[]): ShortcutDef[] {
|
||||
const seen = new Set<string>();
|
||||
return defs.filter((d) => {
|
||||
// Keep the first occurrence by description+category, skip duplicates like zoom-in-plus
|
||||
const key = `${d.category}:${d.description}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export default function ShortcutsHelp({ shortcuts, onClose }: ShortcutsHelpProps) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' || (e.key === '/' && e.shiftKey) || e.key === 'F1') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Additional tool shortcuts not in registry
|
||||
const toolShortcuts = [
|
||||
{ keys: 'V / 1', description: 'Select tool' },
|
||||
{ keys: 'H / 2', description: 'Pan tool' },
|
||||
{ keys: 'P / 3', description: 'Draw tool' },
|
||||
{ keys: 'T / 4', description: 'Text tool' },
|
||||
{ keys: 'E / 5', description: 'Eraser tool' },
|
||||
{ keys: 'Space + drag', description: 'Pan canvas' },
|
||||
{ keys: 'Scroll', description: 'Zoom' },
|
||||
{ keys: 'Middle click + drag', description: 'Pan canvas' },
|
||||
];
|
||||
|
||||
const deduped = dedupe(shortcuts);
|
||||
const grouped = categoryOrder
|
||||
.map((cat) => ({
|
||||
category: cat,
|
||||
label: categoryLabels[cat] || cat,
|
||||
items: deduped.filter((s) => s.category === cat),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 2000,
|
||||
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#141414', border: '1px solid #222', borderRadius: '16px',
|
||||
width: '100%', maxWidth: '720px', maxHeight: '85vh',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: '#e0e0e0', letterSpacing: '-0.3px' }}>
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #333', borderRadius: '6px',
|
||||
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px 24px 24px' }}>
|
||||
{/* Tool shortcuts (hardcoded since they use a different system) */}
|
||||
<SectionHeader label="Tools" />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0' }}>
|
||||
{toolShortcuts.map((s) => (
|
||||
<ShortcutRow key={s.keys} keys={s.keys} description={s.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Registry shortcuts */}
|
||||
{grouped.map((g) => (
|
||||
<React.Fragment key={g.category}>
|
||||
<SectionHeader label={g.label} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0' }}>
|
||||
{g.items.map((s) => (
|
||||
<ShortcutRow key={s.id} keys={formatShortcut(s)} description={s.description} />
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
|
||||
textTransform: 'uppercase', padding: '16px 0 6px', borderBottom: '1px solid #1a1a1a',
|
||||
marginBottom: '4px',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutRow({ keys, description }: { keys: string; description: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '5px 8px', borderRadius: '4px',
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: '#aaa' }}>{description}</span>
|
||||
<kbd style={{
|
||||
fontSize: '10px', color: '#777', background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a', borderRadius: '4px',
|
||||
padding: '2px 8px', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
marginLeft: '12px',
|
||||
}}>
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ interface ToolbarProps {
|
||||
onShareClick?: () => void;
|
||||
onToggleLayers?: () => void;
|
||||
showLayers?: boolean;
|
||||
onToggleHelp?: () => void;
|
||||
boardName?: string;
|
||||
}
|
||||
|
||||
@@ -140,6 +141,7 @@ export default function Toolbar({
|
||||
onShareClick,
|
||||
onToggleLayers,
|
||||
showLayers,
|
||||
onToggleHelp,
|
||||
}: ToolbarProps) {
|
||||
const showStroke = activeTool === ToolType.PEN;
|
||||
const showFontSize = activeTool === ToolType.TEXT;
|
||||
@@ -268,6 +270,17 @@ export default function Toolbar({
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Help / shortcuts */}
|
||||
{onToggleHelp && (
|
||||
<ActionBtn onClick={onToggleHelp} title="Keyboard shortcuts (?)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="7" cy="7" r="6" />
|
||||
<path d="M5.5 5.5a1.5 1.5 0 013 0c0 1-1.5 1-1.5 2" strokeLinecap="round" />
|
||||
<circle cx="7" cy="10" r="0.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user