Snap targets are now siblings of the moving node (not root children), converted to absolute coordinates. This fixes guides not reflecting resized objects.
853 lines
24 KiB
TypeScript
853 lines
24 KiB
TypeScript
import { useEventListener } from '@vueuse/core'
|
|
import { ref, type Ref } from 'vue'
|
|
|
|
import { computeSelectionBounds, computeSnap } from '../engine/snap'
|
|
|
|
import type { NodeType, SceneNode } from '../engine/scene-graph'
|
|
import type { EditorStore, Tool } from '../stores/editor'
|
|
|
|
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
|
|
autoLayoutParentId?: string
|
|
brokeFromAutoLayout?: boolean
|
|
}
|
|
|
|
const AUTO_LAYOUT_BREAK_THRESHOLD = 8
|
|
|
|
interface DragPan {
|
|
type: 'pan'
|
|
startScreenX: number
|
|
startScreenY: 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
|
|
}
|
|
|
|
interface DragRotate {
|
|
type: 'rotate'
|
|
nodeId: string
|
|
centerX: number
|
|
centerY: number
|
|
startAngle: number
|
|
origRotation: number
|
|
}
|
|
|
|
type DragState = DragDraw | DragMove | DragPan | DragResize | DragMarquee | DragRotate
|
|
|
|
const TOOL_TO_NODE: Partial<Record<Tool, NodeType>> = {
|
|
FRAME: 'FRAME',
|
|
RECTANGLE: 'RECTANGLE',
|
|
ELLIPSE: 'ELLIPSE',
|
|
LINE: 'LINE',
|
|
TEXT: 'TEXT'
|
|
}
|
|
|
|
const HANDLE_HIT_RADIUS = 6
|
|
const ROTATION_HIT_RADIUS = 8
|
|
|
|
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 getScreenRect(
|
|
absX: number,
|
|
absY: number,
|
|
w: number,
|
|
h: number,
|
|
zoom: number,
|
|
panX: number,
|
|
panY: number
|
|
) {
|
|
return {
|
|
x1: absX * zoom + panX,
|
|
y1: absY * zoom + panY,
|
|
x2: (absX + w) * zoom + panX,
|
|
y2: (absY + h) * zoom + panY
|
|
}
|
|
}
|
|
|
|
function getHandlePositions(
|
|
absX: number,
|
|
absY: number,
|
|
w: number,
|
|
h: number,
|
|
zoom: number,
|
|
panX: number,
|
|
panY: number
|
|
) {
|
|
const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, 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 unrotate(
|
|
sx: number,
|
|
sy: number,
|
|
centerX: number,
|
|
centerY: number,
|
|
rotation: number
|
|
): { sx: number; sy: number } {
|
|
if (rotation === 0) return { sx, sy }
|
|
const rad = (-rotation * Math.PI) / 180
|
|
const cos = Math.cos(rad)
|
|
const sin = Math.sin(rad)
|
|
const dx = sx - centerX
|
|
const dy = sy - centerY
|
|
return {
|
|
sx: centerX + dx * cos - dy * sin,
|
|
sy: centerY + dx * sin + dy * cos
|
|
}
|
|
}
|
|
|
|
function hitTestHandle(
|
|
sx: number,
|
|
sy: number,
|
|
absX: number,
|
|
absY: number,
|
|
w: number,
|
|
h: number,
|
|
zoom: number,
|
|
panX: number,
|
|
panY: number,
|
|
rotation = 0
|
|
): HandlePosition | null {
|
|
const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY)
|
|
const cx = (x1 + x2) / 2
|
|
const cy = (y1 + y2) / 2
|
|
const ur = unrotate(sx, sy, cx, cy, rotation)
|
|
|
|
const handles = getHandlePositions(absX, absY, w, h, zoom, panX, panY)
|
|
for (const [pos, pt] of Object.entries(handles)) {
|
|
if (Math.abs(ur.sx - pt.x) < HANDLE_HIT_RADIUS && Math.abs(ur.sy - pt.y) < HANDLE_HIT_RADIUS) {
|
|
return pos as HandlePosition
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function hitTestRotationHandle(
|
|
sx: number,
|
|
sy: number,
|
|
absX: number,
|
|
absY: number,
|
|
w: number,
|
|
h: number,
|
|
zoom: number,
|
|
panX: number,
|
|
panY: number,
|
|
rotation = 0
|
|
): boolean {
|
|
const { x1, x2, y1, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY)
|
|
const cx = (x1 + x2) / 2
|
|
const cy = (y1 + y2) / 2
|
|
const ur = unrotate(sx, sy, cx, cy, rotation)
|
|
|
|
const mx = (x1 + x2) / 2
|
|
const rotY = y1 - 24
|
|
return Math.abs(ur.sx - mx) < ROTATION_HIT_RADIUS && Math.abs(ur.sy - rotY) < ROTATION_HIT_RADIUS
|
|
}
|
|
|
|
export function useCanvasInput(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
|
|
const drag = ref<DragState | null>(null)
|
|
const cursorOverride = ref<string | null>(null)
|
|
|
|
function getCoords(e: MouseEvent) {
|
|
const canvas = canvasRef.value
|
|
if (!canvas) return { sx: 0, sy: 0, cx: 0, cy: 0 }
|
|
const rect = canvas.getBoundingClientRect()
|
|
const sx = e.clientX - rect.left
|
|
const sy = e.clientY - rect.top
|
|
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
|
|
return { sx, sy, cx, cy }
|
|
}
|
|
|
|
function onMouseDown(e: MouseEvent) {
|
|
const { sx, sy, cx, cy } = getCoords(e)
|
|
const tool = store.state.activeTool
|
|
|
|
if (e.button === 1 || tool === 'HAND') {
|
|
drag.value = {
|
|
type: 'pan',
|
|
startScreenX: e.clientX,
|
|
startScreenY: e.clientY,
|
|
startPanX: store.state.panX,
|
|
startPanY: store.state.panY
|
|
}
|
|
return
|
|
}
|
|
|
|
if (tool === 'SELECT' && e.altKey && !store.state.selectedIds.size) {
|
|
drag.value = {
|
|
type: 'pan',
|
|
startScreenX: e.clientX,
|
|
startScreenY: e.clientY,
|
|
startPanX: store.state.panX,
|
|
startPanY: store.state.panY
|
|
}
|
|
return
|
|
}
|
|
|
|
if (tool === 'SELECT') {
|
|
// Check rotation handle (single selection only)
|
|
if (store.state.selectedIds.size === 1) {
|
|
const id = [...store.state.selectedIds][0]
|
|
const node = store.graph.getNode(id)
|
|
if (node) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
if (
|
|
hitTestRotationHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
) {
|
|
const screenCx = (abs.x + node.width / 2) * store.state.zoom + store.state.panX
|
|
const screenCy = (abs.y + node.height / 2) * store.state.zoom + store.state.panY
|
|
const startAngle = Math.atan2(sy - screenCy, sx - screenCx) * (180 / Math.PI)
|
|
drag.value = {
|
|
type: 'rotate',
|
|
nodeId: id,
|
|
centerX: screenCx,
|
|
centerY: screenCy,
|
|
startAngle,
|
|
origRotation: node.rotation
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check resize handles
|
|
for (const id of store.state.selectedIds) {
|
|
const node = store.graph.getNode(id)
|
|
if (!node) continue
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const handle = hitTestHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
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) {
|
|
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 }>()
|
|
for (const id of store.state.selectedIds) {
|
|
const n = store.graph.getNode(id)
|
|
if (n) originals.set(id, { x: n.x, y: n.y })
|
|
}
|
|
|
|
// Alt+drag → 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
|
|
}
|
|
|
|
// Detect if we're inside an auto-layout frame
|
|
let autoLayoutParentId: string | undefined
|
|
if (store.state.selectedIds.size === 1) {
|
|
const selectedId = [...store.state.selectedIds][0]
|
|
const selectedNode = store.graph.getNode(selectedId)
|
|
if (selectedNode?.parentId) {
|
|
const parent = store.graph.getNode(selectedNode.parentId)
|
|
if (
|
|
parent &&
|
|
parent.layoutMode !== 'NONE' &&
|
|
selectedNode.layoutPositioning !== 'ABSOLUTE'
|
|
) {
|
|
autoLayoutParentId = parent.id
|
|
}
|
|
}
|
|
}
|
|
|
|
drag.value = { type: 'move', startX: cx, startY: cy, originals, autoLayoutParentId }
|
|
} else {
|
|
store.clearSelection()
|
|
drag.value = { type: 'marquee', startX: cx, startY: cy }
|
|
}
|
|
return
|
|
}
|
|
|
|
// Text tool: click to create text node
|
|
if (tool === 'TEXT') {
|
|
const nodeId = store.createShape('TEXT', cx, cy, 200, 24)
|
|
store.graph.updateNode(nodeId, { text: 'Text' })
|
|
store.select([nodeId])
|
|
store.setTool('SELECT')
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
// Shape creation
|
|
const nodeType = TOOL_TO_NODE[tool]
|
|
if (!nodeType) return
|
|
|
|
const nodeId = store.createShape(nodeType, cx, cy, 0, 0)
|
|
store.select([nodeId])
|
|
|
|
drag.value = { type: 'draw', startX: cx, startY: cy, nodeId }
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
// Cursor on hover
|
|
if (!drag.value && store.state.activeTool === 'SELECT') {
|
|
const { sx, sy } = getCoords(e)
|
|
let cursor: string | null = null
|
|
|
|
// Rotation handle cursor
|
|
if (store.state.selectedIds.size === 1) {
|
|
const id = [...store.state.selectedIds][0]
|
|
const node = store.graph.getNode(id)
|
|
if (node) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
if (
|
|
hitTestRotationHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
) {
|
|
cursor = 'grab'
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!cursor) {
|
|
for (const id of store.state.selectedIds) {
|
|
const node = store.graph.getNode(id)
|
|
if (!node) continue
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const handle = hitTestHandle(
|
|
sx,
|
|
sy,
|
|
abs.x,
|
|
abs.y,
|
|
node.width,
|
|
node.height,
|
|
store.state.zoom,
|
|
store.state.panX,
|
|
store.state.panY,
|
|
node.rotation
|
|
)
|
|
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 + dx
|
|
store.state.panY = d.startPanY + dy
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
const { cx, cy, sx, sy } = getCoords(e)
|
|
|
|
if (d.type === 'rotate') {
|
|
const currentAngle = Math.atan2(sy - d.centerY, sx - d.centerX) * (180 / Math.PI)
|
|
let rotation = d.origRotation + (currentAngle - d.startAngle)
|
|
|
|
// Shift → snap to 15° increments
|
|
if (e.shiftKey) {
|
|
rotation = Math.round(rotation / 15) * 15
|
|
}
|
|
|
|
// Normalize to -180..180
|
|
rotation = ((((rotation + 180) % 360) + 360) % 360) - 180
|
|
|
|
store.setRotationPreview({ nodeId: d.nodeId, angle: rotation })
|
|
return
|
|
}
|
|
|
|
if (d.type === 'move') {
|
|
let dx = cx - d.startX
|
|
let dy = cy - d.startY
|
|
|
|
// Auto-layout: dead zone before breaking out
|
|
if (d.autoLayoutParentId && !d.brokeFromAutoLayout) {
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
if (dist < AUTO_LAYOUT_BREAK_THRESHOLD) {
|
|
// Still in dead zone — compute insertion indicator within parent
|
|
computeAutoLayoutIndicator(d, cx, cy)
|
|
return
|
|
}
|
|
d.brokeFromAutoLayout = true
|
|
store.setLayoutInsertIndicator(null)
|
|
}
|
|
|
|
// Check if we're hovering over an auto-layout frame
|
|
const dropTarget = store.graph.hitTestFrame(cx, cy, store.state.selectedIds)
|
|
const dropParent = dropTarget ? store.graph.getNode(dropTarget.id) : null
|
|
|
|
if (dropParent && dropParent.layoutMode !== 'NONE') {
|
|
computeAutoLayoutIndicatorForFrame(dropParent, cx, cy)
|
|
store.setDropTarget(dropParent.id)
|
|
|
|
// Still move the nodes freely so the user sees them floating
|
|
for (const [id, orig] of d.originals) {
|
|
store.graph.updateNode(id, {
|
|
x: Math.round(orig.x + dx),
|
|
y: Math.round(orig.y + dy)
|
|
})
|
|
}
|
|
store.requestRender()
|
|
return
|
|
}
|
|
|
|
store.setLayoutInsertIndicator(null)
|
|
|
|
// Compute snap using absolute positions
|
|
const selectedNodes: SceneNode[] = []
|
|
for (const [id, orig] of d.originals) {
|
|
const n = store.graph.getNode(id)
|
|
if (n) {
|
|
const abs = store.graph.getAbsolutePosition(id)
|
|
const parentAbs = n.parentId
|
|
? store.graph.getAbsolutePosition(n.parentId)
|
|
: { x: 0, y: 0 }
|
|
selectedNodes.push({
|
|
...n,
|
|
x: abs.x - parentAbs.x - n.x + orig.x + dx,
|
|
y: abs.y - parentAbs.y - n.y + orig.y + dy
|
|
})
|
|
}
|
|
}
|
|
|
|
const bounds = computeSelectionBounds(selectedNodes)
|
|
if (bounds) {
|
|
// Snap against siblings in absolute coordinates
|
|
const firstId = [...d.originals.keys()][0]
|
|
const firstNode = store.graph.getNode(firstId)
|
|
const parentId = firstNode?.parentId ?? store.graph.rootId
|
|
const siblings = store.graph.getChildren(parentId)
|
|
const parentAbs = parentId !== store.graph.rootId
|
|
? store.graph.getAbsolutePosition(parentId)
|
|
: { x: 0, y: 0 }
|
|
const absTargets = siblings.map((n) => ({
|
|
...n,
|
|
x: n.x + parentAbs.x,
|
|
y: n.y + parentAbs.y
|
|
}))
|
|
// Convert moving bounds to absolute coords too
|
|
const absBounds = {
|
|
x: bounds.x + parentAbs.x,
|
|
y: bounds.y + parentAbs.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
const snap = computeSnap(store.state.selectedIds, absBounds, absTargets)
|
|
dx += snap.dx
|
|
dy += snap.dy
|
|
store.setSnapGuides(snap.guides)
|
|
}
|
|
|
|
for (const [id, orig] of d.originals) {
|
|
store.updateNode(id, { x: Math.round(orig.x + dx), y: Math.round(orig.y + dy) })
|
|
}
|
|
|
|
store.setDropTarget(dropTarget?.id ?? null)
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
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 ? 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
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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') {
|
|
const indicator = store.state.layoutInsertIndicator
|
|
store.setLayoutInsertIndicator(null)
|
|
store.setSnapGuides([])
|
|
|
|
if (indicator) {
|
|
// Drop into auto-layout at the indicated position
|
|
for (const id of store.state.selectedIds) {
|
|
store.reorderInAutoLayout(id, indicator.parentId, indicator.index)
|
|
}
|
|
store.setDropTarget(null)
|
|
} else {
|
|
store.commitMove(d.originals)
|
|
|
|
// Reparent into frame if dropped on one
|
|
const dropId = store.state.dropTargetId
|
|
if (dropId) {
|
|
store.reparentNodes([...store.state.selectedIds], dropId)
|
|
store.setDropTarget(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (d.type === 'rotate') {
|
|
const preview = store.state.rotationPreview
|
|
if (preview) {
|
|
store.updateNode(d.nodeId, { rotation: preview.angle })
|
|
}
|
|
store.setRotationPreview(null)
|
|
}
|
|
|
|
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 })
|
|
}
|
|
store.setTool('SELECT')
|
|
}
|
|
|
|
if (d.type === 'marquee') {
|
|
store.setMarquee(null)
|
|
}
|
|
|
|
drag.value = null
|
|
cursorOverride.value = null
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const canvas = canvasRef.value
|
|
if (!canvas) return
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
const rect = canvas.getBoundingClientRect()
|
|
const sx = e.clientX - rect.left
|
|
const sy = e.clientY - rect.top
|
|
store.applyZoom(e.deltaY, sx, sy)
|
|
} else {
|
|
store.pan(-e.deltaX, -e.deltaY)
|
|
}
|
|
}
|
|
|
|
function onDblClick(e: MouseEvent) {
|
|
const { cx, cy } = getCoords(e)
|
|
const hit = store.graph.hitTest(cx, cy)
|
|
if (hit && hit.type === 'TEXT') {
|
|
store.select([hit.id])
|
|
store.startTextEditing(hit.id)
|
|
}
|
|
}
|
|
|
|
function computeAutoLayoutIndicator(d: DragMove, cx: number, cy: number) {
|
|
if (!d.autoLayoutParentId) return
|
|
const parent = store.graph.getNode(d.autoLayoutParentId)
|
|
if (!parent || parent.layoutMode === 'NONE') return
|
|
computeAutoLayoutIndicatorForFrame(parent, cx, cy)
|
|
}
|
|
|
|
function computeAutoLayoutIndicatorForFrame(parent: SceneNode, cx: number, cy: number) {
|
|
const children = store.graph.getChildren(parent.id).filter(
|
|
(c) => c.layoutPositioning !== 'ABSOLUTE' && !store.state.selectedIds.has(c.id)
|
|
)
|
|
|
|
const parentAbs = store.graph.getAbsolutePosition(parent.id)
|
|
const isRow = parent.layoutMode === 'HORIZONTAL'
|
|
|
|
let insertIndex = children.length
|
|
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i]
|
|
const childAbs = store.graph.getAbsolutePosition(child.id)
|
|
const mid = isRow
|
|
? childAbs.x + child.width / 2
|
|
: childAbs.y + child.height / 2
|
|
const cursor = isRow ? cx : cy
|
|
|
|
if (cursor < mid) {
|
|
insertIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Compute indicator position
|
|
let indicatorPos: number
|
|
const crossStart = isRow ? parentAbs.y + parent.paddingTop : parentAbs.x + parent.paddingLeft
|
|
const crossLength = isRow
|
|
? parent.height - parent.paddingTop - parent.paddingBottom
|
|
: parent.width - parent.paddingLeft - parent.paddingRight
|
|
|
|
if (children.length === 0) {
|
|
indicatorPos = isRow
|
|
? parentAbs.x + parent.paddingLeft
|
|
: parentAbs.y + parent.paddingTop
|
|
} else if (insertIndex === 0) {
|
|
const first = children[0]
|
|
const firstAbs = store.graph.getAbsolutePosition(first.id)
|
|
indicatorPos = isRow
|
|
? firstAbs.x - parent.itemSpacing / 2
|
|
: firstAbs.y - parent.itemSpacing / 2
|
|
} else if (insertIndex >= children.length) {
|
|
const last = children[children.length - 1]
|
|
const lastAbs = store.graph.getAbsolutePosition(last.id)
|
|
indicatorPos = isRow
|
|
? lastAbs.x + last.width + parent.itemSpacing / 2
|
|
: lastAbs.y + last.height + parent.itemSpacing / 2
|
|
} else {
|
|
const prev = children[insertIndex - 1]
|
|
const next = children[insertIndex]
|
|
const prevAbs = store.graph.getAbsolutePosition(prev.id)
|
|
const nextAbs = store.graph.getAbsolutePosition(next.id)
|
|
indicatorPos = isRow
|
|
? (prevAbs.x + prev.width + nextAbs.x) / 2
|
|
: (prevAbs.y + prev.height + nextAbs.y) / 2
|
|
}
|
|
|
|
// Account for the dragged node being in the children list
|
|
// (we filtered it out, so insertIndex is relative to the filtered list)
|
|
// Convert to actual childIds index
|
|
const allChildren = store.graph.getChildren(parent.id)
|
|
let realIndex = 0
|
|
let filteredCount = 0
|
|
for (let i = 0; i < allChildren.length; i++) {
|
|
if (store.state.selectedIds.has(allChildren[i].id)) continue
|
|
if (allChildren[i].layoutPositioning === 'ABSOLUTE') {
|
|
realIndex++
|
|
continue
|
|
}
|
|
if (filteredCount === insertIndex) break
|
|
filteredCount++
|
|
realIndex++
|
|
}
|
|
|
|
store.setLayoutInsertIndicator({
|
|
parentId: parent.id,
|
|
index: realIndex,
|
|
x: isRow ? indicatorPos : crossStart,
|
|
y: isRow ? crossStart : indicatorPos,
|
|
length: crossLength,
|
|
direction: isRow ? 'VERTICAL' : 'HORIZONTAL'
|
|
})
|
|
}
|
|
|
|
useEventListener(canvasRef, 'dblclick', onDblClick)
|
|
useEventListener(canvasRef, 'mousedown', onMouseDown)
|
|
useEventListener(canvasRef, 'mousemove', onMouseMove)
|
|
useEventListener(canvasRef, 'mouseup', onMouseUp)
|
|
useEventListener(canvasRef, 'mouseleave', onMouseUp)
|
|
useEventListener(canvasRef, 'wheel', onWheel, { passive: false })
|
|
|
|
return {
|
|
drag,
|
|
cursorOverride
|
|
}
|
|
}
|