onMouseDownObj(e, o)}
ref={selectedId === o.id ? (el) => { selectedElRef.current = el } : undefined}
- style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedId===o.id? '2px solid #0ea5e9':'none' }}
+ style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedIds.includes(o.id)? '2px solid #0ea5e9':'none' }}
>
- {selectedId === o.id && (
+ {selectedIds.includes(o.id) && (
onMouseDownResize(e, o)}
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/>
)}
- {selectedId === o.id && (
+ {selectedIds.includes(o.id) && (
onMouseDownRotate(e, o)}
@@ -189,8 +313,39 @@ export function Canvas({ frame, onChange }: CanvasProps) {
}}
onDrag={({ left, top }) => {
if (!selected) return
- const rawLeft = typeof left === 'number' ? left : 0
- const rawTop = typeof top === 'number' ? top : 0
+ const rawLeftInput = typeof left === 'number' ? left : 0
+ const rawTopInput = typeof top === 'number' ? top : 0
+ // 다중 선택 시: 그룹 bbox 기준으로 스냅 계산
+ let baseLeft = selected.x
+ let baseTop = selected.y
+ let rawLeft = rawLeftInput
+ let rawTop = rawTopInput
+ let groupLeft = selected.x
+ let groupTop = selected.y
+ let groupWidth = selected.width
+ let groupHeight = selected.height
+ if (selectedIds.length > 1) {
+ let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity
+ for (const ly of model.layers) {
+ for (const o of ly.objects) {
+ if (!selectedIds.includes(o.id)) continue
+ minL = Math.min(minL, o.x)
+ minT = Math.min(minT, o.y)
+ maxR = Math.max(maxR, o.x + o.width)
+ maxB = Math.max(maxB, o.y + o.height)
+ }
+ }
+ groupLeft = minL
+ groupTop = minT
+ groupWidth = Math.max(0, maxR - minL)
+ groupHeight = Math.max(0, maxB - minT)
+ const dxProp = rawLeftInput - selected.x
+ const dyProp = rawTopInput - selected.y
+ baseLeft = groupLeft
+ baseTop = groupTop
+ rawLeft = groupLeft + dxProp
+ rawTop = groupTop + dyProp
+ }
let nx = snap8(rawLeft)
let ny = snap8(rawTop)
@@ -198,6 +353,8 @@ export function Canvas({ frame, onChange }: CanvasProps) {
const threshold = 8
let snappedCenterX: number | undefined
let snappedCenterY: number | undefined
+ let centerDistX = Number.POSITIVE_INFINITY
+ let centerDistY = Number.POSITIVE_INFINITY
// 에지 스냅 후보(nx, ny로 바로 설정 가능한 값) 및 가이드 좌표
let edgeNx: number | undefined
let edgeNy: number | undefined
@@ -205,39 +362,34 @@ export function Canvas({ frame, onChange }: CanvasProps) {
let guideEdgeY: number | undefined
let minDistX = Number.POSITIVE_INFINITY
let minDistY = Number.POSITIVE_INFINITY
- outer: for (const layer of model.layers) {
+ for (const layer of model.layers) {
for (const obj of layer.objects) {
- if (obj.id === selected.id) continue
+ if (selectedIds.includes(obj.id)) continue
const otherCenterX = obj.x + obj.width / 2
- const selCenterX = rawLeft + selected.width / 2
- if (Math.abs(selCenterX - otherCenterX) <= threshold) {
+ const selCenterX = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width) / 2
+ const dxCenter = Math.abs(selCenterX - otherCenterX)
+ if (dxCenter <= threshold && dxCenter < centerDistX) {
+ centerDistX = dxCenter
snappedCenterX = otherCenterX
- // 수평 중심도 동시에 체크
- const otherCenterY = obj.y + obj.height / 2
- const selCenterY = rawTop + selected.height / 2
- if (Math.abs(selCenterY - otherCenterY) <= threshold) {
- snappedCenterY = otherCenterY
- }
- break outer
}
const otherCenterY2 = obj.y + obj.height / 2
- const selCenterY2 = rawTop + selected.height / 2
- if (Math.abs(selCenterY2 - otherCenterY2) <= threshold) {
+ const selCenterY2 = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height) / 2
+ const dyCenter = Math.abs(selCenterY2 - otherCenterY2)
+ if (dyCenter <= threshold && dyCenter < centerDistY) {
+ centerDistY = dyCenter
snappedCenterY = otherCenterY2
- // x는 그리드, y만 스냅으로 나가고 루프 종료
- break outer
}
// 에지 스냅 후보 계산 (X축)
const selLeft = rawLeft
- const selRight = rawLeft + selected.width
+ const selRight = rawLeft + (selectedIds.length > 1 ? groupWidth : selected.width)
const otherLeft = obj.x
const otherRight = obj.x + obj.width
const xPairs: Array<{dist: number; nx: number; guideX: number}> = [
{ dist: Math.abs(selLeft - otherLeft), nx: otherLeft, guideX: otherLeft }, // L-L
{ dist: Math.abs(selLeft - otherRight), nx: otherRight, guideX: otherRight }, // L-R
- { dist: Math.abs(selRight - otherLeft), nx: otherLeft - selected.width, guideX: otherLeft }, // R-L
- { dist: Math.abs(selRight - otherRight), nx: otherRight - selected.width, guideX: otherRight }, // R-R
+ { dist: Math.abs(selRight - otherLeft), nx: otherLeft - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherLeft }, // R-L
+ { dist: Math.abs(selRight - otherRight), nx: otherRight - (selectedIds.length > 1 ? groupWidth : selected.width), guideX: otherRight }, // R-R
]
for (const p of xPairs) {
if (p.dist <= threshold && p.dist < minDistX) {
@@ -249,14 +401,14 @@ export function Canvas({ frame, onChange }: CanvasProps) {
// 에지 스냅 후보 계산 (Y축)
const selTop = rawTop
- const selBottom = rawTop + selected.height
+ const selBottom = rawTop + (selectedIds.length > 1 ? groupHeight : selected.height)
const otherTop = obj.y
const otherBottom = obj.y + obj.height
const yPairs: Array<{dist: number; ny: number; guideY: number}> = [
{ dist: Math.abs(selTop - otherTop), ny: otherTop, guideY: otherTop }, // T-T
{ dist: Math.abs(selTop - otherBottom), ny: otherBottom, guideY: otherBottom }, // T-B
- { dist: Math.abs(selBottom - otherTop), ny: otherTop - selected.height, guideY: otherTop }, // B-T
- { dist: Math.abs(selBottom - otherBottom), ny: otherBottom - selected.height, guideY: otherBottom }, // B-B
+ { dist: Math.abs(selBottom - otherTop), ny: otherTop - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherTop }, // B-T
+ { dist: Math.abs(selBottom - otherBottom), ny: otherBottom - (selectedIds.length > 1 ? groupHeight : selected.height), guideY: otherBottom }, // B-B
]
for (const p of yPairs) {
if (p.dist <= threshold && p.dist < minDistY) {
@@ -272,12 +424,12 @@ export function Canvas({ frame, onChange }: CanvasProps) {
if (typeof edgeNx === 'number') {
nx = edgeNx
} else if (typeof snappedCenterX === 'number') {
- nx = snappedCenterX - selected.width / 2
+ nx = snappedCenterX - (selectedIds.length > 1 ? groupWidth : selected.width) / 2
}
if (typeof edgeNy === 'number') {
ny = edgeNy
} else if (typeof snappedCenterY === 'number') {
- ny = snappedCenterY - selected.height / 2
+ ny = snappedCenterY - (selectedIds.length > 1 ? groupHeight : selected.height) / 2
}
// 가이드라인 좌표 업데이트: 축별로 에지/중심/그리드 순으로 우선 표시
@@ -285,7 +437,13 @@ export function Canvas({ frame, onChange }: CanvasProps) {
const guideY = typeof guideEdgeY === 'number' ? guideEdgeY : (typeof snappedCenterY === 'number' ? snappedCenterY : ny)
setGuidePos({ x: guideX, y: guideY })
- updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
+ if (selectedIds.length > 1) {
+ const dx = nx - baseLeft
+ const dy = ny - baseTop
+ updateObjects(selectedIds, (o) => ({ ...o, x: o.x + dx, y: o.y + dy }))
+ } else {
+ updateObject(selected.id, (o) => ({ ...o, x: nx, y: ny }))
+ }
}}
onDragEnd={() => {
// 드래그 종료 시 가이드 숨김
@@ -295,7 +453,17 @@ export function Canvas({ frame, onChange }: CanvasProps) {
if (!selected) return
const nw = Math.max(8, snap8(typeof width === 'number' ? width : 0))
const nh = Math.max(8, snap8(typeof height === 'number' ? height : 0))
- updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh }))
+ if (selectedIds.length > 1) {
+ const dw = nw - selected.width
+ const dh = nh - selected.height
+ updateObjects(selectedIds, (o) => ({
+ ...o,
+ width: Math.max(8, o.width + dw),
+ height: Math.max(8, o.height + dh),
+ }))
+ } else {
+ updateObject(selected.id, (o) => ({ ...o, width: nw, height: nh }))
+ }
}}
onRotate={({ rotate, inputEvent }) => {
if (!selected) return