From ccb706de5b90da1cdcc53a4182819888b69ccd6b Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 22:22:23 +0900 Subject: [PATCH] =?UTF-8?q?WYSIWYG:=20Moveable=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9(=EB=93=9C/=EB=A6=AC/=ED=9A=8C=20props=20+=20?= =?UTF-8?q?8px=20=EC=8A=A4=EB=83=85=20=EA=B7=B8=EB=A6=AC=EB=93=9C)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/wysiwyg/Canvas.moveable.test.tsx | 56 +++++ components/wysiwyg/Canvas.tsx | 22 ++ package-lock.json | 251 ++++++++++++++++++++ package.json | 2 + 4 files changed, 331 insertions(+) create mode 100644 components/wysiwyg/Canvas.moveable.test.tsx diff --git a/components/wysiwyg/Canvas.moveable.test.tsx b/components/wysiwyg/Canvas.moveable.test.tsx new file mode 100644 index 0000000..e76d246 --- /dev/null +++ b/components/wysiwyg/Canvas.moveable.test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +vi.mock('react-moveable', () => { + const Mock = (props: Record) =>
+ return { default: Mock } +}) + +const frame: Frame = { + id: 'frame1', + width: 800, + height: 600, + background: '#ffffff', + layers: [ + { + id: 'layer1', + name: 'Layer 1', + visible: true, + locked: false, + objects: [ + { id: 'obj1', type: 'text', x: 10, y: 12, width: 100, height: 40, rotate: 0, zIndex: 0, props: { text: 'Hello', fontSize: 16, color: '#000000' } }, + ], + }, + ], +} + +describe('Canvas + Moveable integration (smoke)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders Moveable with draggable/resizable/rotatable and 8px grid', () => { + render() + + const mv = screen.getByTestId('moveable') + expect(mv).toBeInTheDocument() + + const props = JSON.parse(mv.getAttribute('data-props') || '{}') + expect(props.draggable).toBe(true) + expect(props.resizable).toBe(true) + expect(props.rotatable).toBe(true) + expect(props.snappable).toBe(true) + expect(props.snapGridWidth).toBe(8) + expect(props.snapGridHeight).toBe(8) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 4a7741b..6c3ec42 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' +import Moveable from 'react-moveable' import type { Frame, WObject } from '@/lib/wysiwyg/schema' function snap8(v: number) { @@ -16,6 +17,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { 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 rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) + const [showGuides, setShowGuides] = useState(false) + const [guidePos, setGuidePos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const selected = useMemo(() => { for (const layer of model.layers) { @@ -52,6 +55,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => { setSelectedId(o.id) 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) }) }, []) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { @@ -80,6 +85,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { const dy = e.clientY - d.startY const nx = snap8(d.origX + dx) const ny = snap8(d.origY + dy) + setGuidePos({ x: nx, y: ny }) updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) } }, [updateObject]) @@ -88,6 +94,7 @@ export function Canvas({ frame, onChange }: CanvasProps) { draggingRef.current = null resizingRef.current = null rotatingRef.current = null + setShowGuides(false) }, []) const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { @@ -111,6 +118,12 @@ export function Canvas({ frame, onChange }: CanvasProps) { style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }} data-testid="wysiwyg-canvas" > + {showGuides && ( + <> +
+
+ + )} {model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
)))} + {/* Moveable 최소 통합: 드래그/리사이즈/회전 + 8px 스냅 그리드 활성화 */} +
) } diff --git a/package-lock.json b/package-lock.json index cc96122..c185243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "jszip": "^3.10.1", + "moveable": "^0.53.0", "next": "16.0.2", "next-intl": "^4.5.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-moveable": "^0.56.0", "zod": "^4.1.12", "zustand": "^5.0.8" }, @@ -385,6 +387,15 @@ "node": ">=18" } }, + "node_modules/@cfcs/core": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.6.tgz", + "integrity": "sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==", + "license": "MIT", + "dependencies": { + "@egjs/component": "^3.0.2" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -520,6 +531,12 @@ "node": ">=18" } }, + "node_modules/@daybrush/utils": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@daybrush/utils/-/utils-1.13.0.tgz", + "integrity": "sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -587,6 +604,33 @@ "react": ">=16.8.0" } }, + "node_modules/@egjs/agent": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@egjs/agent/-/agent-2.4.4.tgz", + "integrity": "sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==", + "license": "MIT" + }, + "node_modules/@egjs/children-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz", + "integrity": "sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==", + "license": "MIT", + "dependencies": { + "@egjs/list-differ": "^1.0.0" + } + }, + "node_modules/@egjs/component": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.5.tgz", + "integrity": "sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==", + "license": "MIT" + }, + "node_modules/@egjs/list-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/list-differ/-/list-differ-1.0.1.tgz", + "integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", @@ -2449,6 +2493,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@scena/dragscroll": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scena/dragscroll/-/dragscroll-1.4.0.tgz", + "integrity": "sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.6.0", + "@scena/event-emitter": "^1.0.2" + } + }, + "node_modules/@scena/event-emitter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@scena/event-emitter/-/event-emitter-1.0.5.tgz", + "integrity": "sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.1.1" + } + }, + "node_modules/@scena/matrix": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scena/matrix/-/matrix-1.1.1.tgz", + "integrity": "sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.4.0" + } + }, "node_modules/@schummar/icu-type-parser": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", @@ -4563,6 +4635,52 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/croact": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/croact/-/croact-1.0.4.tgz", + "integrity": "sha512-9GhvyzTY/IVUrMQ2iz/mzgZ8+NcjczmIo/t4FkC1CU0CEcau6v6VsEih4jkTa4ZmRgYTF0qXEZLObCzdDFplpw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/list-differ": "^1.0.0" + } + }, + "node_modules/croact-css-styled": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/croact-css-styled/-/croact-css-styled-1.1.9.tgz", + "integrity": "sha512-G7yvRiVJ3Eoj0ov2h2xR4312hpOzATay2dGS9clK8yJQothjH1sBXIyvOeRP5wBKD9mPcKcoUXPCPsl0tQog4w==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "css-styled": "~1.0.8", + "framework-utils": "^1.1.0" + } + }, + "node_modules/croact-moveable": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/croact-moveable/-/croact-moveable-0.9.0.tgz", + "integrity": "sha512-fc3bieV6CdqqZFtzsSLi9KmvUMFW3oakUfhPCls1BxKjOfUfn8rktteGED2341A/Qghy8tI3Hm6SdocIc68IKg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/agent": "^2.2.1", + "@egjs/children-differ": "^1.0.1", + "@egjs/list-differ": "^1.0.0", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "@scena/matrix": "^1.1.1", + "croact-css-styled": "^1.1.9", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.3", + "overlap-area": "^1.1.0", + "react-css-styled": "^1.1.9", + "react-moveable": "~0.56.0" + }, + "peerDependencies": { + "croact": "^1.0.4" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4578,6 +4696,25 @@ "node": ">= 8" } }, + "node_modules/css-styled": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/css-styled/-/css-styled-1.0.8.tgz", + "integrity": "sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0" + } + }, + "node_modules/css-to-mat": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/css-to-mat/-/css-to-mat-1.1.1.tgz", + "integrity": "sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/matrix": "^1.0.0" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -5723,6 +5860,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framework-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/framework-utils/-/framework-utils-1.1.0.tgz", + "integrity": "sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5799,6 +5942,16 @@ "node": ">=6.9.0" } }, + "node_modules/gesto": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/gesto/-/gesto-1.19.4.tgz", + "integrity": "sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/event-emitter": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7207,6 +7360,24 @@ "setimmediate": "^1.0.5" } }, + "node_modules/keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==", + "license": "MIT" + }, + "node_modules/keycon": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/keycon/-/keycon-1.4.0.tgz", + "integrity": "sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==", + "license": "MIT", + "dependencies": { + "@cfcs/core": "^0.0.6", + "@daybrush/utils": "^1.7.1", + "@scena/event-emitter": "^1.0.2", + "keycode": "^2.2.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7703,6 +7874,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moveable": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/moveable/-/moveable-0.53.0.tgz", + "integrity": "sha512-71jS9zIoQzMhnNvduhg4tUEdm23+fO/40FN7muVMbZvVwbTku2MIxxLhnU4qFvxI4oVxn75l79SbtgjuA+s7Pw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/event-emitter": "^1.0.5", + "croact": "^1.0.4", + "croact-moveable": "~0.9.0", + "react-moveable": "~0.56.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8065,6 +8249,15 @@ "node": ">= 0.8.0" } }, + "node_modules/overlap-area": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz", + "integrity": "sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.7.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8392,6 +8585,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-css-styled": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-css-styled/-/react-css-styled-1.1.9.tgz", + "integrity": "sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==", + "license": "MIT", + "dependencies": { + "css-styled": "~1.0.8", + "framework-utils": "^1.1.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -8411,6 +8614,36 @@ "dev": true, "license": "MIT" }, + "node_modules/react-moveable": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/react-moveable/-/react-moveable-0.56.0.tgz", + "integrity": "sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/agent": "^2.2.1", + "@egjs/children-differ": "^1.0.1", + "@egjs/list-differ": "^1.0.0", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "@scena/matrix": "^1.1.1", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.3", + "overlap-area": "^1.1.0", + "react-css-styled": "^1.1.9", + "react-selecto": "^1.25.0" + } + }, + "node_modules/react-selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/react-selecto/-/react-selecto-1.26.3.tgz", + "integrity": "sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==", + "license": "MIT", + "dependencies": { + "selecto": "~1.26.3" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -8705,6 +8938,24 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/selecto/-/selecto-1.26.3.tgz", + "integrity": "sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/children-differ": "^1.0.1", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "css-styled": "^1.0.8", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.4", + "keycon": "^1.2.0", + "overlap-area": "^1.1.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/package.json b/package.json index f86dac9..e6e5106 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "jszip": "^3.10.1", + "moveable": "^0.53.0", "next": "16.0.2", "next-intl": "^4.5.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-moveable": "^0.56.0", "zod": "^4.1.12", "zustand": "^5.0.8" },