눈금자(Ruler) 표시 기능 구현
This commit is contained in:
parent
a1ddf4678d
commit
d01ade4e4f
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue