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
This commit is contained in:
parent
b7f51579ef
commit
1009d6dc12
|
|
@ -9,9 +9,10 @@ const store = useEditorStore()
|
|||
const canvasRef = ref<HTMLCanvasElement | null>(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'
|
||||
|
|
|
|||
|
|
@ -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<string, { x: number; y: number }>
|
||||
duplicated?: boolean
|
||||
}
|
||||
|
||||
interface DragPan {
|
||||
type: 'pan'
|
||||
startScreenX: number
|
||||
startScreenY: number
|
||||
startCanvasX: number
|
||||
startCanvasY: number
|
||||
nodeId?: string
|
||||
originals?: Map<string, { x: number; y: number }>
|
||||
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<Record<Tool, NodeType>> = {
|
||||
FRAME: 'FRAME',
|
||||
RECTANGLE: 'RECTANGLE',
|
||||
|
|
@ -22,10 +52,61 @@ const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
|
|||
LINE: 'LINE'
|
||||
}
|
||||
|
||||
const HANDLE_HIT_RADIUS = 6
|
||||
|
||||
const HANDLE_CURSORS: Record<HandlePosition, string> = {
|
||||
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<HandlePosition, { x: number; y: number }>
|
||||
}
|
||||
|
||||
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<HTMLCanvasElement | null>, store: EditorStore) {
|
||||
const drag = ref<DragState | null>(null)
|
||||
const cursorOverride = ref<string | null>(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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<string, { x: number; y: number }>()
|
||||
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<string, { x: number; y: number }>()
|
||||
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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, store:
|
|||
|
||||
return {
|
||||
drag,
|
||||
cursorOverride,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, 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(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ export class SkiaRenderer {
|
|||
this.selectionPaint.setAntiAlias(true)
|
||||
}
|
||||
|
||||
render(graph: SceneGraph, selectedIds: Set<string>): void {
|
||||
render(
|
||||
graph: SceneGraph,
|
||||
selectedIds: Set<string>,
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export function createEditorStore() {
|
|||
const state = reactive({
|
||||
activeTool: 'SELECT' as Tool,
|
||||
selectedIds: new Set<string>(),
|
||||
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<SceneNode>) {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue