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.
This commit is contained in:
Kayshen-X 2026-02-19 16:53:21 +08:00
parent cc17b372eb
commit cf7a9934bc
9 changed files with 1028 additions and 60 deletions

View file

@ -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': {

View file

@ -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
}
}

504
src/canvas/pen-tool.ts Normal file
View file

@ -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()
}

View file

@ -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)

View file

@ -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

View file

@ -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<string, ReactNode> = {
rectangle: <Square size={20} />,
ellipse: <Circle size={20} />,
line: <Minus size={20} />,
path: <PenTool size={20} />,
}
export default function ShapeToolDropdown({
onIconPickerOpen,
onImageImport,
}: ShapeToolDropdownProps) {
const [open, setOpen] = useState(false)
const lastShapeTool = useRef<ToolType>('rectangle')
const wrapperRef = useRef<HTMLDivElement>(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: <Square size={18} />, label: 'Rectangle' },
{ type: 'tool', tool: 'ellipse', icon: <Circle size={18} />, label: 'Ellipse' },
{ type: 'tool', tool: 'line', icon: <Minus size={18} />, label: 'Line' },
{ type: 'action', key: 'icon', icon: <Sparkles size={18} />, label: 'Icon', onAction: onIconPickerOpen },
{ type: 'action', key: 'image', icon: <ImagePlus size={18} />, label: 'Import Image or SVG…', onAction: onImageImport },
{ type: 'tool', tool: 'path', icon: <PenTool size={18} />, label: 'Pen' },
]
const handleSelect = (item: DropdownItem) => {
if (item.type === 'tool') {
setActiveTool(item.tool)
} else {
item.onAction()
}
setOpen(false)
}
return (
<div ref={wrapperRef} className="relative flex flex-col items-center">
{/* Main shape tool button */}
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
pressed={isGroupActive}
onPressedChange={() => setActiveTool(lastShapeTool.current)}
aria-label="Shape tools"
className="data-[state=on]:bg-primary/15 data-[state=on]:text-primary [&_svg]:size-5"
>
{displayIcon}
</Toggle>
</TooltipTrigger>
<TooltipContent side="right">
Shape tools
</TooltipContent>
</Tooltip>
{/* Chevron button below — opens dropdown */}
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center justify-center w-5 h-3 rounded-sm hover:bg-muted text-muted-foreground transition-colors cursor-pointer"
aria-label="More shape tools"
>
<ChevronDown size={10} />
</button>
{open && (
<>
{/* Invisible backdrop to catch click-away */}
<div
className="fixed inset-0 z-40"
onClick={() => setOpen(false)}
/>
{/* Dropdown panel — below the chevron, offset to the right */}
<div className="absolute top-full left-[calc(100%+8px)] mt-1 z-50 bg-card border border-border rounded-lg shadow-xl py-1.5 min-w-[220px]">
{items.map((item) => {
const key = item.type === 'tool' ? item.tool : item.key
const isActive = item.type === 'tool' && activeTool === item.tool
return (
<button
key={key}
onClick={() => handleSelect(item)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors cursor-pointer ${
isActive
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-accent'
}`}
>
<span className="flex-shrink-0">{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
</button>
)
})}
</div>
</>
)}
</div>
)
}

View file

@ -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<HTMLInputElement>(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 (
<div className="absolute top-2 left-2 z-10 w-10 bg-card border border-border rounded-xl flex flex-col items-center py-2 gap-1 shadow-lg">
{/* Drawing Tools */}
<ToolButton
tool="select"
icon={<MousePointer2 size={20} />}
label="Select"
shortcut="V"
/>
<ToolButton
tool="rectangle"
icon={<Square size={20} />}
label="Rectangle"
shortcut="R"
/>
<ToolButton
tool="ellipse"
icon={<Circle size={20} />}
label="Ellipse"
shortcut="O"
/>
<ToolButton
tool="line"
icon={<Minus size={20} />}
label="Line"
shortcut="L"
<ShapeToolDropdown
onIconPickerOpen={() => setIconPickerOpen(true)}
onImageImport={handleAddImage}
/>
<ToolButton
tool="text"
@ -182,29 +188,6 @@ export default function Toolbar() {
<Separator className="my-1 w-8" />
{/* Add Image */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif"
className="hidden"
onChange={handleFileSelected}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleAddImage}
>
<ImagePlus size={18} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Add Image</TooltipContent>
</Tooltip>
<Separator className="my-1 w-8" />
{/* Undo / Redo */}
<Tooltip>
<TooltipTrigger asChild>
@ -242,6 +225,20 @@ export default function Toolbar() {
</kbd>
</TooltipContent>
</Tooltip>
{/* Hidden file input + icon picker dialog */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif"
className="hidden"
onChange={handleFileSelected}
/>
<IconPickerDialog
open={iconPickerOpen}
onClose={() => setIconPickerOpen(false)}
onSelect={handleIconSelect}
/>
</div>
)
}

View file

@ -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<string[]>([])
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState<string | null>(null)
const [searched, setSearched] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative bg-card rounded-lg border border-border p-4 w-[340px] shadow-xl">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">Icons</h3>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
>
<X size={14} />
</Button>
</div>
{/* Search input */}
<div className="relative mb-3">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
{/* Results */}
<div className="max-h-[360px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-6 gap-1">
{icons.map((iconId) => {
const { collection, name } = parseIconId(iconId)
const isFetching = fetching === iconId
return (
<button
key={iconId}
title={iconId}
onClick={() => handleSelect(iconId)}
disabled={isFetching}
className="w-10 h-10 flex items-center justify-center rounded hover:bg-accent cursor-pointer transition-colors disabled:opacity-50"
>
{isFetching ? (
<Loader2 size={16} className="animate-spin text-muted-foreground" />
) : (
<img
src={`${ICONIFY_API}/${collection}/${name}.svg?height=20&color=${getIconColor()}`}
alt={iconId}
width={20}
height={20}
loading="lazy"
/>
)}
</button>
)
})}
</div>
) : searched ? (
<p className="text-xs text-muted-foreground text-center py-10">
No icons found
</p>
) : (
<p className="text-xs text-muted-foreground text-center py-10">
Type to search Iconify icons
</p>
)}
</div>
{/* Footer */}
<p className="text-[10px] text-muted-foreground mt-2 text-right">
Powered by Iconify
</p>
</div>
</div>
)
}

View file

@ -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<string, ToolType> = {
@ -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