From cf7a9934bce817d414c6f069ef4936daeecbfa1d Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 19 Feb 2026 16:53:21 +0800 Subject: [PATCH] feat(canvas): implement pen tool functionality and enhance object scaling - Introduce a new pen tool for freehand drawing, allowing users to create custom paths on the canvas. - Cache native dimensions of objects before scaling to ensure accurate rendering. - Update object synchronization to utilize cached dimensions, improving scaling accuracy during transformations. - Enhance event handling to support pen tool interactions, including pointer down, move, and up events. - Add a shape tool dropdown in the toolbar for easy access to shape tools, including the new pen tool. - Implement an icon picker dialog for importing SVG icons into the canvas. --- src/canvas/canvas-object-factory.ts | 16 +- src/canvas/canvas-object-sync.ts | 7 + src/canvas/pen-tool.ts | 504 ++++++++++++++++++ src/canvas/use-canvas-events.ts | 75 ++- src/canvas/use-frame-labels.ts | 10 +- src/components/editor/shape-tool-dropdown.tsx | 169 ++++++ src/components/editor/toolbar.tsx | 89 ++-- src/components/shared/icon-picker-dialog.tsx | 208 ++++++++ src/hooks/use-keyboard-shortcuts.ts | 10 + 9 files changed, 1028 insertions(+), 60 deletions(-) create mode 100644 src/canvas/pen-tool.ts create mode 100644 src/components/editor/shape-tool-dropdown.tsx create mode 100644 src/components/shared/icon-picker-dialog.tsx diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index 94a6305d..da9f9ce4 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -264,15 +264,29 @@ export function createFabricObject( stroke: resolveStrokeColor(node.stroke), strokeWidth: resolveStrokeWidth(node.stroke), }) as FabricObjectWithPenId + // Cache native dimensions before scaling (Polygon width/height is derived from points) + ;(obj as any).__nativeWidth = obj.width + ;(obj as any).__nativeHeight = obj.height + if (w > 0 && h > 0 && obj.width && obj.height) { + obj.set({ scaleX: w / obj.width, scaleY: h / obj.height }) + } break } case 'path': { + const pw = sizeToNumber(node.width, 0) + const ph = sizeToNumber(node.height, 0) obj = new fabric.Path(node.d, { ...baseProps, - fill: resolveFill(node.fill, sizeToNumber(node.width, 100), sizeToNumber(node.height, 100)), + fill: resolveFill(node.fill, pw || 100, ph || 100), stroke: resolveStrokeColor(node.stroke), strokeWidth: resolveStrokeWidth(node.stroke), }) as FabricObjectWithPenId + // Cache native dimensions before scaling (Path width/height is derived from d) + ;(obj as any).__nativeWidth = obj.width + ;(obj as any).__nativeHeight = obj.height + if (pw > 0 && ph > 0 && obj.width && obj.height) { + obj.set({ scaleX: pw / obj.width, scaleY: ph / obj.height }) + } break } case 'text': { diff --git a/src/canvas/canvas-object-sync.ts b/src/canvas/canvas-object-sync.ts index 29e88ea6..fc26b856 100644 --- a/src/canvas/canvas-object-sync.ts +++ b/src/canvas/canvas-object-sync.ts @@ -137,6 +137,13 @@ export function syncFabricObject( stroke: resolveStrokeColor(node.stroke), strokeWidth: resolveStrokeWidth(node.stroke), }) + // Use cached native dimensions (from path/points data) to compute correct + // scale, even if obj.width was previously corrupted by scale baking. + const nw = (obj as any).__nativeWidth || obj.width + const nh = (obj as any).__nativeHeight || obj.height + if (w > 0 && h > 0 && nw && nh) { + obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh }) + } break } } diff --git a/src/canvas/pen-tool.ts b/src/canvas/pen-tool.ts new file mode 100644 index 00000000..ef1a7ee3 --- /dev/null +++ b/src/canvas/pen-tool.ts @@ -0,0 +1,504 @@ +import * as fabric from 'fabric' +import { useCanvasStore } from '@/stores/canvas-store' +import { useDocumentStore, generateId } from '@/stores/document-store' +import { + DEFAULT_STROKE, + DEFAULT_STROKE_WIDTH, + SELECTION_BLUE, +} from './canvas-constants' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PenAnchorPoint { + x: number + y: number + /** Incoming control handle (offset relative to anchor). null = straight. */ + handleIn: { x: number; y: number } | null + /** Outgoing control handle (offset relative to anchor). null = straight. */ + handleOut: { x: number; y: number } | null +} + +interface PenToolState { + isActive: boolean + points: PenAnchorPoint[] + isDraggingHandle: boolean + cursorPos: { x: number; y: number } | null +} + +// --------------------------------------------------------------------------- +// Module-level state +// --------------------------------------------------------------------------- + +let state: PenToolState = { + isActive: false, + points: [], + isDraggingHandle: false, + cursorPos: null, +} + +// Temporary Fabric objects for visual feedback +let previewPath: fabric.FabricObject | null = null +let rubberBandLine: fabric.FabricObject | null = null +let anchorCircles: fabric.FabricObject[] = [] +let handleLines: fabric.FabricObject[] = [] +let handleDots: fabric.FabricObject[] = [] + +// --------------------------------------------------------------------------- +// Visual constants +// --------------------------------------------------------------------------- + +const PREVIEW_STROKE = SELECTION_BLUE +const PREVIEW_STROKE_WIDTH = 1.5 +const RUBBER_BAND_STROKE = 'rgba(13, 153, 255, 0.5)' +const RUBBER_BAND_DASH = [4, 4] +const ANCHOR_RADIUS = 4 +const ANCHOR_FILL = '#ffffff' +const ANCHOR_STROKE = SELECTION_BLUE +const ANCHOR_FIRST_RADIUS = 5 +const HANDLE_DOT_RADIUS = 3 +const HANDLE_LINE_STROKE = '#888888' +const CLOSE_HIT_THRESHOLD = 8 // screen pixels + +// --------------------------------------------------------------------------- +// Exported query +// --------------------------------------------------------------------------- + +export function isPenToolActive(): boolean { + return state.isActive +} + +// --------------------------------------------------------------------------- +// Exported event handlers +// --------------------------------------------------------------------------- + +export function penToolPointerDown( + canvas: fabric.Canvas, + scenePoint: { x: number; y: number }, +): void { + if (!state.isActive) { + // First click — start a new path + state.isActive = true + state.points = [ + { x: scenePoint.x, y: scenePoint.y, handleIn: null, handleOut: null }, + ] + state.isDraggingHandle = true + state.cursorPos = scenePoint + renderPreview(canvas) + return + } + + // Check if clicking near the first point to close the path + if (state.points.length >= 3) { + const first = state.points[0] + const zoom = useCanvasStore.getState().viewport.zoom || 1 + const threshold = CLOSE_HIT_THRESHOLD / zoom + const dist = Math.hypot( + scenePoint.x - first.x, + scenePoint.y - first.y, + ) + if (dist < threshold) { + finalizePath(canvas, true) + return + } + } + + // Add a new anchor point + state.points.push({ + x: scenePoint.x, + y: scenePoint.y, + handleIn: null, + handleOut: null, + }) + state.isDraggingHandle = true + renderPreview(canvas) +} + +export function penToolPointerMove( + canvas: fabric.Canvas, + scenePoint: { x: number; y: number }, +): void { + if (!state.isActive) return + + // Ignore if panning + const { isPanning } = useCanvasStore.getState().interaction + if (isPanning) return + + if (state.isDraggingHandle && state.points.length > 0) { + // Update handle for the current (last) point + const pt = state.points[state.points.length - 1] + const dx = scenePoint.x - pt.x + const dy = scenePoint.y - pt.y + + // Only set handles if the drag is significant (> 2px) + if (Math.hypot(dx, dy) > 2) { + pt.handleOut = { x: dx, y: dy } + pt.handleIn = { x: -dx, y: -dy } + } else { + pt.handleOut = null + pt.handleIn = null + } + } + + state.cursorPos = scenePoint + renderPreview(canvas) +} + +export function penToolPointerUp(canvas: fabric.Canvas): void { + if (!state.isActive) return + state.isDraggingHandle = false + renderPreview(canvas) +} + +export function penToolDoubleClick(canvas: fabric.Canvas): void { + if (!state.isActive) return + + // The double-click adds an extra point from the second click of the pair. + // Remove it so we don't get a duplicate degenerate segment. + if (state.points.length > 1) { + state.points.pop() + } + + finalizePath(canvas, false) +} + +/** + * Handle keyboard events during pen drawing. + * Returns true if the event was consumed. + */ +export function penToolKeyDown( + canvas: fabric.Canvas, + key: string, +): boolean { + if (!state.isActive) return false + + switch (key) { + case 'Enter': + finalizePath(canvas, false) + return true + + case 'Escape': + cancelPenTool(canvas) + return true + + case 'Backspace': { + if (state.points.length > 1) { + state.points.pop() + renderPreview(canvas) + } else { + // Only one point left — cancel entirely + cancelPenTool(canvas) + } + return true + } + + default: + return false + } +} + +export function cancelPenTool(canvas: fabric.Canvas): void { + clearPreviewObjects(canvas) + state = { + isActive: false, + points: [], + isDraggingHandle: false, + cursorPos: null, + } + useCanvasStore.getState().setActiveTool('select') +} + +// --------------------------------------------------------------------------- +// Internal: path data construction +// --------------------------------------------------------------------------- + +function buildPathData( + points: PenAnchorPoint[], + closed: boolean, +): string { + if (points.length === 0) return '' + + const parts: string[] = [] + const first = points[0] + parts.push(`M ${first.x} ${first.y}`) + + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1] + const curr = points[i] + appendSegment(parts, prev, curr) + } + + if (closed && points.length > 1) { + // Close segment from last point back to first + const last = points[points.length - 1] + appendSegment(parts, last, first) + parts.push('Z') + } + + return parts.join(' ') +} + +function appendSegment( + parts: string[], + from: PenAnchorPoint, + to: PenAnchorPoint, +): void { + const hasHandleOut = from.handleOut !== null + const hasHandleIn = to.handleIn !== null + + if (!hasHandleOut && !hasHandleIn) { + // Straight line + parts.push(`L ${to.x} ${to.y}`) + } else { + // Cubic bezier + const cx1 = from.x + (from.handleOut?.x ?? 0) + const cy1 = from.y + (from.handleOut?.y ?? 0) + const cx2 = to.x + (to.handleIn?.x ?? 0) + const cy2 = to.y + (to.handleIn?.y ?? 0) + parts.push(`C ${cx1} ${cy1} ${cx2} ${cy2} ${to.x} ${to.y}`) + } +} + +// --------------------------------------------------------------------------- +// Internal: bounding box via browser SVG engine +// --------------------------------------------------------------------------- + +function getPathBBox(d: string): { + x: number + y: number + w: number + h: number +} { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const pathEl = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path', + ) + pathEl.setAttribute('d', d) + svg.appendChild(pathEl) + document.body.appendChild(svg) + const bbox = pathEl.getBBox() + document.body.removeChild(svg) + return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height } +} + +// --------------------------------------------------------------------------- +// Internal: finalize path into a PenNode +// --------------------------------------------------------------------------- + +function finalizePath(canvas: fabric.Canvas, closed: boolean): void { + clearPreviewObjects(canvas) + + // Need at least 2 points for a valid path + if (state.points.length < 2) { + state = { + isActive: false, + points: [], + isDraggingHandle: false, + cursorPos: null, + } + useCanvasStore.getState().setActiveTool('select') + return + } + + // Build path data in absolute scene coordinates + const absD = buildPathData(state.points, closed) + const bbox = getPathBBox(absD) + + // Guard against degenerate paths + if (bbox.w < 1 && bbox.h < 1) { + state = { + isActive: false, + points: [], + isDraggingHandle: false, + cursorPos: null, + } + useCanvasStore.getState().setActiveTool('select') + return + } + + // Normalize: translate all points so the path origin is at (0,0) + const normalized = state.points.map((pt) => ({ + ...pt, + x: pt.x - bbox.x, + y: pt.y - bbox.y, + })) + const d = buildPathData(normalized, closed) + + useDocumentStore.getState().addNode(null, { + id: generateId(), + type: 'path', + name: 'Path', + x: bbox.x, + y: bbox.y, + d, + width: Math.round(bbox.w), + height: Math.round(bbox.h), + fill: [{ type: 'solid', color: 'transparent' }], + stroke: { + thickness: DEFAULT_STROKE_WIDTH, + fill: [{ type: 'solid', color: DEFAULT_STROKE }], + }, + }) + + state = { + isActive: false, + points: [], + isDraggingHandle: false, + cursorPos: null, + } + useCanvasStore.getState().setActiveTool('select') +} + +// --------------------------------------------------------------------------- +// Internal: visual preview rendering +// --------------------------------------------------------------------------- + +function clearPreviewObjects(canvas: fabric.Canvas): void { + const objs = [ + previewPath, + rubberBandLine, + ...anchorCircles, + ...handleLines, + ...handleDots, + ].filter(Boolean) as fabric.FabricObject[] + + for (const obj of objs) { + canvas.remove(obj) + } + + previewPath = null + rubberBandLine = null + anchorCircles = [] + handleLines = [] + handleDots = [] +} + +function renderPreview(canvas: fabric.Canvas): void { + clearPreviewObjects(canvas) + + const { points, cursorPos } = state + if (points.length === 0) return + + const baseProps = { + selectable: false, + evented: false, + objectCaching: false, + originX: 'left' as const, + originY: 'top' as const, + excludeFromExport: true, + } + + // --- Preview path (constructed segments so far) --- + if (points.length > 1) { + const d = buildPathData(points, false) + try { + previewPath = new fabric.Path(d, { + ...baseProps, + fill: 'transparent', + stroke: PREVIEW_STROKE, + strokeWidth: PREVIEW_STROKE_WIDTH, + strokeUniform: true, + }) + canvas.add(previewPath) + } catch { + // Invalid path data — skip preview + } + } + + // --- Rubber-band line from last point to cursor --- + const last = points[points.length - 1] + if (cursorPos && !state.isDraggingHandle) { + rubberBandLine = new fabric.Line( + [last.x, last.y, cursorPos.x, cursorPos.y], + { + ...baseProps, + fill: '', + stroke: RUBBER_BAND_STROKE, + strokeWidth: 1, + strokeDashArray: RUBBER_BAND_DASH, + strokeUniform: true, + }, + ) + canvas.add(rubberBandLine) + } + + // --- Anchor circles --- + for (let i = 0; i < points.length; i++) { + const pt = points[i] + const isFirst = i === 0 + const r = isFirst ? ANCHOR_FIRST_RADIUS : ANCHOR_RADIUS + const circle = new fabric.Circle({ + ...baseProps, + left: pt.x - r, + top: pt.y - r, + radius: r, + fill: ANCHOR_FILL, + stroke: ANCHOR_STROKE, + strokeWidth: 1.5, + strokeUniform: true, + }) + anchorCircles.push(circle) + canvas.add(circle) + } + + // --- Handle lines and dots --- + for (const pt of points) { + if (pt.handleOut) { + const hx = pt.x + pt.handleOut.x + const hy = pt.y + pt.handleOut.y + const line = new fabric.Line([pt.x, pt.y, hx, hy], { + ...baseProps, + fill: '', + stroke: HANDLE_LINE_STROKE, + strokeWidth: 1, + strokeUniform: true, + }) + handleLines.push(line) + canvas.add(line) + + const dot = new fabric.Circle({ + ...baseProps, + left: hx - HANDLE_DOT_RADIUS, + top: hy - HANDLE_DOT_RADIUS, + radius: HANDLE_DOT_RADIUS, + fill: SELECTION_BLUE, + stroke: '#ffffff', + strokeWidth: 1, + strokeUniform: true, + }) + handleDots.push(dot) + canvas.add(dot) + } + + if (pt.handleIn) { + const hx = pt.x + pt.handleIn.x + const hy = pt.y + pt.handleIn.y + const line = new fabric.Line([pt.x, pt.y, hx, hy], { + ...baseProps, + fill: '', + stroke: HANDLE_LINE_STROKE, + strokeWidth: 1, + strokeUniform: true, + }) + handleLines.push(line) + canvas.add(line) + + const dot = new fabric.Circle({ + ...baseProps, + left: hx - HANDLE_DOT_RADIUS, + top: hy - HANDLE_DOT_RADIUS, + radius: HANDLE_DOT_RADIUS, + fill: SELECTION_BLUE, + stroke: '#ffffff', + strokeWidth: 1, + strokeUniform: true, + }) + handleDots.push(dot) + canvas.add(dot) + } + } + + canvas.renderAll() +} diff --git a/src/canvas/use-canvas-events.ts b/src/canvas/use-canvas-events.ts index 8883c17e..ea4d0874 100644 --- a/src/canvas/use-canvas-events.ts +++ b/src/canvas/use-canvas-events.ts @@ -24,6 +24,14 @@ import { finalizeParentTransform, finalizeParentRotation, } from './parent-child-transform' +import { + isPenToolActive, + penToolPointerDown, + penToolPointerMove, + penToolPointerUp, + penToolDoubleClick, + cancelPenTool, +} from './pen-tool' function createNodeForTool( tool: ToolType, @@ -143,6 +151,10 @@ export function useCanvasEvents() { let prevTool = useCanvasStore.getState().activeTool const unsubTool = useCanvasStore.subscribe((state) => { if (state.activeTool === prevTool) return + // Cancel pen tool if switching away mid-drawing + if (prevTool === 'path' && isPenToolActive() && state.fabricCanvas) { + cancelPenTool(state.fabricCanvas) + } prevTool = state.activeTool if (!state.fabricCanvas) return if (isDrawingTool(state.activeTool)) { @@ -163,6 +175,13 @@ export function useCanvasEvents() { if (isPanning) return const pointer = toScene(canvas, e) + + // Pen tool: delegate to state machine + if (tool === 'path') { + penToolPointerDown(canvas, pointer) + return + } + startPoint = { x: pointer.x, y: pointer.y } drawing = true @@ -229,6 +248,13 @@ export function useCanvasEvents() { } const onPointerMove = (e: PointerEvent) => { + // Pen tool has its own move handling + if (isPenToolActive()) { + const pointer = toScene(canvas, e) + penToolPointerMove(canvas, pointer) + return + } + if (!drawing || !tempObj || !startPoint) return const tool = useCanvasStore.getState().activeTool @@ -267,6 +293,12 @@ export function useCanvasEvents() { } const onPointerUp = (_e: PointerEvent) => { + // Pen tool: end handle drag + if (isPenToolActive()) { + penToolPointerUp(canvas) + return + } + if (!drawing || !tempObj || !startPoint) { drawing = false startPoint = null @@ -307,11 +339,20 @@ export function useCanvasEvents() { useCanvasStore.getState().setActiveTool('select') } + const onDoubleClick = (e: MouseEvent) => { + if (isPenToolActive()) { + e.preventDefault() + e.stopPropagation() + penToolDoubleClick(canvas) + } + } + // All listeners on upperEl because Fabric.js captures the pointer // to this element, so pointermove/pointerup won't reach document. upperEl.addEventListener('pointerdown', onPointerDown) upperEl.addEventListener('pointermove', onPointerMove) upperEl.addEventListener('pointerup', onPointerUp) + upperEl.addEventListener('dblclick', onDoubleClick) // --- History batching for drag/resize/rotate --- canvas.on('mouse:down', (opt) => { @@ -489,11 +530,19 @@ export function useCanvasEvents() { if (asPen.penNodeId) { const scaleX = target.scaleX ?? 1 const scaleY = target.scaleY ?? 1 - if (target.width !== undefined) { - target.set({ width: target.width * scaleX, scaleX: 1 }) - } - if (target.height !== undefined) { - target.set({ height: target.height * scaleY, scaleY: 1 }) + // Path/Polygon dimensions are derived from their data, so we can't + // bake scale into width/height. Keep scaleX/scaleY on the Fabric + // object and let syncObjToStore compute the stored dimensions. + const isPathLike = + target.type === 'path' || + target.type === 'polygon' + if (!isPathLike) { + if (target.width !== undefined) { + target.set({ width: target.width * scaleX, scaleX: 1 }) + } + if (target.height !== undefined) { + target.set({ height: target.height * scaleY, scaleY: 1 }) + } } target.setCoords() syncObjToStore(asPen) @@ -511,11 +560,16 @@ export function useCanvasEvents() { for (const child of group.getObjects()) { const sx = child.scaleX ?? 1 const sy = child.scaleY ?? 1 - if (child.width !== undefined) { - child.set({ width: child.width * sx, scaleX: 1 }) - } - if (child.height !== undefined) { - child.set({ height: child.height * sy, scaleY: 1 }) + const childIsPathLike = + child.type === 'path' || + child.type === 'polygon' + if (!childIsPathLike) { + if (child.width !== undefined) { + child.set({ width: child.width * sx, scaleX: 1 }) + } + if (child.height !== undefined) { + child.set({ height: child.height * sy, scaleY: 1 }) + } } child.setCoords() } @@ -548,6 +602,7 @@ export function useCanvasEvents() { upperEl.removeEventListener('pointerdown', onPointerDown) upperEl.removeEventListener('pointermove', onPointerMove) upperEl.removeEventListener('pointerup', onPointerUp) + upperEl.removeEventListener('dblclick', onDoubleClick) } }, 100) diff --git a/src/canvas/use-frame-labels.ts b/src/canvas/use-frame-labels.ts index a842dbfb..80c025c2 100644 --- a/src/canvas/use-frame-labels.ts +++ b/src/canvas/use-frame-labels.ts @@ -26,8 +26,12 @@ export function useFrameLabels() { const dpr = el.width / el.offsetWidth const store = useDocumentStore.getState() - // Only top-level document children show labels - const topIds = new Set(store.document.children.map((c) => c.id)) + // Only top-level frame nodes show labels + const topFrameIds = new Set( + store.document.children + .filter((c) => c.type === 'frame') + .map((c) => c.id), + ) const objects = canvas.getObjects() as FabricObjectWithPenId[] ctx.save() @@ -37,7 +41,7 @@ export function useFrameLabels() { for (const obj of objects) { if (!obj.penNodeId) continue - if (!topIds.has(obj.penNodeId)) continue + if (!topFrameIds.has(obj.penNodeId)) continue const node = store.getNodeById(obj.penNodeId) if (!node) continue diff --git a/src/components/editor/shape-tool-dropdown.tsx b/src/components/editor/shape-tool-dropdown.tsx new file mode 100644 index 00000000..4321377a --- /dev/null +++ b/src/components/editor/shape-tool-dropdown.tsx @@ -0,0 +1,169 @@ +import { useState, useRef, useEffect, type ReactNode } from 'react' +import { + Square, + Circle, + Minus, + PenTool, + Sparkles, + ImagePlus, + ChevronDown, +} from 'lucide-react' +import type { ToolType } from '@/types/canvas' +import { useCanvasStore } from '@/stores/canvas-store' +import { Toggle } from '@/components/ui/toggle' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' + +const SHAPE_TOOLS: ToolType[] = ['rectangle', 'ellipse', 'line', 'path'] + +interface ToolItem { + type: 'tool' + tool: ToolType + icon: ReactNode + label: string +} + +interface ActionItem { + type: 'action' + key: string + icon: ReactNode + label: string + onAction: () => void +} + +type DropdownItem = ToolItem | ActionItem + +interface ShapeToolDropdownProps { + onIconPickerOpen: () => void + onImageImport: () => void +} + +const TOOL_ICON_MAP: Record = { + rectangle: , + ellipse: , + line: , + path: , +} + +export default function ShapeToolDropdown({ + onIconPickerOpen, + onImageImport, +}: ShapeToolDropdownProps) { + const [open, setOpen] = useState(false) + const lastShapeTool = useRef('rectangle') + const wrapperRef = useRef(null) + + const activeTool = useCanvasStore((s) => s.activeTool) + const setActiveTool = useCanvasStore((s) => s.setActiveTool) + + const isGroupActive = SHAPE_TOOLS.includes(activeTool) + + // Track last used shape tool + useEffect(() => { + if (SHAPE_TOOLS.includes(activeTool)) { + lastShapeTool.current = activeTool + } + }, [activeTool]) + + // Close on Escape + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + setOpen(false) + } + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open]) + + const displayIcon = + isGroupActive + ? TOOL_ICON_MAP[activeTool] + : TOOL_ICON_MAP[lastShapeTool.current] + + const items: DropdownItem[] = [ + { type: 'tool', tool: 'rectangle', icon: , label: 'Rectangle' }, + { type: 'tool', tool: 'ellipse', icon: , label: 'Ellipse' }, + { type: 'tool', tool: 'line', icon: , label: 'Line' }, + { type: 'action', key: 'icon', icon: , label: 'Icon', onAction: onIconPickerOpen }, + { type: 'action', key: 'image', icon: , label: 'Import Image or SVG…', onAction: onImageImport }, + { type: 'tool', tool: 'path', icon: , label: 'Pen' }, + ] + + const handleSelect = (item: DropdownItem) => { + if (item.type === 'tool') { + setActiveTool(item.tool) + } else { + item.onAction() + } + setOpen(false) + } + + return ( +
+ {/* Main shape tool button */} + + + setActiveTool(lastShapeTool.current)} + aria-label="Shape tools" + className="data-[state=on]:bg-primary/15 data-[state=on]:text-primary [&_svg]:size-5" + > + {displayIcon} + + + + Shape tools + + + + {/* Chevron button below — opens dropdown */} + + + {open && ( + <> + {/* Invisible backdrop to catch click-away */} +
setOpen(false)} + /> + + {/* Dropdown panel — below the chevron, offset to the right */} +
+ {items.map((item) => { + const key = item.type === 'tool' ? item.tool : item.key + const isActive = item.type === 'tool' && activeTool === item.tool + return ( + + ) + })} +
+ + )} +
+ ) +} diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index dec85cdd..e6ba5e23 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -1,17 +1,14 @@ -import { useRef, useCallback } from 'react' +import { useState, useRef, useCallback } from 'react' import { MousePointer2, - Square, - Circle, - Minus, Type, Frame, Hand, Undo2, Redo2, - ImagePlus, } from 'lucide-react' import ToolButton from './tool-button' +import ShapeToolDropdown from './shape-tool-dropdown' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, generateId } from '@/stores/document-store' import { parseSvgToNodes } from '@/utils/svg-parser' @@ -23,11 +20,35 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' +import IconPickerDialog from '@/components/shared/icon-picker-dialog' export default function Toolbar() { const canUndo = useHistoryStore((s) => s.undoStack.length > 0) const canRedo = useHistoryStore((s) => s.redoStack.length > 0) const fileInputRef = useRef(null) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + const handleIconSelect = useCallback((svgText: string, iconName: string) => { + const nodes = parseSvgToNodes(svgText) + if (nodes.length === 0) return + + const { viewport, fabricCanvas } = useCanvasStore.getState() + const canvasEl = fabricCanvas?.getElement() + const canvasW = canvasEl?.clientWidth ?? 800 + const canvasH = canvasEl?.clientHeight ?? 600 + const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom + const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom + + for (const node of nodes) { + const w = ('width' in node ? (typeof node.width === 'number' ? node.width : 100) : 100) + const h = ('height' in node ? (typeof node.height === 'number' ? node.height : 100) : 100) + node.x = centerX - w / 2 + node.y = centerY - h / 2 + node.name = iconName + useDocumentStore.getState().addNode(null, node) + } + setIconPickerOpen(false) + }, []) const handleUndo = () => { const currentDoc = useDocumentStore.getState().document @@ -136,30 +157,15 @@ export default function Toolbar() { return (
- {/* Drawing Tools */} } label="Select" shortcut="V" /> - } - label="Rectangle" - shortcut="R" - /> - } - label="Ellipse" - shortcut="O" - /> - } - label="Line" - shortcut="L" + setIconPickerOpen(true)} + onImageImport={handleAddImage} /> - {/* Add Image */} - - - - - - Add Image - - - - {/* Undo / Redo */} @@ -242,6 +225,20 @@ export default function Toolbar() { + + {/* Hidden file input + icon picker dialog */} + + setIconPickerOpen(false)} + onSelect={handleIconSelect} + />
) } diff --git a/src/components/shared/icon-picker-dialog.tsx b/src/components/shared/icon-picker-dialog.tsx new file mode 100644 index 00000000..8eefc0f2 --- /dev/null +++ b/src/components/shared/icon-picker-dialog.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { X, Loader2, Search } from 'lucide-react' +import { Button } from '@/components/ui/button' + +const ICONIFY_API = 'https://api.iconify.design' +const SEARCH_LIMIT = 64 +const DEBOUNCE_MS = 300 + +/** Detect dark mode and return a color param for Iconify preview URLs. */ +function getIconColor(): string { + const isLight = + typeof document !== 'undefined' && + document.documentElement.classList.contains('light') + // Encode # as %23 for URL usage + return isLight ? '%23333333' : '%23e4e4e7' +} + +interface IconPickerDialogProps { + open: boolean + onClose: () => void + onSelect: (svgText: string, iconName: string) => void +} + +/** Split "mdi:home" → { collection: "mdi", name: "home" } */ +function parseIconId(id: string) { + const idx = id.indexOf(':') + return { collection: id.slice(0, idx), name: id.slice(idx + 1) } +} + +export default function IconPickerDialog({ + open, + onClose, + onSelect, +}: IconPickerDialogProps) { + const [query, setQuery] = useState('') + const [icons, setIcons] = useState([]) + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(null) + const [searched, setSearched] = useState(false) + const inputRef = useRef(null) + const timerRef = useRef | null>(null) + + // Focus input on open + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 50) + } else { + setQuery('') + setIcons([]) + setSearched(false) + } + }, [open]) + + // Escape key closes dialog + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open, onClose]) + + // Debounced search + const doSearch = useCallback((q: string) => { + if (timerRef.current) clearTimeout(timerRef.current) + + if (!q.trim()) { + setIcons([]) + setLoading(false) + setSearched(false) + return + } + + setLoading(true) + timerRef.current = setTimeout(async () => { + try { + const res = await fetch( + `${ICONIFY_API}/search?query=${encodeURIComponent(q.trim())}&limit=${SEARCH_LIMIT}`, + ) + if (!res.ok) throw new Error('Search failed') + const data = await res.json() + setIcons(data.icons ?? []) + } catch { + setIcons([]) + } finally { + setLoading(false) + setSearched(true) + } + }, DEBOUNCE_MS) + }, []) + + const handleQueryChange = (val: string) => { + setQuery(val) + doSearch(val) + } + + const handleSelect = async (iconId: string) => { + const { collection, name } = parseIconId(iconId) + setFetching(iconId) + try { + const res = await fetch(`${ICONIFY_API}/${collection}/${name}.svg`) + if (!res.ok) throw new Error('Fetch failed') + const svgText = await res.text() + onSelect(svgText, iconId) + } catch { + // silently fail + } finally { + setFetching(null) + } + } + + if (!open) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

Icons

+ +
+ + {/* Search input */} +
+ + handleQueryChange(e.target.value)} + placeholder="Search icons..." + className="w-full bg-secondary border border-input rounded-md pl-8 pr-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring" + /> +
+ + {/* Results */} +
+ {loading ? ( +
+ +
+ ) : icons.length > 0 ? ( +
+ {icons.map((iconId) => { + const { collection, name } = parseIconId(iconId) + const isFetching = fetching === iconId + return ( + + ) + })} +
+ ) : searched ? ( +

+ No icons found +

+ ) : ( +

+ Type to search Iconify icons +

+ )} +
+ + {/* Footer */} +

+ Powered by Iconify +

+
+
+ ) +} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 46964b73..681ee308 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -14,6 +14,7 @@ import { } from '@/utils/file-operations' import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync' import { zoomToFitContent } from '@/canvas/use-fabric-canvas' +import { isPenToolActive, penToolKeyDown } from '@/canvas/pen-tool' import type { ToolType } from '@/types/canvas' const TOOL_KEYS: Record = { @@ -40,6 +41,15 @@ export function useKeyboardShortcuts() { return } + // During pen tool drawing, handle Enter/Escape/Backspace specially + if (isPenToolActive()) { + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas && penToolKeyDown(canvas, e.key)) { + e.preventDefault() + return + } + } + const isMod = e.metaKey || e.ctrlKey // Undo: Cmd/Ctrl+Z