diff --git a/src/lib/Toolbar.svelte b/src/lib/Toolbar.svelte index 2910a56..690c6cb 100644 --- a/src/lib/Toolbar.svelte +++ b/src/lib/Toolbar.svelte @@ -11,7 +11,6 @@ let { onundo, onclear, onexit }: Props = $props(); // --- Draggable toolbar state --- - // Start centered vertically on the right edge of the screen. let position = $state({ x: 0, y: 0 }); let positioned = $state(false); let dragging = $state(false); @@ -34,8 +33,8 @@ // Place toolbar at right-center on first layout. function initPosition(): void { if (positioned) return; - const tbW = 52; // approx toolbar width - const tbH = 380; // approx toolbar height + const tbW = 52; + const tbH = 520; position = { x: Math.max(16, window.innerWidth - tbW - 24), y: Math.max(16, Math.round((window.innerHeight - tbH) / 2)) @@ -43,7 +42,6 @@ positioned = true; } - // Start auto-fade + initial position on mount $effect(() => { initPosition(); resetFade(); @@ -54,7 +52,6 @@ // --- Dragging --- function onPointerDown(e: PointerEvent): void { - // Only start drag from the toolbar background, not from buttons if ((e.target as HTMLElement).closest('[data-btn]')) return; dragging = true; dragOffset = { x: e.clientX - position.x, y: e.clientY - position.y }; @@ -76,27 +73,34 @@ } // --- Tool selection --- - function selectTool(tool: Tool): void { - currentTool.set(tool); + function selectTool(t: Tool): void { + currentTool.set(t); resetFade(); } function selectColor(color: string): void { currentColor.set(color); - // If eraser was selected, switch back to pen when choosing a color currentTool.update((t) => (t === 'eraser' ? 'pen' : t)); resetFade(); } - // Auto-subscribe to stores (Svelte 5 $store syntax in template) + // --- Thickness slider --- + function changeThickness(delta: number): void { + currentThickness.update((t) => Math.max(2, Math.min(20, t + delta))); + resetFade(); + } - const tools: { id: Tool; icon: string; label: string }[] = [ - { id: 'pen', icon: '✏️', label: 'Pen' }, - { id: 'highlighter', icon: '🖍️', label: 'Highlighter' }, - { id: 'eraser', icon: '⌫', label: 'Eraser' } + const tools: { id: Tool; icon: string; label: string; key: string }[] = [ + { id: 'pen', icon: '✏️', label: 'Pen', key: 'P' }, + { id: 'highlighter', icon: '🖍️', label: 'Highlighter', key: 'H' }, + { id: 'line', icon: '╱', label: 'Line', key: 'L' }, + { id: 'arrow', icon: '→', label: 'Arrow', key: 'A' }, + { id: 'rectangle', icon: '▭', label: 'Rectangle', key: 'R' }, + { id: 'ellipse', icon: '◯', label: 'Ellipse', key: 'O' }, + { id: 'eraser', icon: '⌫', label: 'Eraser', key: 'E' } ]; - const colors = ['#ef4444', '#facc15', '#22c55e', '#ffffff']; + const colors = ['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#ffffff']; @@ -113,18 +117,18 @@ role="toolbar" aria-label="DrawOver toolbar" > - - {#each tools as tool (tool.id)} + + {#each tools as t (t.id)} {/each} @@ -150,18 +154,32 @@
+ +
+ + {$currentThickness} + +
+ + +
+ - - - @@ -175,11 +193,11 @@ gap: 4px; padding: 8px 6px; border-radius: 16px; - background: rgba(30, 30, 36, 0.72); + background: rgba(26, 26, 31, 0.82); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); z-index: 9999; opacity: 0.25; transition: opacity 0.3s ease; @@ -202,13 +220,13 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 36px; + height: 36px; border-radius: 10px; border: none; background: transparent; color: rgba(255, 255, 255, 0.8); - font-size: 15px; + font-size: 16px; cursor: pointer; transition: background 0.15s ease, transform 0.1s ease; padding: 0; @@ -223,9 +241,9 @@ } .tool-btn.active { - background: rgba(99, 162, 255, 0.3); + background: rgba(239, 89, 63, 0.25); color: #fff; - box-shadow: inset 0 0 0 1px rgba(99, 162, 255, 0.5); + box-shadow: inset 0 0 0 1px rgba(239, 89, 63, 0.5); } .color-btn { @@ -234,8 +252,8 @@ .color-btn .dot { display: block; - width: 16px; - height: 16px; + width: 18px; + height: 18px; border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, 0.3); transition: transform 0.1s ease; @@ -244,21 +262,42 @@ .color-btn.active .dot { transform: scale(1.15); border-color: rgba(255, 255, 255, 0.9); - box-shadow: 0 0 0 2px rgba(99, 162, 255, 0.4); + box-shadow: 0 0 0 2px rgba(239, 89, 63, 0.4); } .color-btn.active { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.08); } .divider { - width: 22px; + width: 28px; height: 1px; - background: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.12); margin: 2px 0; flex-shrink: 0; } + .size-row { + display: flex; + align-items: center; + gap: 2px; + } + + .size-btn { + width: 28px; + height: 28px; + font-size: 14px; + font-weight: 600; + } + + .size-display { + font-size: 12px; + font-family: monospace; + color: rgba(255, 255, 255, 0.6); + min-width: 20px; + text-align: center; + } + .exit-btn { color: rgba(255, 100, 100, 0.9); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 90d9fa5..988bd8d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,4 @@ -export type Tool = 'pen' | 'highlighter' | 'eraser'; +export type Tool = 'pen' | 'highlighter' | 'eraser' | 'line' | 'arrow' | 'rectangle' | 'ellipse'; export interface Point { x: number; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b6e74f3..7b67018 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -15,9 +15,8 @@ let isDrawing = $state(false); let currentPoints: Point[] = []; let dpr = 1; - let pdown = $state(0); - // Store subscription values for template reactivity + // Store subscription values let drawModeOn = $state(false); let tool = $state('pen'); let color = $state('#ef4444'); @@ -28,6 +27,13 @@ const unsubColor = currentColor.subscribe((v) => (color = v)); const unsubThickness = currentThickness.subscribe((v) => (thickness = v)); + // ─── Helpers ─── + const SHAPE_TOOLS: Tool[] = ['line', 'arrow', 'rectangle', 'ellipse']; + + function isShape(t: Tool): boolean { + return SHAPE_TOOLS.includes(t); + } + // ─── Canvas setup ─── function setupCanvas(): void { if (!canvas) return; @@ -53,8 +59,6 @@ } function onPointerDown(e: PointerEvent): void { - pdown++; - console.log('[DrawOver] pointerdown', { drawModeOn, button: e.button, type: e.pointerType }); if (!drawModeOn) return; if (e.button !== 0 && e.pointerType === 'mouse') return; e.preventDefault(); @@ -68,8 +72,13 @@ if (!isDrawing) return; e.preventDefault(); - currentPoints.push(getPoint(e)); - // Draw incrementally: render the last segment(s) for performance + const pt = getPoint(e); + if (isShape(tool)) { + // For shapes, we only keep start + current point + currentPoints = [currentPoints[0], pt]; + } else { + currentPoints.push(pt); + } renderIncremental(); } @@ -81,7 +90,11 @@ canvas.releasePointerCapture?.(e.pointerId); if (currentPoints.length < 2) { - // Treat as a dot — create a minimal stroke + // Dot for pen/highlighter; ignore for shapes + if (isShape(tool)) { + currentPoints = []; + return; + } const p = currentPoints[0]; currentPoints = [ { x: p.x - 0.5, y: p.y }, @@ -92,10 +105,10 @@ const stroke: Stroke = { id: crypto.randomUUID(), points: [...currentPoints], - color: tool === 'eraser' ? '#000000' : color, + color: color, width: tool === 'eraser' ? 30 : tool === 'highlighter' ? thickness * 4 : thickness, tool: tool, - opacity: tool === 'highlighter' ? 0.4 : tool === 'eraser' ? 1 : 1 + opacity: tool === 'highlighter' ? 0.4 : 1 }; strokes = [...strokes, stroke]; @@ -115,7 +128,58 @@ } } + function drawArrowHead(fromX: number, fromY: number, toX: number, toY: number): void { + const headLen = Math.max(12, ctx.lineWidth * 2.5); + const angle = Math.atan2(toY - fromY, toX - fromX); + ctx.beginPath(); + ctx.moveTo(toX, toY); + ctx.lineTo( + toX - headLen * Math.cos(angle - Math.PI / 6), + toY - headLen * Math.sin(angle - Math.PI / 6) + ); + ctx.moveTo(toX, toY); + ctx.lineTo( + toX - headLen * Math.cos(angle + Math.PI / 6), + toY - headLen * Math.sin(angle + Math.PI / 6) + ); + ctx.stroke(); + } + + function drawShape(s: Stroke): void { + if (s.points.length < 2) return; + const [a, b] = [s.points[0], s.points[s.points.length - 1]]; + + ctx.beginPath(); + if (s.tool === 'line') { + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } else if (s.tool === 'arrow') { + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + drawArrowHead(a.x, a.y, b.x, b.y); + } else if (s.tool === 'rectangle') { + ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y); + } else if (s.tool === 'ellipse') { + const cx = (a.x + b.x) / 2; + const cy = (a.y + b.y) / 2; + const rx = Math.abs(b.x - a.x) / 2; + const ry = Math.abs(b.y - a.y) / 2; + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + ctx.stroke(); + } + } + function drawStroke(s: Stroke): void { + if (isShape(s.tool)) { + ctx.save(); + setStrokeStyle(s); + drawShape(s); + ctx.restore(); + return; + } + if (s.points.length < 2) return; ctx.save(); @@ -123,13 +187,12 @@ const pts = s.points; if (pts.length === 2) { - // Straight line for 2-point stroke ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y); ctx.stroke(); } else { - // Catmull-Rom spline interpolation for smooth lines + // Catmull-Rom spline interpolation ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); @@ -139,7 +202,6 @@ const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2; - // Catmull-Rom to Bezier conversion (tension = 0.5) const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; @@ -153,30 +215,35 @@ ctx.restore(); } - /** - * Render the in-progress stroke using Catmull-Rom for the committed points - * plus a direct line to the current cursor position for immediate feedback. - */ + /** Render the in-progress stroke (freehand or shape preview) */ function renderIncremental(): void { redraw(); - if (currentPoints.length < 2) return; ctx.save(); - // Apply current tool style to the in-progress stroke - const isHighlighter = tool === 'highlighter'; - const isEraser = tool === 'eraser'; - - ctx.globalCompositeOperation = isEraser ? 'destination-out' : isHighlighter ? 'multiply' : 'source-over'; - ctx.globalAlpha = isHighlighter ? 0.4 : 1; + const isHl = tool === 'highlighter'; + const isEr = tool === 'eraser'; + ctx.globalCompositeOperation = isEr ? 'destination-out' : isHl ? 'multiply' : 'source-over'; + ctx.globalAlpha = isHl ? 0.4 : 1; ctx.strokeStyle = color; - ctx.lineWidth = isEraser ? 30 : isHighlighter ? thickness * 4 : thickness; + ctx.lineWidth = isEr ? 30 : isHl ? thickness * 4 : thickness; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const pts = currentPoints; - if (pts.length === 2) { + if (isShape(tool)) { + // Shape preview — draw from the preview stroke + const previewStroke: Stroke = { + id: 'preview', + points: pts, + color, + width: thickness, + tool, + opacity: 1 + }; + drawShape(previewStroke); + } else if (pts.length === 2) { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y); @@ -184,18 +251,15 @@ } else { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); - for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2; - const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); } ctx.stroke(); @@ -216,7 +280,6 @@ async function toggleDrawMode(): Promise { try { const result = await invoke('toggle_draw_mode'); - console.log('[DrawOver] toggle_draw_mode result:', result); drawMode.set(result); } catch (e) { console.error('[DrawOver] toggle_draw_mode failed:', e); @@ -243,37 +306,88 @@ } } + // ─── Keyboard shortcuts ─── + const TOOL_KEYS: Record = { + p: 'pen', + h: 'highlighter', + l: 'line', + a: 'arrow', + r: 'rectangle', + o: 'ellipse', + e: 'eraser' + }; + + function handleKeydown(e: KeyboardEvent): void { + if (!drawModeOn) return; + + // Undo + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undo(); + return; + } + + // Clear all + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + clearAll(); + return; + } + + // Brush size + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + currentThickness.update((t) => Math.min(20, t + 2)); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + currentThickness.update((t) => Math.max(2, t - 2)); + return; + } + + // Tool selection + const k = e.key.toLowerCase(); + if (TOOL_KEYS[k] && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + currentTool.set(TOOL_KEYS[k]); + } + + // Color shortcuts (1-7) + const colorMap = ['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#ffffff']; + const numKey = parseInt(e.key); + if (numKey >= 1 && numKey <= 7 && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + currentColor.set(colorMap[numKey - 1]); + } + } + // ─── Tauri event listeners ─── onMount(() => { setupCanvas(); window.addEventListener('resize', onResize); + window.addEventListener('keydown', handleKeydown); - // Listen for draw mode toggle from global hotkey (Rust side) let unlistenDrawToggle: UnlistenFn | null = null; listen('draw-mode-toggled', (event) => { - console.log('[DrawOver] draw-mode-toggled event:', event.payload); drawMode.set(event.payload); }) .then((un) => { unlistenDrawToggle = un; }) - .catch(() => { - // Not in Tauri context (e.g. browser dev) — ignore - }); + .catch(() => {}); - // Sync initial state from Rust backend invoke('is_draw_mode') .then((v) => { - console.log('[DrawOver] initial is_draw_mode:', v); drawMode.set(v); }) .catch(() => {}); - // Clean up on destroy return () => { window.removeEventListener('resize', onResize); + window.removeEventListener('keydown', handleKeydown); unsubDrawMode(); unsubTool(); unsubColor(); @@ -282,9 +396,8 @@ }; }); - // ─── Redraw when draw mode toggles ─── + // ─── Redraw when state changes ─── $effect(() => { - // Re-render when strokes change or draw mode changes if (drawModeOn !== undefined) { redraw(); } @@ -294,16 +407,16 @@
-
- mode:{drawModeOn} tool:{tool} cls:{drawModeOn ? 'CAPTURE' : 'pass'} pdown:{pdown} strokes:{strokes.length} -
- - + {#if drawModeOn} + + {/if}