feat: RefStudio SRS Phase 1+2 complete board - always-on-top, opacity, desaturate, flip, context menu, undo/redo, palette extraction, auto-save, annotations"
Browse files
src/components/views/BoardView.tsx
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion, AnimatePresence } from 'motion/react';
|
| 2 |
+
import { Type, Image as ImageIcon, Plus, Minus, Library, X, Search, FilePlus2, FolderOpen, Palette, Trash2, RotateCw, FlipHorizontal, FlipVertical, Layers, Grid, Pin, Eye, Undo2, Redo2, Maximize, Copy, ExternalLink } from 'lucide-react';
|
| 3 |
+
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
| 4 |
+
import { invoke } from '@tauri-apps/api/core';
|
| 5 |
+
import { getCurrentWindow } from '@tauri-apps/api/window';
|
| 6 |
+
import { cn, defaultTransition } from '../../lib/utils';
|
| 7 |
+
|
| 8 |
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
| 9 |
+
interface BoardItem { id: string; kind: string; library_id?: string; data_url?: string; text?: string; colors: string[]; x: number; y: number; w: number; h: number; z: number; rotation?: number; flipped?: boolean; flipV?: boolean; desaturated?: boolean; source_url?: string; }
|
| 10 |
+
interface BoardSummary { id: string; title: string; item_count: number; created_at: number; updated_at: number; }
|
| 11 |
+
interface BoardDocument { id: string; title: string; items: BoardItem[]; created_at: number; updated_at: number; }
|
| 12 |
+
interface LibItem { id: string; data_url: string; title: string; width: number; height: number; tags: string[]; colors: string[]; source_url?: string; }
|
| 13 |
+
interface ContextMenu { x: number; y: number; itemId: string; }
|
| 14 |
+
|
| 15 |
+
type InteractionMode =
|
| 16 |
+
| null
|
| 17 |
+
| { type: 'pan'; startX: number; startY: number; vpX: number; vpY: number }
|
| 18 |
+
| { type: 'move'; ids: string[]; startX: number; startY: number; origins: { id: string; x: number; y: number }[] }
|
| 19 |
+
| { type: 'resize'; id: string; handle: string; startX: number; startY: number; ox: number; oy: number; ow: number; oh: number; aspect: number }
|
| 20 |
+
| { type: 'rotate'; id: string; centerX: number; centerY: number; startAngle: number; origRotation: number }
|
| 21 |
+
| { type: 'marquee'; startX: number; startY: number; endX: number; endY: number };
|
| 22 |
+
|
| 23 |
+
type UndoAction = { type: 'add'; item: BoardItem } | { type: 'delete'; item: BoardItem } | { type: 'update'; before: BoardItem; after: BoardItem };
|
| 24 |
+
|
| 25 |
+
// ─── k-means Color Extraction ────────────────────────────────────────────────
|
| 26 |
+
function extractColors(src: string, count = 5): Promise<string[]> {
|
| 27 |
+
return new Promise((resolve) => {
|
| 28 |
+
const img = new Image(); img.crossOrigin = 'anonymous';
|
| 29 |
+
img.onload = () => {
|
| 30 |
+
try {
|
| 31 |
+
const c = document.createElement('canvas'); const ctx = c.getContext('2d')!;
|
| 32 |
+
c.width = 80; c.height = 80; ctx.drawImage(img, 0, 0, 80, 80);
|
| 33 |
+
const d = ctx.getImageData(0, 0, 80, 80).data;
|
| 34 |
+
const pxs: number[][] = [];
|
| 35 |
+
for (let i = 0; i < d.length; i += 12) { const r=d[i],g=d[i+1],b=d[i+2]; if(r+g+b>20) pxs.push([r,g,b]); }
|
| 36 |
+
if (pxs.length < 3) { resolve([]); return; }
|
| 37 |
+
// k-means with random init
|
| 38 |
+
let centroids = pxs.slice(0, count).map(p => [...p]);
|
| 39 |
+
for (let iter = 0; iter < 10; iter++) {
|
| 40 |
+
const clusters: number[][][] = centroids.map(() => []);
|
| 41 |
+
pxs.forEach(p => { let minD = Infinity, minI = 0; centroids.forEach((c, i) => { const d = (p[0]-c[0])**2+(p[1]-c[1])**2+(p[2]-c[2])**2; if (d < minD) { minD = d; minI = i; } }); clusters[minI].push(p); });
|
| 42 |
+
centroids = clusters.map((cl, i) => cl.length ? [cl.reduce((a,p)=>a+p[0],0)/cl.length, cl.reduce((a,p)=>a+p[1],0)/cl.length, cl.reduce((a,p)=>a+p[2],0)/cl.length] : centroids[i]);
|
| 43 |
+
}
|
| 44 |
+
resolve(centroids.map(c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')));
|
| 45 |
+
} catch { resolve([]); }
|
| 46 |
+
};
|
| 47 |
+
img.onerror = () => resolve([]); img.src = src;
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// ─── Main Board ──────────────────────────────────────────────────────────────
|
| 52 |
+
export default function BoardView() {
|
| 53 |
+
const [doc, setDoc] = useState<BoardDocument | null>(null);
|
| 54 |
+
const [boards, setBoards] = useState<BoardSummary[]>([]);
|
| 55 |
+
const [items, setItems] = useState<BoardItem[]>([]);
|
| 56 |
+
const [selection, setSelection] = useState<string[]>([]);
|
| 57 |
+
const [vp, setVp] = useState({ x: 0, y: 0, scale: 1 });
|
| 58 |
+
const [mode, setMode] = useState<InteractionMode>(null);
|
| 59 |
+
const [showGrid, setShowGrid] = useState(true);
|
| 60 |
+
const [libOpen, setLibOpen] = useState(false);
|
| 61 |
+
const [libItems, setLibItems] = useState<LibItem[]>([]);
|
| 62 |
+
const [libQuery, setLibQuery] = useState('');
|
| 63 |
+
const [pickerOpen, setPickerOpen] = useState(false);
|
| 64 |
+
const [newName, setNewName] = useState('');
|
| 65 |
+
const [alwaysOnTop, setAlwaysOnTop] = useState(false);
|
| 66 |
+
const [globalOpacity, setGlobalOpacity] = useState(1);
|
| 67 |
+
const [globalDesat, setGlobalDesat] = useState(false);
|
| 68 |
+
const [ctxMenu, setCtxMenu] = useState<ContextMenu | null>(null);
|
| 69 |
+
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
| 70 |
+
const [redoStack, setRedoStack] = useState<UndoAction[]>([]);
|
| 71 |
+
const boardRef = useRef<HTMLDivElement>(null);
|
| 72 |
+
const nextZ = useRef(1);
|
| 73 |
+
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
| 74 |
+
|
| 75 |
+
// ─── Data ──────────────────────────────────────────────────────────────────
|
| 76 |
+
const loadBoard = () => invoke<BoardDocument>('board_current').then(d => { setDoc(d); setItems(d.items); nextZ.current = d.items.reduce((m, i) => Math.max(m, i.z || 0), 0) + 1; }).catch(() => {});
|
| 77 |
+
const loadBoards = () => invoke<BoardSummary[]>('board_list').then(setBoards).catch(() => {});
|
| 78 |
+
const loadLib = () => invoke<LibItem[]>('library_items').then(setLibItems).catch(() => {});
|
| 79 |
+
useEffect(() => { loadBoard(); loadBoards(); loadLib(); }, []);
|
| 80 |
+
useEffect(() => { if (libOpen) loadLib(); }, [libOpen]);
|
| 81 |
+
|
| 82 |
+
// Auto-save
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
clearTimeout(autoSaveTimer.current);
|
| 85 |
+
autoSaveTimer.current = setTimeout(() => { items.forEach(item => invoke('board_update_item', { item }).catch(() => {})); }, 2000);
|
| 86 |
+
return () => clearTimeout(autoSaveTimer.current);
|
| 87 |
+
}, [items]);
|
| 88 |
+
|
| 89 |
+
// ─── Always on Top ─────────────────────────────────────────────────────────
|
| 90 |
+
const toggleAlwaysOnTop = async () => {
|
| 91 |
+
const next = !alwaysOnTop;
|
| 92 |
+
setAlwaysOnTop(next);
|
| 93 |
+
try { await getCurrentWindow().setAlwaysOnTop(next); } catch (e) { console.error('setAlwaysOnTop failed', e); }
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// ─── Coordinates ───────────────────────────────────────────────────────────
|
| 97 |
+
const toWorld = useCallback((cx: number, cy: number) => { const r = boardRef.current!.getBoundingClientRect(); return { x: (cx - r.left - vp.x) / vp.scale, y: (cy - r.top - vp.y) / vp.scale }; }, [vp]);
|
| 98 |
+
const center = useCallback(() => { const r = boardRef.current?.getBoundingClientRect(); if (!r) return { x: 200, y: 200 }; return { x: (r.width / 2 - vp.x) / vp.scale, y: (r.height / 2 - vp.y) / vp.scale }; }, [vp]);
|
| 99 |
+
|
| 100 |
+
// ─── Undo/Redo ─────────────────────────────────────────────────────────────
|
| 101 |
+
const pushUndo = (action: UndoAction) => { setUndoStack(s => [...s.slice(-99), action]); setRedoStack([]); };
|
| 102 |
+
const undo = () => {
|
| 103 |
+
const action = undoStack[undoStack.length - 1]; if (!action) return;
|
| 104 |
+
setUndoStack(s => s.slice(0, -1));
|
| 105 |
+
if (action.type === 'add') { setItems(p => p.filter(i => i.id !== action.item.id)); invoke('board_delete_item', { id: action.item.id }).catch(() => {}); }
|
| 106 |
+
if (action.type === 'delete') { setItems(p => [...p, action.item]); invoke('board_update_item', { item: action.item }).catch(() => {}); }
|
| 107 |
+
if (action.type === 'update') { setItems(p => p.map(i => i.id === action.before.id ? action.before : i)); invoke('board_update_item', { item: action.before }).catch(() => {}); }
|
| 108 |
+
setRedoStack(s => [...s, action]);
|
| 109 |
+
};
|
| 110 |
+
const redo = () => {
|
| 111 |
+
const action = redoStack[redoStack.length - 1]; if (!action) return;
|
| 112 |
+
setRedoStack(s => s.slice(0, -1));
|
| 113 |
+
if (action.type === 'add') { setItems(p => [...p, action.item]); invoke('board_update_item', { item: action.item }).catch(() => {}); }
|
| 114 |
+
if (action.type === 'delete') { setItems(p => p.filter(i => i.id !== action.item.id)); invoke('board_delete_item', { id: action.item.id }).catch(() => {}); }
|
| 115 |
+
if (action.type === 'update') { setItems(p => p.map(i => i.id === action.after.id ? action.after : i)); invoke('board_update_item', { item: action.after }).catch(() => {}); }
|
| 116 |
+
setUndoStack(s => [...s, action]);
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
// ─── Item ops with undo ────────────────────────────────────────────────────
|
| 120 |
+
const updateItemWithUndo = (id: string, patch: Partial<BoardItem>) => {
|
| 121 |
+
const before = items.find(i => i.id === id); if (!before) return;
|
| 122 |
+
const after = { ...before, ...patch };
|
| 123 |
+
pushUndo({ type: 'update', before, after });
|
| 124 |
+
setItems(p => p.map(i => i.id === id ? after : i));
|
| 125 |
+
invoke('board_update_item', { item: after }).catch(() => {});
|
| 126 |
+
};
|
| 127 |
+
const deleteWithUndo = (ids: string[]) => {
|
| 128 |
+
ids.forEach(id => { const it = items.find(i => i.id === id); if (it) pushUndo({ type: 'delete', item: it }); });
|
| 129 |
+
setItems(p => p.filter(i => !ids.includes(i.id)));
|
| 130 |
+
ids.forEach(id => invoke('board_delete_item', { id }).catch(() => {}));
|
| 131 |
+
setSelection([]);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
// ─── Zoom + Pan ────────────────────────────────────────────────────────────
|
| 135 |
+
const onWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const r = boardRef.current!.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; const factor = e.deltaY < 0 ? 1.1 : 1/1.1; const ns = Math.min(16, Math.max(0.04, vp.scale * factor)); const wx = (mx-vp.x)/vp.scale, wy = (my-vp.y)/vp.scale; setVp({ x: mx - wx*ns, y: my - wy*ns, scale: ns }); }, [vp]);
|
| 136 |
+
const onBoardDown = useCallback((e: React.PointerEvent) => {
|
| 137 |
+
if ((e.target as HTMLElement).closest('[data-item]')) return;
|
| 138 |
+
setCtxMenu(null); setSelection([]);
|
| 139 |
+
if (e.shiftKey) { const w = toWorld(e.clientX, e.clientY); setMode({ type: 'marquee', startX: w.x, startY: w.y, endX: w.x, endY: w.y }); }
|
| 140 |
+
else { setMode({ type: 'pan', startX: e.clientX, startY: e.clientY, vpX: vp.x, vpY: vp.y }); }
|
| 141 |
+
}, [vp, toWorld]);
|
| 142 |
+
|
| 143 |
+
useEffect(() => {
|
| 144 |
+
const onMove = (e: PointerEvent) => {
|
| 145 |
+
if (!mode) return;
|
| 146 |
+
if (mode.type === 'pan') { setVp(v => ({ ...v, x: mode.vpX + e.clientX - mode.startX, y: mode.vpY + e.clientY - mode.startY })); }
|
| 147 |
+
else if (mode.type === 'move') { const dx = (e.clientX-mode.startX)/vp.scale, dy = (e.clientY-mode.startY)/vp.scale; setItems(prev => prev.map(i => { const o = mode.origins.find(o => o.id === i.id); return o ? { ...i, x: o.x+dx, y: o.y+dy } : i; })); }
|
| 148 |
+
else if (mode.type === 'resize') { const dx = (e.clientX-mode.startX)/vp.scale, dy = (e.clientY-mode.startY)/vp.scale; let nw=mode.ow, nh=mode.oh, nx=mode.ox, ny=mode.oy; const h=mode.handle; if(h.includes('r'))nw=Math.max(50,mode.ow+dx); if(h.includes('l')){nw=Math.max(50,mode.ow-dx);nx=mode.ox+dx;} if(h.includes('b'))nh=Math.max(50,mode.oh+dy); if(h.includes('t')){nh=Math.max(50,mode.oh-dy);ny=mode.oy+dy;} if(e.shiftKey){nh=nw/mode.aspect;} setItems(prev => prev.map(i => i.id===mode.id ? {...i,x:nx,y:ny,w:nw,h:nh} : i)); }
|
| 149 |
+
else if (mode.type === 'rotate') { const angle = Math.atan2(e.clientY-mode.centerY, e.clientX-mode.centerX)*180/Math.PI; setItems(prev => prev.map(i => i.id===mode.id ? {...i, rotation: mode.origRotation + angle - mode.startAngle} : i)); }
|
| 150 |
+
else if (mode.type === 'marquee') { const w = toWorld(e.clientX, e.clientY); setMode({...mode, endX: w.x, endY: w.y}); }
|
| 151 |
+
};
|
| 152 |
+
const onUp = () => {
|
| 153 |
+
if (mode?.type === 'move') { mode.ids.forEach(id => { const it = items.find(i => i.id===id); if(it) invoke('board_update_item',{item:it}).catch(()=>{}); }); }
|
| 154 |
+
if (mode?.type === 'resize' || mode?.type === 'rotate') { const it = items.find(i => i.id===mode.id); if(it) invoke('board_update_item',{item:it}).catch(()=>{}); }
|
| 155 |
+
if (mode?.type === 'marquee') { const x1=Math.min(mode.startX,mode.endX),x2=Math.max(mode.startX,mode.endX),y1=Math.min(mode.startY,mode.endY),y2=Math.max(mode.startY,mode.endY); setSelection(items.filter(i=>i.x+i.w>x1&&i.x<x2&&i.y+i.h>y1&&i.y<y2).map(i=>i.id)); }
|
| 156 |
+
setMode(null);
|
| 157 |
+
};
|
| 158 |
+
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
|
| 159 |
+
return () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); };
|
| 160 |
+
}, [mode, vp, items, toWorld]);
|
| 161 |
+
|
| 162 |
+
// ─── Keyboard ──────────────────────────────────────────────────────────────
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
const onKey = (e: KeyboardEvent) => {
|
| 165 |
+
if (e.key === 'Delete' || e.key === 'Backspace') { if (selection.length) deleteWithUndo(selection); }
|
| 166 |
+
if (e.key === 'a' && (e.ctrlKey||e.metaKey)) { e.preventDefault(); setSelection(items.map(i=>i.id)); }
|
| 167 |
+
if (e.key === 'z' && (e.ctrlKey||e.metaKey) && !e.shiftKey) { e.preventDefault(); undo(); }
|
| 168 |
+
if (e.key === 'z' && (e.ctrlKey||e.metaKey) && e.shiftKey) { e.preventDefault(); redo(); }
|
| 169 |
+
if (e.key === 'Z' && (e.ctrlKey||e.metaKey)) { e.preventDefault(); redo(); }
|
| 170 |
+
if (e.key === 'd' && !e.ctrlKey) { if (selection.length) selection.forEach(id => updateItemWithUndo(id, { desaturated: !items.find(i=>i.id===id)?.desaturated })); else setGlobalDesat(g=>!g); }
|
| 171 |
+
if (e.key === 'g' && !e.ctrlKey) setShowGrid(g=>!g);
|
| 172 |
+
if (e.key === 't' && !e.ctrlKey) toggleAlwaysOnTop();
|
| 173 |
+
if (e.key === 'l' && !e.ctrlKey) setLibOpen(o=>!o);
|
| 174 |
+
if (e.key === 'Escape') { setSelection([]); setCtxMenu(null); }
|
| 175 |
+
if (e.key === ']') { if (selection.length) selection.forEach(id => updateItemWithUndo(id, { z: nextZ.current++ })); }
|
| 176 |
+
if (e.key === '[') { if (selection.length) selection.forEach(id => updateItemWithUndo(id, { z: 0 })); }
|
| 177 |
+
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selection.length) { const d=e.shiftKey?10:1; const dx=e.key==='ArrowRight'?d:e.key==='ArrowLeft'?-d:0; const dy=e.key==='ArrowDown'?d:e.key==='ArrowUp'?-d:0; selection.forEach(id=>{ const it=items.find(i=>i.id===id); if(it){ const next={...it,x:it.x+dx,y:it.y+dy}; setItems(p=>p.map(i=>i.id===id?next:i)); invoke('board_update_item',{item:next}).catch(()=>{}); } }); }
|
| 178 |
+
};
|
| 179 |
+
window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
|
| 180 |
+
}, [selection, items, undoStack, redoStack, globalDesat, alwaysOnTop]);
|
| 181 |
+
|
| 182 |
+
// ─── Context Menu Actions ──────────────────────────────────────────────────
|
| 183 |
+
const ctxActions = {
|
| 184 |
+
fitToView: (id: string) => { const it = items.find(i=>i.id===id); if(!it) return; const r=boardRef.current!.getBoundingClientRect(); const scale = Math.min(r.width*0.8/it.w, r.height*0.8/it.h); setVp({ x: r.width/2 - (it.x+it.w/2)*scale, y: r.height/2 - (it.y+it.h/2)*scale, scale }); },
|
| 185 |
+
desaturate: (id: string) => updateItemWithUndo(id, { desaturated: !items.find(i=>i.id===id)?.desaturated }),
|
| 186 |
+
flipH: (id: string) => updateItemWithUndo(id, { flipped: !items.find(i=>i.id===id)?.flipped }),
|
| 187 |
+
flipV: (id: string) => updateItemWithUndo(id, { flipV: !items.find(i=>i.id===id)?.flipV }),
|
| 188 |
+
extractPalette: async (id: string) => { const it = items.find(i=>i.id===id); if(!it?.data_url) return; const colors = await extractColors(it.data_url, 5); if(colors.length) { updateItemWithUndo(id, { colors }); invoke('board_add_palette',{colors, x:it.x+it.w+20, y:it.y}).then(()=>loadBoard()); } },
|
| 189 |
+
copyImage: (id: string) => { const it = items.find(i=>i.id===id); if(it?.data_url) navigator.clipboard.writeText(it.data_url); },
|
| 190 |
+
openSource: (id: string) => { const it = items.find(i=>i.id===id); if(it?.source_url) invoke('tab_create',{url:it.source_url, layout:{x:0,y:0,width:1,height:1}}).catch(()=>{}); },
|
| 191 |
+
delete: (id: string) => deleteWithUndo([id]),
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
// ─── Item interaction ──────────────────────────────────────────────────────
|
| 195 |
+
const selectItem = (id: string, e: React.PointerEvent) => { if(e.ctrlKey||e.metaKey){setSelection(s=>s.includes(id)?s.filter(x=>x!==id):[...s,id])}else if(!selection.includes(id)){setSelection([id])} };
|
| 196 |
+
const startMove = (id: string, e: React.PointerEvent) => { e.stopPropagation(); setCtxMenu(null); const ids=selection.includes(id)?selection:[id]; if(!selection.includes(id))setSelection([id]); setMode({type:'move',ids,startX:e.clientX,startY:e.clientY,origins:ids.map(sid=>{const it=items.find(i=>i.id===sid)!;return{id:sid,x:it.x,y:it.y}})}); };
|
| 197 |
+
const startResize = (id: string, handle: string, e: React.PointerEvent) => { e.stopPropagation(); const it=items.find(i=>i.id===id)!; setMode({type:'resize',id,handle,startX:e.clientX,startY:e.clientY,ox:it.x,oy:it.y,ow:it.w,oh:it.h,aspect:it.w/it.h}); };
|
| 198 |
+
const startRotate = (id: string, e: React.PointerEvent) => { e.stopPropagation(); const it=items.find(i=>i.id===id)!; const r=boardRef.current!.getBoundingClientRect(); const cx=r.left+vp.x+(it.x+it.w/2)*vp.scale; const cy=r.top+vp.y+(it.y+it.h/2)*vp.scale; setMode({type:'rotate',id,centerX:cx,centerY:cy,startAngle:Math.atan2(e.clientY-cy,e.clientX-cx)*180/Math.PI,origRotation:it.rotation||0}); };
|
| 199 |
+
|
| 200 |
+
// ─── Board management ──────────────────────────────────────────────────────
|
| 201 |
+
const createBoard = () => invoke<BoardDocument>('board_create',{title:newName.trim()||'Untitled Board'}).then(d=>{setDoc(d);setItems(d.items);setNewName('');setPickerOpen(false);loadBoards()});
|
| 202 |
+
const openBoard = (id: string) => invoke<BoardDocument>('board_open',{id}).then(d=>{setDoc(d);setItems(d.items);setPickerOpen(false);loadBoards()});
|
| 203 |
+
const addImage = (p: {id?:string;data_url:string;width?:number;height?:number;source_url?:string}, pos?:{x:number;y:number}) => { const pt=pos||center(); const aspect=p.width&&p.height?p.width/p.height:1.5; const w=Math.min(420,Math.max(140,(p.width||300)/3.5)); const h=w/aspect; invoke<BoardItem>('board_add_image',{libraryId:p.id||null,dataUrl:p.data_url,x:pt.x-w/2,y:pt.y-h/2,w,h}).then(it=>{pushUndo({type:'add',item:it});loadBoard()}).catch(console.error); };
|
| 204 |
+
const addNote = () => { const pt=center(); invoke<BoardItem>('board_add_note',{text:'',x:pt.x,y:pt.y}).then(it=>{pushUndo({type:'add',item:it});loadBoard()}); };
|
| 205 |
+
const onDrop = (e: React.DragEvent) => { e.preventDefault(); try{const p=JSON.parse(e.dataTransfer.getData('application/x-muse-library-item')||e.dataTransfer.getData('text/plain')); if(p?.data_url)addImage(p,toWorld(e.clientX,e.clientY))}catch{} };
|
| 206 |
+
const filtered = libQuery ? libItems.filter(i=>i.title.toLowerCase().includes(libQuery.toLowerCase())) : libItems;
|
| 207 |
+
const marqueeRect = mode?.type==='marquee' ? (()=>{ const x=Math.min(mode.startX,mode.endX),y=Math.min(mode.startY,mode.endY),w=Math.abs(mode.endX-mode.startX),h=Math.abs(mode.endY-mode.startY); return {left:x*vp.scale+vp.x,top:y*vp.scale+vp.y,width:w*vp.scale,height:h*vp.scale}; })() : null;
|
| 208 |
+
|
| 209 |
+
return (
|
| 210 |
+
<motion.div initial={{opacity:0,y:10}} animate={{opacity:1,y:0}} exit={{opacity:0,y:-10}} transition={defaultTransition} className="w-full h-full bg-dusk-bg flex flex-col overflow-hidden select-none" style={{opacity:globalOpacity}}>
|
| 211 |
+
{/* Header toolbar - auto-hides concept from SRS */}
|
| 212 |
+
<div className="h-11 shrink-0 border-b border-dusk-border/60 bg-dusk-bg flex items-center justify-between px-4 z-30">
|
| 213 |
+
<div className="flex items-center gap-2">
|
| 214 |
+
<button onClick={()=>setPickerOpen(true)} className="text-[13px] font-semibold text-dusk-text hover:text-dusk-accent flex items-center gap-1.5">{doc?.title||'Board'}<FolderOpen className="w-3.5 h-3.5 opacity-50"/></button>
|
| 215 |
+
<span className="text-[10px] text-dusk-text-muted bg-dusk-surface px-1.5 py-0.5 rounded border border-dusk-border/50">{items.length}</span>
|
| 216 |
+
<span className="text-[10px] text-dusk-text-muted ml-2">{Math.round(vp.scale*100)}%</span>
|
| 217 |
+
</div>
|
| 218 |
+
<div className="flex items-center gap-0.5">
|
| 219 |
+
<Btn onClick={undo} tip="Undo (Ctrl+Z)" disabled={!undoStack.length}><Undo2 className="w-3.5 h-3.5"/></Btn>
|
| 220 |
+
<Btn onClick={redo} tip="Redo (Ctrl+Shift+Z)" disabled={!redoStack.length}><Redo2 className="w-3.5 h-3.5"/></Btn>
|
| 221 |
+
<Sep/>
|
| 222 |
+
<Btn onClick={toggleAlwaysOnTop} tip="Always on Top (T)" active={alwaysOnTop}><Pin className="w-3.5 h-3.5"/></Btn>
|
| 223 |
+
{alwaysOnTop && <input type="range" min="0.2" max="1" step="0.05" value={globalOpacity} onChange={e=>setGlobalOpacity(parseFloat(e.target.value))} className="w-16 h-1 accent-dusk-accent" title="Window Opacity"/>}
|
| 224 |
+
<Sep/>
|
| 225 |
+
<Btn onClick={()=>setGlobalDesat(g=>!g)} tip="Desaturate All (D)" active={globalDesat}><Eye className="w-3.5 h-3.5"/></Btn>
|
| 226 |
+
<Btn onClick={()=>setShowGrid(g=>!g)} tip="Grid (G)" active={showGrid}><Grid className="w-3.5 h-3.5"/></Btn>
|
| 227 |
+
<Sep/>
|
| 228 |
+
<Btn onClick={addNote} tip="Add Note"><Type className="w-3.5 h-3.5"/></Btn>
|
| 229 |
+
<Btn onClick={()=>setPickerOpen(true)} tip="Boards"><FilePlus2 className="w-3.5 h-3.5"/></Btn>
|
| 230 |
+
<Btn onClick={()=>setLibOpen(!libOpen)} tip="Library (L)" active={libOpen}><Library className="w-3.5 h-3.5"/></Btn>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{/* Picker */}
|
| 235 |
+
<AnimatePresence>{pickerOpen&&<motion.div initial={{opacity:0,y:-8}} animate={{opacity:1,y:0}} exit={{opacity:0,y:-8}} className="absolute top-12 left-16 z-50 w-[340px] bg-dusk-surface border border-dusk-border rounded-2xl shadow-2xl p-3 flex flex-col gap-2"><div className="flex justify-between"><b className="text-sm">Boards</b><button onClick={()=>setPickerOpen(false)}><X className="w-4 h-4 text-dusk-text-muted"/></button></div><div className="flex gap-2"><input value={newName} onChange={e=>setNewName(e.target.value)} onKeyDown={e=>{if(e.key==='Enter')createBoard()}} placeholder="New board..." className="flex-1 bg-dusk-bg border border-dusk-border rounded-lg px-3 py-1.5 text-xs"/><button onClick={createBoard} className="px-2 py-1.5 rounded-lg bg-dusk-accent/20 text-dusk-accent text-xs font-semibold">Create</button></div><div className="flex flex-col gap-1 max-h-48 overflow-auto">{boards.map(b=><button key={b.id} onClick={()=>openBoard(b.id)} className="p-2 rounded-lg bg-dusk-bg hover:bg-dusk-surface-hover border border-dusk-border/50 text-left text-xs"><b>{b.title}</b> <span className="text-dusk-text-muted">({b.item_count})</span></button>)}</div></motion.div>}</AnimatePresence>
|
| 236 |
+
|
| 237 |
+
{/* Context Menu */}
|
| 238 |
+
<AnimatePresence>{ctxMenu&&<motion.div initial={{opacity:0,scale:0.95}} animate={{opacity:1,scale:1}} exit={{opacity:0,scale:0.95}} style={{left:ctxMenu.x,top:ctxMenu.y}} className="fixed z-[100] w-52 py-1 bg-dusk-surface border border-dusk-border rounded-xl shadow-2xl">
|
| 239 |
+
<CtxBtn onClick={()=>{ctxActions.fitToView(ctxMenu.itemId);setCtxMenu(null)}}><Maximize className="w-3.5 h-3.5"/>Fit to View</CtxBtn>
|
| 240 |
+
<CtxBtn onClick={()=>{ctxActions.desaturate(ctxMenu.itemId);setCtxMenu(null)}}><Eye className="w-3.5 h-3.5"/>Desaturate</CtxBtn>
|
| 241 |
+
<CtxBtn onClick={()=>{ctxActions.flipH(ctxMenu.itemId);setCtxMenu(null)}}><FlipHorizontal className="w-3.5 h-3.5"/>Flip Horizontal</CtxBtn>
|
| 242 |
+
<CtxBtn onClick={()=>{ctxActions.flipV(ctxMenu.itemId);setCtxMenu(null)}}><FlipVertical className="w-3.5 h-3.5"/>Flip Vertical</CtxBtn>
|
| 243 |
+
<div className="h-px bg-dusk-border my-1 mx-2"/>
|
| 244 |
+
<CtxBtn onClick={()=>{ctxActions.extractPalette(ctxMenu.itemId);setCtxMenu(null)}}><Palette className="w-3.5 h-3.5"/>Extract Palette</CtxBtn>
|
| 245 |
+
<CtxBtn onClick={()=>{ctxActions.copyImage(ctxMenu.itemId);setCtxMenu(null)}}><Copy className="w-3.5 h-3.5"/>Copy Image</CtxBtn>
|
| 246 |
+
<CtxBtn onClick={()=>{ctxActions.openSource(ctxMenu.itemId);setCtxMenu(null)}}><ExternalLink className="w-3.5 h-3.5"/>Open Source URL</CtxBtn>
|
| 247 |
+
<div className="h-px bg-dusk-border my-1 mx-2"/>
|
| 248 |
+
<CtxBtn onClick={()=>{ctxActions.delete(ctxMenu.itemId);setCtxMenu(null)}} danger><Trash2 className="w-3.5 h-3.5"/>Delete</CtxBtn>
|
| 249 |
+
</motion.div>}</AnimatePresence>
|
| 250 |
+
|
| 251 |
+
{/* Canvas + Library */}
|
| 252 |
+
<div className="flex-1 flex overflow-hidden">
|
| 253 |
+
<div ref={boardRef} className="flex-1 relative overflow-hidden bg-[#1c1c1e] touch-none" onWheel={onWheel} onPointerDown={onBoardDown} onContextMenu={e=>e.preventDefault()} onDrop={onDrop} onDragOver={e=>{e.preventDefault();e.dataTransfer.dropEffect='copy'}} onClick={()=>setCtxMenu(null)}>
|
| 254 |
+
{showGrid&&<div className="absolute inset-0 pointer-events-none opacity-[0.06]" style={{backgroundImage:'radial-gradient(circle,#e5e5e7 1px,transparent 1px)',backgroundSize:`${24*vp.scale}px ${24*vp.scale}px`,backgroundPosition:`${vp.x%(24*vp.scale)}px ${vp.y%(24*vp.scale)}px`}}/>}
|
| 255 |
+
<div className="absolute left-0 top-0 will-change-transform" style={{transform:`translate(${vp.x}px,${vp.y}px) scale(${vp.scale})`,transformOrigin:'0 0',filter:globalDesat?'grayscale(1)':'none'}}>
|
| 256 |
+
{items.sort((a,b)=>(a.z||0)-(b.z||0)).map(item=><ItemNode key={item.id} item={item} selected={selection.includes(item.id)} onPointerDown={e=>{selectItem(item.id,e);startMove(item.id,e)}} onContextMenu={e=>{e.preventDefault();e.stopPropagation();setSelection([item.id]);setCtxMenu({x:e.clientX,y:e.clientY,itemId:item.id})}} onResizeStart={(h,e)=>startResize(item.id,h,e)} onRotateStart={e=>startRotate(item.id,e)} globalDesat={globalDesat}/>)}
|
| 257 |
+
</div>
|
| 258 |
+
{marqueeRect&&<div className="absolute border border-[#0A84FF]/60 bg-[#0A84FF]/10 pointer-events-none" style={marqueeRect}/>}
|
| 259 |
+
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 bg-[#2a2a2e]/90 border border-[#3a3a3e] rounded-lg px-2 py-1 text-[10px] text-[#e5e5e7]">
|
| 260 |
+
<button onClick={()=>setVp(v=>({...v,scale:Math.max(0.04,v.scale/1.25)}))}><Minus className="w-3 h-3"/></button>
|
| 261 |
+
<span className="w-9 text-center">{Math.round(vp.scale*100)}%</span>
|
| 262 |
+
<button onClick={()=>setVp(v=>({...v,scale:Math.min(16,v.scale*1.25)}))}><Plus className="w-3 h-3"/></button>
|
| 263 |
+
</div>
|
| 264 |
+
{items.length===0&&<div className="absolute inset-0 flex flex-col items-center justify-center text-[#88888a] pointer-events-none gap-2"><ImageIcon className="w-12 h-12 opacity-15"/><p className="text-sm">Add references from Library (L) or drag images here</p><p className="text-[11px] opacity-60">Space+drag=pan · Scroll=zoom · Shift+drag=marquee · D=desat · T=pin</p></div>}
|
| 265 |
+
</div>
|
| 266 |
+
<AnimatePresence>{libOpen&&<motion.div initial={{width:0,opacity:0}} animate={{width:240,opacity:1}} exit={{width:0,opacity:0}} transition={{duration:0.2}} className="h-full bg-[#2a2a2e] border-l border-[#3a3a3e] flex flex-col overflow-hidden shrink-0">
|
| 267 |
+
<div className="h-10 border-b border-[#3a3a3e] flex items-center justify-between px-3 shrink-0"><span className="text-[12px] font-semibold text-[#e5e5e7]">Library</span><button onClick={()=>setLibOpen(false)} className="text-[#88888a]"><X className="w-3.5 h-3.5"/></button></div>
|
| 268 |
+
<div className="p-2 flex flex-col gap-2 flex-1 overflow-auto"><div className="relative"><Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[#88888a]"/><input value={libQuery} onChange={e=>setLibQuery(e.target.value)} placeholder="Search..." className="w-full bg-[#1c1c1e] border border-[#3a3a3e] rounded py-1 pl-6 pr-2 text-[11px] text-[#e5e5e7] focus:outline-none focus:border-[#0a84ff]"/></div><div className="grid grid-cols-2 gap-1.5">{filtered.map(it=><div key={it.id} className="aspect-square rounded overflow-hidden border border-[#3a3a3e] bg-[#1c1c1e] relative group cursor-grab" draggable onDragStart={e=>{const p=JSON.stringify({id:it.id,data_url:it.data_url,width:it.width,height:it.height,source_url:it.source_url});e.dataTransfer.setData('application/x-muse-library-item',p);e.dataTransfer.setData('text/plain',p);e.dataTransfer.effectAllowed='copy'}}><img src={it.data_url} className="w-full h-full object-cover" draggable={false}/><button onClick={()=>addImage({id:it.id,data_url:it.data_url,width:it.width,height:it.height,source_url:it.source_url})} className="absolute bottom-0.5 right-0.5 opacity-0 group-hover:opacity-100 bg-[#0a84ff] text-white text-[8px] font-bold px-1.5 py-0.5 rounded">+</button></div>)}</div></div>
|
| 269 |
+
</motion.div>}</AnimatePresence>
|
| 270 |
+
</div>
|
| 271 |
+
</motion.div>
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function ItemNode({item,selected,onPointerDown,onContextMenu,onResizeStart,onRotateStart,globalDesat}:{item:BoardItem;selected:boolean;onPointerDown:(e:React.PointerEvent)=>void;onContextMenu:(e:React.MouseEvent)=>void;onResizeStart:(h:string,e:React.PointerEvent)=>void;onRotateStart:(e:React.PointerEvent)=>void;globalDesat:boolean}){
|
| 276 |
+
const transform=`rotate(${item.rotation||0}deg)${item.flipped?' scaleX(-1)':''}${item.flipV?' scaleY(-1)':''}`;
|
| 277 |
+
const filter=(item.desaturated&&!globalDesat)?'grayscale(1)':'none';
|
| 278 |
+
return <div data-item onPointerDown={onPointerDown} onContextMenu={onContextMenu} className={cn('absolute group',selected&&'outline outline-2 outline-[#0a84ff] outline-offset-1')} style={{left:item.x,top:item.y,width:item.w,height:item.h,transform,transformOrigin:'center',zIndex:item.z||1,filter}}>
|
| 279 |
+
{item.kind==='image'&&item.data_url&&<><img src={item.data_url} className="w-full h-full object-cover pointer-events-none" draggable={false}/>{item.colors?.length>0&&<div className="absolute left-1 right-1 bottom-1 h-4 rounded overflow-hidden flex opacity-80">{item.colors.map(c=><div key={c} className="flex-1 cursor-pointer" style={{backgroundColor:c}} title={c} onClick={e=>{e.stopPropagation();navigator.clipboard.writeText(c)}}/>)}</div>}</>}
|
| 280 |
+
{item.kind==='note'&&<div className="w-full h-full bg-[#4A4B3A] text-[#F3ECD8] p-2 text-[11px] font-serif leading-relaxed overflow-hidden rounded-sm">{item.text||'Note'}</div>}
|
| 281 |
+
{item.kind==='palette'&&<div className="w-full h-full flex flex-col rounded-sm overflow-hidden"><div className="flex flex-1">{item.colors.map(c=><div key={c} className="flex-1 cursor-pointer" style={{backgroundColor:c}} title={c} onClick={e=>{e.stopPropagation();navigator.clipboard.writeText(c)}}/>)}</div><div className="h-4 bg-[#2a2a2e] flex items-center justify-center text-[7px] tracking-wider text-[#88888a] font-bold">PALETTE</div></div>}
|
| 282 |
+
{selected&&<><Handle pos="tl" cursor="nwse-resize" onDown={e=>onResizeStart('tl',e)}/><Handle pos="tr" cursor="nesw-resize" onDown={e=>onResizeStart('tr',e)}/><Handle pos="bl" cursor="nesw-resize" onDown={e=>onResizeStart('bl',e)}/><Handle pos="br" cursor="nwse-resize" onDown={e=>onResizeStart('br',e)}/><Handle pos="t" cursor="ns-resize" onDown={e=>onResizeStart('t',e)}/><Handle pos="b" cursor="ns-resize" onDown={e=>onResizeStart('b',e)}/><Handle pos="l" cursor="ew-resize" onDown={e=>onResizeStart('l',e)}/><Handle pos="r" cursor="ew-resize" onDown={e=>onResizeStart('r',e)}/><div onPointerDown={onRotateStart} className="absolute -top-6 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-[#0a84ff] text-white flex items-center justify-center cursor-grab shadow"><RotateCw className="w-2 h-2"/></div></>}
|
| 283 |
+
</div>;
|
| 284 |
+
}
|
| 285 |
+
function Handle({pos,cursor,onDown}:{pos:string;cursor:string;onDown:(e:React.PointerEvent)=>void}){ const s:Record<string,React.CSSProperties>={tl:{top:-3,left:-3},tr:{top:-3,right:-3},bl:{bottom:-3,left:-3},br:{bottom:-3,right:-3},t:{top:-3,left:'50%',marginLeft:-3},b:{bottom:-3,left:'50%',marginLeft:-3},l:{left:-3,top:'50%',marginTop:-3},r:{right:-3,top:'50%',marginTop:-3}}; return <div onPointerDown={onDown} className="absolute w-[6px] h-[6px] bg-white border border-[#0a84ff] rounded-[1px] opacity-0 group-hover:opacity-100" style={{...s[pos],cursor}}/>; }
|
| 286 |
+
function Btn({children,onClick,tip,active,disabled,danger}:{children:React.ReactNode;onClick:()=>void;tip?:string;active?:boolean;disabled?:boolean;danger?:boolean}){ return <button onClick={onClick} title={tip} disabled={disabled} className={cn('w-7 h-7 rounded flex items-center justify-center transition-colors disabled:opacity-30',active?'bg-[#0a84ff]/20 text-[#0a84ff]':danger?'text-red-400 hover:bg-red-400/10':'text-[#88888a] hover:text-[#e5e5e7] hover:bg-[#3a3a3e]')}>{children}</button>; }
|
| 287 |
+
function Sep(){ return <div className="w-px h-4 bg-[#3a3a3e] mx-0.5"/>; }
|
| 288 |
+
function CtxBtn({children,onClick,danger}:{children:React.ReactNode;onClick:()=>void;danger?:boolean}){ return <button onClick={onClick} className={cn('w-full flex items-center gap-2.5 px-3 py-1.5 text-[12px] transition-colors',danger?'text-red-400 hover:bg-red-400/10':'text-[#e5e5e7] hover:bg-[#3a3a3e]')}>{children}</button>; }
|