asdf98 commited on
Commit
29cc955
·
verified ·
1 Parent(s): b69eb83

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
Files changed (1) hide show
  1. src/components/views/BoardView.tsx +288 -0
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>; }