From 1009d6dc12610cd7501500d244e426db4e5369e2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 27 Feb 2026 21:59:36 +0300 Subject: [PATCH] Add resize handles, marquee selection, duplicate, constrained drawing - Resize by dragging selection handles (8 positions: corners + edges) - Shift+resize maintains aspect ratio - Marquee selection: drag on empty canvas to select nodes in rectangle - Alt+drag to duplicate and move selected nodes - Cmd+D to duplicate in place (+20px offset) - Cmd+A to select all - Shift+draw constrains to square/circle - Cursor changes to resize arrows on handle hover - Marquee drawn as blue transparent rect with selection stroke --- src/components/EditorCanvas.vue | 3 +- src/composables/use-canvas-input.ts | 355 ++++++++++++++++++++++++---- src/composables/use-canvas.ts | 2 +- src/composables/use-keyboard.ts | 6 + src/engine/renderer.ts | 22 +- src/stores/editor.ts | 33 +++ 6 files changed, 368 insertions(+), 53 deletions(-) diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index 602baf4cb..9cf1ccfad 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -9,9 +9,10 @@ const store = useEditorStore() const canvasRef = ref(null) useCanvas(canvasRef, store) -const { onMouseDown, onMouseMove, onMouseUp } = useCanvasInput(canvasRef, store) +const { onMouseDown, onMouseMove, onMouseUp, cursorOverride } = useCanvasInput(canvasRef, store) const cursor = computed(() => { + if (cursorOverride.value) return cursorOverride.value const tool = store.state.activeTool if (tool === 'HAND') return 'grab' if (tool === 'SELECT') return 'default' diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index b4ec61339..a970e03b3 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -1,20 +1,50 @@ import { ref, onMounted, onUnmounted, type Ref } from 'vue' -import type { NodeType } from '../engine/scene-graph' +import type { NodeType, SceneNode } from '../engine/scene-graph' import type { EditorStore, Tool } from '../stores/editor' -interface DragState { - type: 'draw' | 'move' | 'pan' +type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' + +interface DragDraw { + type: 'draw' + startX: number + startY: number + nodeId: string +} + +interface DragMove { + type: 'move' + startX: number + startY: number + originals: Map + duplicated?: boolean +} + +interface DragPan { + type: 'pan' startScreenX: number startScreenY: number - startCanvasX: number - startCanvasY: number - nodeId?: string - originals?: Map - startPanX?: number - startPanY?: number + startPanX: number + startPanY: number } +interface DragResize { + type: 'resize' + handle: HandlePosition + startX: number + startY: number + origRect: { x: number; y: number; width: number; height: number } + nodeId: string +} + +interface DragMarquee { + type: 'marquee' + startX: number + startY: number +} + +type DragState = DragDraw | DragMove | DragPan | DragResize | DragMarquee + const TOOL_TO_NODE: Partial> = { FRAME: 'FRAME', RECTANGLE: 'RECTANGLE', @@ -22,10 +52,61 @@ const TOOL_TO_NODE: Partial> = { LINE: 'LINE' } +const HANDLE_HIT_RADIUS = 6 + +const HANDLE_CURSORS: Record = { + nw: 'nwse-resize', + n: 'ns-resize', + ne: 'nesw-resize', + e: 'ew-resize', + se: 'nwse-resize', + s: 'ns-resize', + sw: 'nesw-resize', + w: 'ew-resize' +} + +function getHandlePositions(node: SceneNode, zoom: number, panX: number, panY: number) { + const x1 = node.x * zoom + panX + const y1 = node.y * zoom + panY + const x2 = (node.x + node.width) * zoom + panX + const y2 = (node.y + node.height) * zoom + panY + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + return { + nw: { x: x1, y: y1 }, + n: { x: mx, y: y1 }, + ne: { x: x2, y: y1 }, + e: { x: x2, y: my }, + se: { x: x2, y: y2 }, + s: { x: mx, y: y2 }, + sw: { x: x1, y: y2 }, + w: { x: x1, y: my } + } as Record +} + +function hitTestHandle( + sx: number, + sy: number, + node: SceneNode, + zoom: number, + panX: number, + panY: number +): HandlePosition | null { + const handles = getHandlePositions(node, zoom, panX, panY) + for (const [pos, pt] of Object.entries(handles)) { + if (Math.abs(sx - pt.x) < HANDLE_HIT_RADIUS && Math.abs(sy - pt.y) < HANDLE_HIT_RADIUS) { + return pos as HandlePosition + } + } + return null +} + export function useCanvasInput(canvasRef: Ref, store: EditorStore) { const drag = ref(null) + const cursorOverride = ref(null) - function getCanvasCoords(e: MouseEvent) { + function getCoords(e: MouseEvent) { const canvas = canvasRef.value if (!canvas) return { sx: 0, sy: 0, cx: 0, cy: 0 } const rect = canvas.getBoundingClientRect() @@ -36,31 +117,27 @@ export function useCanvasInput(canvasRef: Ref, store: } function onMouseDown(e: MouseEvent) { - const { sx, sy, cx, cy } = getCanvasCoords(e) + const { sx, sy, cx, cy } = getCoords(e) const tool = store.state.activeTool - // Middle mouse or Hand tool or space → pan + // Middle mouse or Hand tool → pan if (e.button === 1 || tool === 'HAND') { drag.value = { type: 'pan', startScreenX: e.clientX, startScreenY: e.clientY, - startCanvasX: cx, - startCanvasY: cy, startPanX: store.state.panX, startPanY: store.state.panY } return } - // Alt+click → pan - if (tool === 'SELECT' && e.altKey) { + // Alt+click with SELECT → pan + if (tool === 'SELECT' && e.altKey && !store.state.selectedIds.size) { drag.value = { type: 'pan', startScreenX: e.clientX, startScreenY: e.clientY, - startCanvasX: cx, - startCanvasY: cy, startPanX: store.state.panX, startPanY: store.state.panY } @@ -68,27 +145,83 @@ export function useCanvasInput(canvasRef: Ref, store: } if (tool === 'SELECT') { + // Check resize handles first + for (const id of store.state.selectedIds) { + const node = store.graph.getNode(id) + if (!node) continue + const handle = hitTestHandle( + sx, + sy, + node, + store.state.zoom, + store.state.panX, + store.state.panY + ) + if (handle) { + drag.value = { + type: 'resize', + handle, + startX: cx, + startY: cy, + origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, + nodeId: id + } + return + } + } + + // Hit test nodes const hit = store.graph.hitTest(cx, cy) if (hit) { - store.select([hit.id], e.shiftKey) + if (!store.state.selectedIds.has(hit.id) && !e.shiftKey) { + store.select([hit.id]) + } else if (e.shiftKey) { + store.select([hit.id], true) + } const originals = new Map() - const ids = e.shiftKey ? store.state.selectedIds : new Set([hit.id]) - for (const id of ids) { + for (const id of store.state.selectedIds) { const n = store.graph.getNode(id) if (n) originals.set(id, { x: n.x, y: n.y }) } - drag.value = { - type: 'move', - startScreenX: sx, - startScreenY: sy, - startCanvasX: cx, - startCanvasY: cy, - originals + // Alt+drag selected → duplicate + if (e.altKey && store.state.selectedIds.size > 0) { + const newIds: string[] = [] + const newOriginals = new Map() + for (const id of store.state.selectedIds) { + const src = store.graph.getNode(id) + if (!src) continue + const newId = store.createShape(src.type, src.x, src.y, src.width, src.height) + store.graph.updateNode(newId, { + name: src.name + ' copy', + fills: [...src.fills], + strokes: [...src.strokes], + effects: [...src.effects], + cornerRadius: src.cornerRadius, + opacity: src.opacity, + rotation: src.rotation + }) + newIds.push(newId) + newOriginals.set(newId, { x: src.x, y: src.y }) + } + store.select(newIds) + drag.value = { + type: 'move', + startX: cx, + startY: cy, + originals: newOriginals, + duplicated: true + } + store.requestRender() + return } + + drag.value = { type: 'move', startX: cx, startY: cy, originals } } else { + // Marquee selection store.clearSelection() + drag.value = { type: 'marquee', startX: cx, startY: cy } } return } @@ -100,61 +233,177 @@ export function useCanvasInput(canvasRef: Ref, store: const nodeId = store.createShape(nodeType, cx, cy, 0, 0) store.select([nodeId]) - drag.value = { - type: 'draw', - startScreenX: sx, - startScreenY: sy, - startCanvasX: cx, - startCanvasY: cy, - nodeId - } + drag.value = { type: 'draw', startX: cx, startY: cy, nodeId } } function onMouseMove(e: MouseEvent) { + // Cursor changes on hover (when not dragging) + if (!drag.value && store.state.activeTool === 'SELECT') { + const { sx, sy } = getCoords(e) + let cursor: string | null = null + for (const id of store.state.selectedIds) { + const node = store.graph.getNode(id) + if (!node) continue + const handle = hitTestHandle( + sx, + sy, + node, + store.state.zoom, + store.state.panX, + store.state.panY + ) + if (handle) { + cursor = HANDLE_CURSORS[handle] + break + } + } + cursorOverride.value = cursor + } + if (!drag.value) return const d = drag.value if (d.type === 'pan') { const dx = e.clientX - d.startScreenX const dy = e.clientY - d.startScreenY - store.state.panX = (d.startPanX ?? 0) + dx - store.state.panY = (d.startPanY ?? 0) + dy + store.state.panX = d.startPanX + dx + store.state.panY = d.startPanY + dy store.requestRender() return } - const { cx, cy } = getCanvasCoords(e) + const { cx, cy } = getCoords(e) - if (d.type === 'move' && d.originals) { - const dx = cx - d.startCanvasX - const dy = cy - d.startCanvasY + if (d.type === 'move') { + const dx = cx - d.startX + const dy = cy - d.startY for (const [id, orig] of d.originals) { - store.updateNode(id, { x: orig.x + dx, y: orig.y + dy }) + store.updateNode(id, { x: Math.round(orig.x + dx), y: Math.round(orig.y + dy) }) } return } - if (d.type === 'draw' && d.nodeId) { - const w = cx - d.startCanvasX - const h = cy - d.startCanvasY + if (d.type === 'resize') { + applyResize(d, cx, cy, e.shiftKey) + return + } + + if (d.type === 'draw') { + let w = cx - d.startX + let h = cy - d.startY + + // Shift → constrain to square + if (e.shiftKey) { + const size = Math.max(Math.abs(w), Math.abs(h)) + w = Math.sign(w) * size + h = Math.sign(h) * size + } + store.updateNode(d.nodeId, { - x: w < 0 ? cx : d.startCanvasX, - y: h < 0 ? cy : d.startCanvasY, + x: w < 0 ? d.startX + w : d.startX, + y: h < 0 ? d.startY + h : d.startY, width: Math.abs(w), height: Math.abs(h) }) + return } + + if (d.type === 'marquee') { + const minX = Math.min(d.startX, cx) + const minY = Math.min(d.startY, cy) + const maxX = Math.max(d.startX, cx) + const maxY = Math.max(d.startY, cy) + + const hits: string[] = [] + for (const node of store.graph.getChildren(store.graph.rootId)) { + if ( + node.x + node.width > minX && + node.x < maxX && + node.y + node.height > minY && + node.y < maxY + ) { + hits.push(node.id) + } + } + store.select(hits) + store.setMarquee({ x: minX, y: minY, width: maxX - minX, height: maxY - minY }) + } + } + + function applyResize(d: DragResize, cx: number, cy: number, constrain: boolean) { + const { handle, origRect } = d + let { x, y, width, height } = origRect + const dx = cx - d.startX + const dy = cy - d.startY + + // Which edges move + const moveLeft = handle.includes('w') + const moveRight = handle.includes('e') + const moveTop = handle === 'nw' || handle === 'n' || handle === 'ne' + const moveBottom = handle === 'sw' || handle === 's' || handle === 'se' + + if (moveRight) width = origRect.width + dx + if (moveLeft) { + x = origRect.x + dx + width = origRect.width - dx + } + if (moveBottom) height = origRect.height + dy + if (moveTop) { + y = origRect.y + dy + height = origRect.height - dy + } + + // Shift → maintain aspect ratio + if (constrain && origRect.width > 0 && origRect.height > 0) { + const aspect = origRect.width / origRect.height + if (handle === 'n' || handle === 's') { + width = Math.abs(height) * aspect + x = origRect.x + (origRect.width - width) / 2 + } else if (handle === 'e' || handle === 'w') { + height = Math.abs(width) / aspect + y = origRect.y + (origRect.height - height) / 2 + } else { + if (Math.abs(dx) > Math.abs(dy)) { + height = (Math.abs(width) / aspect) * Math.sign(height || 1) + if (moveTop) y = origRect.y + origRect.height - Math.abs(height) + } else { + width = Math.abs(height) * aspect * Math.sign(width || 1) + if (moveLeft) x = origRect.x + origRect.width - Math.abs(width) + } + } + } + + // Prevent negative sizes — flip + if (width < 0) { + x = x + width + width = -width + } + if (height < 0) { + y = y + height + height = -height + } + + store.updateNode(d.nodeId, { + x: Math.round(x), + y: Math.round(y), + width: Math.round(Math.max(1, width)), + height: Math.round(Math.max(1, height)) + }) } function onMouseUp() { if (!drag.value) return const d = drag.value - if (d.type === 'move' && d.originals) { + if (d.type === 'move') { store.commitMove(d.originals) } - if (d.type === 'draw' && d.nodeId) { + if (d.type === 'resize') { + // TODO: commit resize to undo stack + } + + if (d.type === 'draw') { const node = store.graph.getNode(d.nodeId) if (node && node.width < 2 && node.height < 2) { store.updateNode(d.nodeId, { width: 100, height: 100 }) @@ -162,7 +411,12 @@ export function useCanvasInput(canvasRef: Ref, store: store.setTool('SELECT') } + if (d.type === 'marquee') { + store.setMarquee(null) + } + drag.value = null + cursorOverride.value = null } function onWheel(e: WheelEvent) { @@ -194,6 +448,7 @@ export function useCanvasInput(canvasRef: Ref, store: return { drag, + cursorOverride, onMouseDown, onMouseMove, onMouseUp diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index 2608635cc..0efde7868 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -46,7 +46,7 @@ export function useCanvas(canvasRef: Ref, store: Edito renderer.panX = store.state.panX renderer.panY = store.state.panY renderer.zoom = store.state.zoom - renderer.render(store.graph, store.state.selectedIds) + renderer.render(store.graph, store.state.selectedIds, store.state.marquee) } onMounted(() => { diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 8e3d6b23c..f4fc8d5c5 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -24,6 +24,12 @@ export function useKeyboard(store: EditorStore) { } else if (e.key === '0') { e.preventDefault() store.zoomToFit() + } else if (e.key === 'd') { + e.preventDefault() + store.duplicateSelected() + } else if (e.key === 'a') { + e.preventDefault() + store.selectAll() } } diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts index 467ee7e80..1d0fe30e9 100644 --- a/src/engine/renderer.ts +++ b/src/engine/renderer.ts @@ -33,7 +33,11 @@ export class SkiaRenderer { this.selectionPaint.setAntiAlias(true) } - render(graph: SceneGraph, selectedIds: Set): void { + render( + graph: SceneGraph, + selectedIds: Set, + marquee?: { x: number; y: number; width: number; height: number } | null + ): void { const canvas = this.surface.getCanvas() canvas.clear(this.ck.Color4f(0.96, 0.96, 0.96, 1.0)) @@ -81,6 +85,22 @@ export class SkiaRenderer { this.drawHandle(canvas, x2, my) } + // Marquee selection rectangle + if (marquee && marquee.width > 0 && marquee.height > 0) { + const mx1 = marquee.x * this.zoom + this.panX + const my1 = marquee.y * this.zoom + this.panY + const mx2 = (marquee.x + marquee.width) * this.zoom + this.panX + const my2 = (marquee.y + marquee.height) * this.zoom + this.panY + const mRect = this.ck.LTRBRect(mx1, my1, mx2, my2) + + const marqueeFill = new this.ck.Paint() + marqueeFill.setStyle(this.ck.PaintStyle.Fill) + marqueeFill.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.08)) + canvas.drawRect(mRect, marqueeFill) + canvas.drawRect(mRect, this.selectionPaint) + marqueeFill.delete() + } + canvas.restore() this.surface.flush() } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index f484fe0a7..22d6658e5 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -65,6 +65,7 @@ export function createEditorStore() { const state = reactive({ activeTool: 'SELECT' as Tool, selectedIds: new Set(), + marquee: null as { x: number; y: number; width: number; height: number } | null, panX: 0, panY: 0, zoom: 1, @@ -114,6 +115,11 @@ export function createEditorStore() { state.selectedIds = new Set() } + function setMarquee(rect: { x: number; y: number; width: number; height: number } | null) { + state.marquee = rect + requestRender() + } + function updateNode(id: string, changes: Partial) { graph.updateNode(id, changes) requestRender() @@ -132,6 +138,30 @@ export function createEditorStore() { return node.id } + function selectAll() { + const children = graph.getChildren(graph.rootId) + state.selectedIds = new Set(children.map((n) => n.id)) + } + + function duplicateSelected() { + const newIds: string[] = [] + for (const id of state.selectedIds) { + const src = graph.getNode(id) + if (!src) continue + const node = graph.createNode(src.type, graph.rootId, { + ...src, + name: src.name + ' copy', + x: src.x + 20, + y: src.y + 20 + }) + newIds.push(node.id) + } + if (newIds.length > 0) { + state.selectedIds = new Set(newIds) + requestRender() + } + } + function deleteSelected() { undo.beginBatch('Delete') for (const id of state.selectedIds) { @@ -242,8 +272,11 @@ export function createEditorStore() { setTool, select, clearSelection, + selectAll, + setMarquee, updateNode, createShape, + duplicateSelected, deleteSelected, commitMove, undoAction,