mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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:
parent
cc17b372eb
commit
cf7a9934bc
9 changed files with 1028 additions and 60 deletions
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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
504
src/canvas/pen-tool.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
169
src/components/editor/shape-tool-dropdown.tsx
Normal file
169
src/components/editor/shape-tool-dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
208
src/components/shared/icon-picker-dialog.tsx
Normal file
208
src/components/shared/icon-picker-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue