눈금자(Ruler) 표시 기능 구현

This commit is contained in:
dohyeons 2025-10-01 16:27:05 +09:00
parent a1ddf4678d
commit d01ade4e4f
4 changed files with 244 additions and 54 deletions

View File

@ -5,6 +5,7 @@ import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
@ -27,6 +28,7 @@ export function ReportDesignerCanvas() {
pasteComponents,
undo,
redo,
showRuler,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
@ -201,62 +203,82 @@ export function ReportDesignerCanvas() {
{/* 캔버스 스크롤 영역 */}
<div className="flex-1 overflow-auto p-8">
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative mx-auto bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
}}
onClick={handleCanvasClick}
>
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="mx-auto inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */}
{showRuler && (
<div className="flex">
{/* 좌상단 코너 (20x20) */}
<div className="h-5 w-5 bg-gray-200" />
{/* 가로 눈금자 */}
<Ruler orientation="horizontal" length={canvasWidth} />
</div>
)}
{/* 세로 눈금자 + 캔버스 */}
<div className="flex">
{/* 세로 눈금자 */}
{showRuler && <Ruler orientation="vertical" length={canvasHeight} />}
{/* 캔버스 */}
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
}}
onClick={handleCanvasClick}
>
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>

View File

@ -28,6 +28,7 @@ import {
ChevronUp,
Lock,
Unlock,
Ruler as RulerIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
@ -83,6 +84,8 @@ export function ReportDesignerToolbar() {
toggleLock,
lockComponents,
unlockComponents,
showRuler,
setShowRuler,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
@ -211,6 +214,16 @@ export function ReportDesignerToolbar() {
<Grid3x3 className="h-4 w-4" />
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
</Button>
<Button
variant={showRuler ? "default" : "outline"}
size="sm"
onClick={() => setShowRuler(!showRuler)}
className="gap-2"
title="눈금자 표시 켜기/끄기"
>
<RulerIcon className="h-4 w-4" />
{showRuler ? "눈금자 ON" : "눈금자 OFF"}
</Button>
<Button
variant="outline"
size="sm"

View File

@ -0,0 +1,145 @@
"use client";
import { JSX } from "react";
interface RulerProps {
orientation: "horizontal" | "vertical";
length: number; // mm 단위
offset?: number; // 스크롤 오프셋 (px)
}
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준)
const mmToPx = (mm: number) => mm * 3.7795;
const lengthPx = mmToPx(length);
const isHorizontal = orientation === "horizontal";
// 눈금 생성 (10mm 단위 큰 눈금, 5mm 단위 중간 눈금, 1mm 단위 작은 눈금)
const renderTicks = () => {
const ticks: JSX.Element[] = [];
const maxMm = length;
for (let mm = 0; mm <= maxMm; mm++) {
const px = mmToPx(mm);
// 10mm 단위 큰 눈금
if (mm % 10 === 0) {
ticks.push(
<div
key={`major-${mm}`}
className="absolute bg-gray-700"
style={
isHorizontal
? {
left: `${px}px`,
top: "0",
width: "1px",
height: "12px",
}
: {
top: `${px}px`,
left: "0",
height: "1px",
width: "12px",
}
}
/>,
);
// 숫자 표시 (10mm 단위)
if (mm > 0) {
ticks.push(
<div
key={`label-${mm}`}
className="absolute text-[9px] text-gray-600"
style={
isHorizontal
? {
left: `${px + 2}px`,
top: "0px",
}
: {
top: `${px + 2}px`,
left: "0px",
writingMode: "vertical-lr",
}
}
>
{mm}
</div>,
);
}
}
// 5mm 단위 중간 눈금
else if (mm % 5 === 0) {
ticks.push(
<div
key={`medium-${mm}`}
className="absolute bg-gray-500"
style={
isHorizontal
? {
left: `${px}px`,
top: "4px",
width: "1px",
height: "8px",
}
: {
top: `${px}px`,
left: "4px",
height: "1px",
width: "8px",
}
}
/>,
);
}
// 1mm 단위 작은 눈금
else {
ticks.push(
<div
key={`minor-${mm}`}
className="absolute bg-gray-400"
style={
isHorizontal
? {
left: `${px}px`,
top: "8px",
width: "1px",
height: "4px",
}
: {
top: `${px}px`,
left: "8px",
height: "1px",
width: "4px",
}
}
/>,
);
}
}
return ticks;
};
return (
<div
className="relative bg-gray-100 select-none"
style={
isHorizontal
? {
width: `${lengthPx}px`,
height: "20px",
}
: {
width: "20px",
height: `${lengthPx}px`,
}
}
>
{renderTicks()}
</div>
);
}

View File

@ -356,6 +356,10 @@ interface ReportDesignerContextType {
toggleLock: () => void;
lockComponents: () => void;
unlockComponents: () => void;
// 눈금자 표시
showRuler: boolean;
setShowRuler: (show: boolean) => void;
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
@ -377,6 +381,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
// 눈금자 표시
const [showRuler, setShowRuler] = useState(true);
// 정렬 가이드라인
const [alignmentGuides, setAlignmentGuides] = useState<{
vertical: number[];
@ -1340,6 +1347,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
toggleLock,
lockComponents,
unlockComponents,
// 눈금자 표시
showRuler,
setShowRuler,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;