텍스트 인라인 편집 기능 추가
This commit is contained in:
parent
f300b637d1
commit
c20e393a1a
|
|
@ -193,6 +193,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
|
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
|
||||||
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
|
// 인라인 편집 상태
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const isSelected = selectedComponentId === component.id;
|
const isSelected = selectedComponentId === component.id;
|
||||||
const isMultiSelected = selectedComponentIds.includes(component.id);
|
const isMultiSelected = selectedComponentIds.includes(component.id);
|
||||||
const isLocked = component.locked === true;
|
const isLocked = component.locked === true;
|
||||||
|
|
@ -290,15 +295,76 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 더블 클릭 핸들러 (텍스트 컴포넌트만)
|
// 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
|
||||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
if (component.type !== "text" && component.type !== "label") return;
|
if (component.type !== "text" && component.type !== "label") return;
|
||||||
|
if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
fitTextToContent();
|
|
||||||
|
// 인라인 편집 모드 진입
|
||||||
|
setEditValue(component.defaultValue || "");
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 시작 시 textarea에 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료를 위한 ref
|
||||||
|
const editValueRef = useRef(editValue);
|
||||||
|
const isEditingRef = useRef(isEditing);
|
||||||
|
editValueRef.current = editValue;
|
||||||
|
isEditingRef.current = isEditing;
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료 (저장 후 종료)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSelected && !isMultiSelected && isEditingRef.current) {
|
||||||
|
// 현재 편집 값으로 저장
|
||||||
|
if (editValueRef.current !== component.defaultValue) {
|
||||||
|
updateComponent(component.id, { defaultValue: editValueRef.current });
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
|
||||||
|
|
||||||
|
// 인라인 편집 저장
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
updateComponent(component.id, {
|
||||||
|
defaultValue: editValue,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 취소
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 키보드 핸들러
|
||||||
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditCancel();
|
||||||
|
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
// Enter: 저장 (Shift+Enter는 줄바꿈)
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditSave();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
// 편집 모드에서는 드래그 비활성화
|
||||||
|
if (isEditing) return;
|
||||||
|
|
||||||
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -636,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
switch (component.type) {
|
switch (component.type) {
|
||||||
case "text":
|
case "text":
|
||||||
case "label":
|
case "label":
|
||||||
|
// 인라인 편집 모드
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleEditSave}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
className="h-full w-full resize-none border-none bg-transparent p-0 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
style={{
|
||||||
|
fontSize: `${component.fontSize}px`,
|
||||||
|
color: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ export function ReportDesignerCanvas() {
|
||||||
duplicateComponents,
|
duplicateComponents,
|
||||||
copyStyles,
|
copyStyles,
|
||||||
pasteStyles,
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
showRuler,
|
showRuler,
|
||||||
|
|
@ -646,6 +647,13 @@ export function ReportDesignerCanvas() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+F (또는 Cmd+Shift+F): 텍스트 크기 자동 맞춤
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
fitSelectedToContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+C (또는 Cmd+C): 복사
|
// Ctrl+C (또는 Cmd+C): 복사
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -699,6 +707,7 @@ export function ReportDesignerCanvas() {
|
||||||
duplicateComponents,
|
duplicateComponents,
|
||||||
copyStyles,
|
copyStyles,
|
||||||
pasteStyles,
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ interface ReportDesignerContextType {
|
||||||
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
|
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
|
||||||
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
|
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
|
||||||
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
|
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
|
||||||
|
fitSelectedToContent: () => void; // Ctrl+Shift+F 텍스트 크기 자동 맞춤
|
||||||
|
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
|
|
@ -1503,6 +1504,114 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
[currentPageId],
|
[currentPageId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 텍스트 컴포넌트 크기 자동 맞춤 (Ctrl+Shift+F)
|
||||||
|
const fitSelectedToContent = useCallback(() => {
|
||||||
|
const MM_TO_PX = 4; // 고정 스케일 팩터
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 ID 결정
|
||||||
|
const targetIds =
|
||||||
|
selectedComponentIds.length > 0
|
||||||
|
? selectedComponentIds
|
||||||
|
: selectedComponentId
|
||||||
|
? [selectedComponentId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targetIds.length === 0) return;
|
||||||
|
|
||||||
|
// 텍스트/레이블 컴포넌트만 필터링
|
||||||
|
const textComponents = components.filter(
|
||||||
|
(c) =>
|
||||||
|
targetIds.includes(c.id) &&
|
||||||
|
(c.type === "text" || c.type === "label") &&
|
||||||
|
!c.locked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textComponents.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 불가",
|
||||||
|
description: "선택된 텍스트 컴포넌트가 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 페이지 설정 가져오기
|
||||||
|
const page = currentPage;
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const canvasWidthPx = page.width * MM_TO_PX;
|
||||||
|
const canvasHeightPx = page.height * MM_TO_PX;
|
||||||
|
const marginRightPx = (page.margins?.right || 10) * MM_TO_PX;
|
||||||
|
const marginBottomPx = (page.margins?.bottom || 10) * MM_TO_PX;
|
||||||
|
|
||||||
|
// 각 텍스트 컴포넌트 크기 조정
|
||||||
|
textComponents.forEach((comp) => {
|
||||||
|
const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트");
|
||||||
|
const fontSize = comp.fontSize || 14;
|
||||||
|
|
||||||
|
// 최대 크기 (여백 고려)
|
||||||
|
const maxWidth = canvasWidthPx - marginRightPx - comp.x;
|
||||||
|
const maxHeight = canvasHeightPx - marginBottomPx - comp.y;
|
||||||
|
|
||||||
|
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||||||
|
const lines = displayValue.split("\n");
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "nowrap";
|
||||||
|
measureEl.style.fontSize = `${fontSize}px`;
|
||||||
|
measureEl.style.fontWeight = comp.fontWeight || "normal";
|
||||||
|
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||||
|
measureEl.textContent = line || " ";
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
|
||||||
|
const lineWidth = measureEl.getBoundingClientRect().width;
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 패딩 및 높이 계산
|
||||||
|
const horizontalPadding = 24;
|
||||||
|
const verticalPadding = 20;
|
||||||
|
const lineHeight = fontSize * 1.5;
|
||||||
|
const totalHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||||||
|
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||||||
|
|
||||||
|
const newWidth = Math.max(50, finalWidth);
|
||||||
|
const newHeight = Math.max(30, finalHeight);
|
||||||
|
|
||||||
|
// 크기 업데이트 - setLayoutConfig 직접 사용
|
||||||
|
setLayoutConfig((prev) => ({
|
||||||
|
pages: prev.pages.map((p) =>
|
||||||
|
p.page_id === currentPageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
components: p.components.map((c) =>
|
||||||
|
c.id === comp.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
width: snapToGrid ? Math.round(newWidth / gridSize) * gridSize : newWidth,
|
||||||
|
height: snapToGrid ? Math.round(newHeight / gridSize) * gridSize : newHeight,
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 완료",
|
||||||
|
description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, snapToGrid, gridSize, toast]);
|
||||||
|
|
||||||
// 컴포넌트 삭제 (현재 페이지에서)
|
// 컴포넌트 삭제 (현재 페이지에서)
|
||||||
const removeComponent = useCallback(
|
const removeComponent = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
|
@ -1889,6 +1998,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
copyStyles,
|
copyStyles,
|
||||||
pasteStyles,
|
pasteStyles,
|
||||||
duplicateAtPosition,
|
duplicateAtPosition,
|
||||||
|
fitSelectedToContent,
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue