Feat: 캔버스 직사각형 렌더링 추가 - S11P21S002-103

This commit is contained in:
jhynsoo 2024-09-02 15:33:40 +09:00
parent ee85f6fdb2
commit f7cc913e14
9 changed files with 334 additions and 4 deletions

View File

@ -18,14 +18,17 @@
"axios": "^1.7.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"konva": "^9.3.14",
"lucide-react": "^0.436.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-konva": "^18.2.10",
"react-resizable-panels": "^2.1.1",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-image": "^1.1.1",
"zod": "^3.23.8",
"zustand": "^4.5.5"
},
@ -5590,7 +5593,6 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/qs": {
@ -5609,7 +5611,6 @@
"version": "18.3.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz",
"integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -5626,6 +5627,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.8",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz",
"integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@ -6999,7 +7009,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@ -8924,6 +8933,18 @@
"node": ">=0.10.0"
}
},
"node_modules/its-fine": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
"integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.0"
},
"peerDependencies": {
"react": ">=18.0"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@ -9098,6 +9119,26 @@
"node": ">=6"
}
},
"node_modules/konva": {
"version": "9.3.14",
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.14.tgz",
"integrity": "sha512-Gmm5lyikGYJyogKQA7Fy6dKkfNh350V6DwfZkid0RVrGYP2cfCsxuMxgF5etKeCv7NjXYpJxKqi1dYkIkX/dcA==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -10642,6 +10683,53 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/react-konva": {
"version": "18.2.10",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
"integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.2",
"its-fine": "^1.1.1",
"react-reconciler": "~0.29.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-remove-scroll": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz",
@ -12479,6 +12567,16 @@
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/use-image": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.1.tgz",
"integrity": "sha512-n4YO2k8AJG/BcDtxmBx8Aa+47kxY5m335dJiCQA5tTeVU4XdhrhqR6wT0WISRXwdMEOv5CSjqekDZkEMiiWaYQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/use-sidecar": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",

View File

@ -24,14 +24,17 @@
"axios": "^1.7.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"konva": "^9.3.14",
"lucide-react": "^0.436.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-konva": "^18.2.10",
"react-resizable-panels": "^2.1.1",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-image": "^1.1.1",
"zod": "^3.23.8",
"zustand": "^4.5.5"
},

BIN
frontend/public/sample.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

View File

@ -0,0 +1,40 @@
import { Label } from '@/types';
import Konva from 'konva';
import { useEffect, useRef } from 'react';
import { Line } from 'react-konva';
export default function LabelRect({
isSelected,
onSelect,
info,
}: {
isSelected: boolean;
onSelect: () => void;
info: Label;
}) {
const rectRef = useRef<Konva.Line>(null);
const trRef = useRef<Konva.Transformer>(null);
useEffect(() => {
if (isSelected) {
trRef.current?.nodes([rectRef.current as Konva.Node]);
trRef.current?.getLayer()?.batchDraw();
}
}, [isSelected]);
return (
<Line
points={info.coordinates.flat()}
stroke={info.color}
strokeWidth={1}
ref={rectRef}
onMouseDown={onSelect}
onTouchStart={onSelect}
strokeScaleEnabled={false}
fillAfterStrokeEnabled={false}
fill={`${info.color}33`}
closed
draggable
/>
);
}

View File

@ -0,0 +1,14 @@
import { Meta } from '@storybook/react';
import ImageCanvas from '.';
const meta: Meta<typeof ImageCanvas> = {
title: 'Components/ImageCanvas',
component: ImageCanvas,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
export const Default = () => <ImageCanvas />;

View File

@ -0,0 +1,145 @@
import useCanvasStore from '@/stores/useCanvasStore';
import Konva from 'konva';
import { useEffect, useRef, useState } from 'react';
import { Image, Layer, Stage } from 'react-konva';
import useImage from 'use-image';
import { Label } from '@/types';
import LabelRect from './LabelRect';
import { Vector2d } from 'konva/lib/types';
const mockLabels: Label[] = [
{
id: 1,
name: 'Label 1',
color: '#FFaa33',
type: 'rect',
coordinates: [
[100, 100],
[200, 100],
[200, 200],
[100, 200],
],
},
{
id: 2,
name: 'Label 3',
color: '#aa33ff',
type: 'rect',
coordinates: [
[1200, 200],
[1200, 400],
[1400, 400],
[1400, 200],
],
},
{
id: 3,
name: 'Label 3',
color: '#aaff33',
type: 'polygon',
// star shape
coordinates: [
[500, 375],
[523, 232],
[600, 232],
[535, 175],
[560, 100],
[500, 150],
[440, 100],
[465, 175],
[400, 232],
[477, 232],
],
},
];
export default function ImageCanvas() {
const stageWidth = window.innerWidth;
const stageHeight = window.innerHeight;
const stageRef = useRef<Konva.Stage>(null);
const scale = useRef<number>(0);
const imageUrl = '/sample.jpg';
const labels = useCanvasStore((state) => state.labels) ?? [];
const [selectedId, setSelectedId] = useState<number | null>(null);
const [image, imageStatus] = useImage(imageUrl);
const handleZoom = (e: Konva.KonvaEventObject<WheelEvent>) => {
const scaleBy = 1.05;
const oldScale = scale.current;
const mousePointTo = {
x: (stageRef.current?.getPointerPosition()?.x ?? 0) / oldScale - stageRef.current!.x() / oldScale,
y: (stageRef.current?.getPointerPosition()?.y ?? 0) / oldScale - stageRef.current!.y() / oldScale,
};
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
scale.current = newScale;
stageRef.current?.scale({ x: newScale, y: newScale });
const newPos = {
x: -(mousePointTo.x - (stageRef.current?.getPointerPosition()?.x ?? 0) / newScale) * newScale,
y: -(mousePointTo.y - (stageRef.current?.getPointerPosition()?.y ?? 0) / newScale) * newScale,
};
stageRef.current?.position(newPos);
stageRef.current?.batchDraw();
};
const handleScroll = (e: Konva.KonvaEventObject<WheelEvent>) => {
const delta = -e.evt.deltaY;
const x = stageRef.current?.x();
const y = stageRef.current?.y();
const newX = e.evt.shiftKey ? x! + delta : x!;
const newY = e.evt.shiftKey ? y! : y! + delta;
stageRef.current?.position({ x: newX, y: newY });
stageRef.current?.batchDraw();
};
const handleWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
if (stageRef.current === null) return;
e.evt.preventDefault();
e.evt.ctrlKey ? handleZoom(e) : handleScroll(e);
};
const getScale = (): Vector2d => {
if (scale.current) return { x: scale.current, y: scale.current };
const widthRatio = stageWidth / image!.width;
const heightRatio = stageHeight / image!.height;
scale.current = Math.min(widthRatio, heightRatio);
console.log(scale);
return { x: scale.current, y: scale.current };
};
// TODO: remove mock data
useEffect(() => {
useCanvasStore.setState({ labels: mockLabels });
}, []);
return imageStatus === 'loaded' ? (
<Stage
ref={stageRef}
width={stageWidth}
height={stageHeight}
className="overflow-hidden bg-gray-200"
draggable
onWheel={handleWheel}
scale={getScale()}
>
<Layer>
<Image image={image} />
</Layer>
{labels.map((label) => (
<Layer key={label.id}>
{label.type === 'rect' ? (
<LabelRect
isSelected={label.id === selectedId}
onSelect={() => setSelectedId(label.id)}
info={label}
/>
) : (
<></>
)}
</Layer>
))}
</Stage>
) : (
<div></div>
);
}

View File

@ -65,16 +65,22 @@ export default function WorkspaceLayout() {
id: 1,
name: 'Label 1',
color: '#FFaa33',
coordinates: [],
type: 'rect',
},
{
id: 2,
name: 'Label 2',
color: '#aaFF55',
coordinates: [],
type: 'rect',
},
{
id: 3,
name: 'Label 3',
color: '#77aaFF',
coordinates: [],
type: 'rect',
},
];
@ -88,7 +94,7 @@ export default function WorkspaceLayout() {
projects={workspace.projects}
/>
<ResizablePanel className="flex w-full items-center">
<main className="grow">
<main className="h-full grow">
<Outlet />
</main>
<WorkspaceLabelBar labels={labels} />

View File

@ -0,0 +1,22 @@
import { Label } from '@/types';
import { create } from 'zustand';
interface CanvasState {
image: string;
labels: Label[];
changeImage: (image: string, labels: Label[]) => void;
addLabel: (label: Label) => void;
removeLabel: (labelId: number) => void;
updateLabel: (label: Label) => void;
}
const useCanvasStore = create<CanvasState>()((set) => ({
image: '',
labels: [],
changeImage: (image: string, labels: Label[]) => set({ image, labels }),
addLabel: (label: Label) => set((state) => ({ labels: [...state.labels, label] })),
removeLabel: (labelId: number) => set((state) => ({ labels: state.labels.filter((label) => label.id !== labelId) })),
updateLabel: (label: Label) => set((state) => ({ labels: state.labels.map((l) => (l.id === label.id ? label : l)) })),
}));
export default useCanvasStore;

View File

@ -30,4 +30,6 @@ export type Label = {
id: number;
name: string;
color: string;
type: 'polygon' | 'rect';
coordinates: Array<[number, number]>;
};