From 713648fb3a2d26143edd574b7e736092b99e4cb3 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Mon, 17 Nov 2025 01:06:14 +0900 Subject: [PATCH] =?UTF-8?q?WYSIWYG:=20=EB=A9=80=ED=8B=B0=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88/?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=20TDD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84\n-=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88:?= =?UTF-8?q?=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=84=A0=ED=83=9D=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=8A=A4=EB=83=85=20=EB=8D=B8=ED=83=80=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=84=A0=ED=83=9D=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=EC=97=90=20=EA=B7=A0=EB=93=B1=20=EC=A0=81=EC=9A=A9\n-=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84:=20=EC=8A=A4=EB=83=85/Shift=20=EB=AF=B8?= =?UTF-8?q?=EC=84=B8=ED=9A=8C=EC=A0=84=20=EB=8D=B8=ED=83=80=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=84=A0=ED=83=9D=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=EC=97=90=20=EA=B7=A0=EB=93=B1=20=EC=A0=81=EC=9A=A9\n-=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=95=88=EC=A0=95=ED=99=94(waitF?= =?UTF-8?q?or)=20=EB=B0=8F=20=ED=9B=85=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95(=EC=9D=BC=EB=B6=80=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=94=EC=A1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wysiwyg/Canvas.multigroup.resize.test.tsx | 56 ++++++++++++++ .../wysiwyg/Canvas.multigroup.rotate.test.tsx | 77 +++++++++++++++++++ components/wysiwyg/Canvas.tsx | 71 +++++++++++++++-- 3 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 components/wysiwyg/Canvas.multigroup.resize.test.tsx create mode 100644 components/wysiwyg/Canvas.multigroup.rotate.test.tsx diff --git a/components/wysiwyg/Canvas.multigroup.resize.test.tsx b/components/wysiwyg/Canvas.multigroup.resize.test.tsx new file mode 100644 index 0000000..0442207 --- /dev/null +++ b/components/wysiwyg/Canvas.multigroup.resize.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent, waitFor } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 20, y: 20, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + { id: 'o2', type: 'image', x: 120, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas multigroup resize', () => { + it('resizes all selected objects by the same snapped delta when resizing last-selected', async () => { + const frame = makeFrame() + const { getByTestId } = render() + + // select o1 + const obj1 = getByTestId('obj-o1') + fireEvent.mouseDown(obj1, { clientX: 25, clientY: 25 }) + // shift+select o2 + const obj2 = getByTestId('obj-o2') + fireEvent.mouseDown(obj2, { clientX: 125, clientY: 65, shiftKey: true }) + + // resize using last-selected (o2) handle + const handle2 = getByTestId('resize-handle-o2') + const canvas = getByTestId('wysiwyg-canvas') + + // Start at bottom-right corner; move by +7,+9 (snap8 => +8, +8) + fireEvent.mouseDown(handle2, { clientX: 200, clientY: 90 }) + fireEvent.mouseMove(canvas, { clientX: 207, clientY: 99 }) + fireEvent.mouseUp(canvas) + + const o1 = getByTestId('obj-o1') + const o2 = getByTestId('obj-o2') + + await waitFor(() => { + expect(o2).toHaveStyle({ width: '88px', height: '40px' }) + expect(o1).toHaveStyle({ width: '58px', height: '50px' }) + }) + }) +}) diff --git a/components/wysiwyg/Canvas.multigroup.rotate.test.tsx b/components/wysiwyg/Canvas.multigroup.rotate.test.tsx new file mode 100644 index 0000000..dc6b40f --- /dev/null +++ b/components/wysiwyg/Canvas.multigroup.rotate.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent, waitFor } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 60, y: 60, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + { id: 'o2', type: 'image', x: 160, y: 60, width: 80, height: 30, rotate: 0, zIndex: 1, props: { src: 'y', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas multigroup rotate', () => { + it('rotates all selected objects by same snapped angle when rotating last-selected', async () => { + const frame = makeFrame() + const { getByTestId } = render() + + // select o1 then shift+select o2 (last-selected o2) + fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 }) + fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true }) + + const handle2 = getByTestId('rotate-handle-o2') + const canvas = getByTestId('wysiwyg-canvas') + + // start rotate at 0deg and move +16px -> snap to 15deg by default + fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 }) + fireEvent.mouseMove(canvas, { clientX: 116, clientY: 50 }) + fireEvent.mouseUp(canvas) + + const o1 = getByTestId('obj-o1') + const o2 = getByTestId('obj-o2') + + await waitFor(() => { + expect(o2).toHaveStyle({ transform: 'rotate(15deg)' }) + expect(o1).toHaveStyle({ transform: 'rotate(15deg)' }) + }) + }) + + it('applies fine rotation delta to all when holding Shift', async () => { + const frame = makeFrame() + const { getByTestId } = render() + + // select o1 then shift+select o2 (last-selected o2) + fireEvent.mouseDown(getByTestId('obj-o1'), { clientX: 60, clientY: 60 }) + fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 160, clientY: 60, shiftKey: true }) + + const handle2 = getByTestId('rotate-handle-o2') + const canvas = getByTestId('wysiwyg-canvas') + + // start rotate at 0deg, move +7px with Shift -> 7deg (no snap) + fireEvent.mouseDown(handle2, { clientX: 100, clientY: 50 }) + fireEvent.mouseMove(canvas, { clientX: 107, clientY: 50, shiftKey: true }) + fireEvent.mouseUp(canvas) + + const o1 = getByTestId('obj-o1') + const o2 = getByTestId('obj-o2') + + await waitFor(() => { + expect(o2).toHaveStyle({ transform: 'rotate(7deg)' }) + expect(o1).toHaveStyle({ transform: 'rotate(7deg)' }) + }) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index bf482e7..6a8ff8a 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -33,7 +33,9 @@ export function Canvas({ frame, onChange }: CanvasProps) { const selectedId = selectedIds[selectedIds.length - 1] ?? null const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null) const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null) + const resizeGroupRef = useRef<{ ids: string[]; orig: Record } | null>(null) const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) + const rotateGroupRef = useRef<{ ids: string[]; orig: Record } | null>(null) const [showGuides, setShowGuides] = useState(false) const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const [marquee, setMarquee] = useState<{ @@ -114,7 +116,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y } setShowGuides(true) setGuidePos({ x: snap8(o.x), y: snap8(o.y) }) - }, []) + }, [selectedIds, model.layers]) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { // 마퀴 선택 처리 우선 @@ -148,7 +150,18 @@ export function Canvas({ frame, onChange }: CanvasProps) { if (!e.shiftKey) { next = Math.round(next / 15) * 15 } - updateObject(rot.id, (o) => ({ ...o, rotate: next })) + if (selectedIds.length > 1 && rotateGroupRef.current) { + const { ids, orig } = rotateGroupRef.current + const base = orig[rot.id] + const dd = next - base.d + updateObjects(ids, (o) => { + const o0 = orig[o.id] + if (!o0) return o + return { ...o, rotate: o0.d + dd } + }) + } else { + updateObject(rot.id, (o) => ({ ...o, rotate: next })) + } return } const r = resizingRef.current @@ -157,7 +170,23 @@ export function Canvas({ frame, onChange }: CanvasProps) { const dy = e.clientY - r.startY const nw = Math.max(8, snap8(r.origW + dx)) const nh = Math.max(8, snap8(r.origH + dy)) - updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) + if (selectedIds.length > 1 && resizeGroupRef.current) { + const { ids, orig } = resizeGroupRef.current + const base = orig[r.id] + const dw = nw - base.w + const dh = nh - base.h + updateObjects(ids, (o) => { + const o0 = orig[o.id] + if (!o0) return o + return { + ...o, + width: Math.max(8, o0.w + dw), + height: Math.max(8, o0.h + dh), + } + }) + } else { + updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) + } return } const d = draggingRef.current @@ -169,7 +198,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { setGuidePos({ x: nx, y: ny }) updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) } - }, [updateObject, marquee.active, marquee.x0, marquee.y0, model.layers]) + }, [updateObject, updateObjects, marquee.active, marquee.x0, marquee.y0, model.layers, selectedIds.length]) const onMouseUpCanvas = useCallback(() => { if (marquee.active) { @@ -177,6 +206,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { } draggingRef.current = null resizingRef.current = null + resizeGroupRef.current = null rotatingRef.current = null setShowGuides(false) }, [marquee.active]) @@ -184,12 +214,29 @@ export function Canvas({ frame, onChange }: CanvasProps) { const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { e.stopPropagation() resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height } - }, []) + // capture originals for group resize + const ids = Array.from(new Set([...selectedIds, o.id])) + const orig: Record = {} + for (const ly of model.layers) { + for (const ob of ly.objects) { + if (ids.includes(ob.id)) orig[ob.id] = { w: ob.width, h: ob.height } + } + } + resizeGroupRef.current = { ids, orig } + }, [selectedIds, model.layers]) const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => { e.stopPropagation() rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate } - }, []) + const ids = Array.from(new Set([...selectedIds, o.id])) + const orig: Record = {} + for (const ly of model.layers) { + for (const ob of ly.objects) { + if (ids.includes(ob.id)) orig[ob.id] = { d: ob.rotate } + } + } + rotateGroupRef.current = { ids, orig } + }, [selectedIds, model.layers]) return (
({ ...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