diff --git a/.cursor/rules/shadcn-tailwind-v4.mdc b/.cursor/rules/shadcn-tailwind-v4.mdc new file mode 100644 index 0000000..a5413ad --- /dev/null +++ b/.cursor/rules/shadcn-tailwind-v4.mdc @@ -0,0 +1,122 @@ +--- +description: +globs: +alwaysApply: false +--- +# Tailwind CSS v4 + Shadcn UI 호환성 규칙 + +## **CSS 설정 (src/index.css)** + +- **@theme 레이어 사용** + ```css + @theme { + --radius: 0.5rem; + } + ``` + +- **CSS 변수 정의** + - `:root`에 라이트 모드 색상 변수 정의 + - `.dark`에 다크 모드 색상 변수 정의 + - `hsl(var(--foreground))` 형태로 색상 사용 + +## **cn 함수 (src/lib/utils.ts)** + +- **에러 핸들링 필수** + ```typescript + // ✅ DO: fallback 로직 포함 + export function cn(...inputs: ClassValue[]) { + try { + return twMerge(clsx(inputs)); + } catch (error) { + console.warn("tailwind-merge fallback:", error); + return clsx(inputs); + } + } + + // ❌ DON'T: 에러 핸들링 없이 사용 + export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); + } + ``` + +## **컴포넌트 스타일링** + +- **CSS 변수 활용** + ```typescript + // ✅ DO: CSS 변수 기반 스타일링 + className="bg-background text-foreground border-border" + + // ✅ DO: cn 함수로 조건부 스타일링 + className={cn( + "base-styles", + condition && "conditional-styles" + )} + ``` + +- **색상 시스템 준수** + - `background`, `foreground`, `primary`, `secondary` 등 정의된 변수 사용 + - 직접 색상 값 대신 변수 사용 + +## **패키지 관리** + +- **필수 패키지** + ```json + { + "@tailwindcss/cli": "^4.1.10", + "@tailwindcss/vite": "^4.1.10", + "tailwind-merge": "latest", + "clsx": "^2.1.1", + "class-variance-authority": "^0.7.1" + } + ``` + +- **제거해야 할 파일** + - `tailwind.config.js` (v4는 CSS-first 방식) + - `postcss.config.js` (v4는 PostCSS 불필요) + +## **Vite 설정** + +- **플러그인 설정** + ```typescript + // vite.config.ts + import tailwindcss from "@tailwindcss/vite"; + + export default defineConfig({ + plugins: [react(), tailwindcss()], + }); + ``` + +## **문제 해결** + +- **tailwind-merge 오류 시** + - 최신 버전으로 업데이트 + - cn 함수에 fallback 로직 구현 + +- **스타일이 적용되지 않을 때** + - CSS 변수가 올바르게 정의되었는지 확인 + - @theme 레이어가 포함되었는지 확인 + +- **빌드 오류 시** + - node_modules 캐시 삭제 후 재설치 + - package-lock.json 삭제 후 재설치 + +## **모범 사례** + +- **컴포넌트 개발 시** + - 항상 CSS 변수 사용 + - cn 함수로 클래스 조합 + - 조건부 스타일링에 적절한 패턴 적용 + +- **테마 관리** + - 라이트/다크 모드 변수 동시 정의 + - 일관된 색상 시스템 유지 + +- **성능 최적화** + - 불필요한 클래스 중복 방지 + - cn 함수 사용으로 클래스 충돌 해결 + +## **참고 자료** + +- [Tailwind CSS v4 공식 문서](https://tailwindcss.com/docs/v4-beta) +- [Shadcn UI + Tailwind v4 가이드](https://www.luisball.com/blog/shadcn-ui-with-tailwind-v4) +- [Shadcn UI 공식 설치 가이드](https://ui.shadcn.com/docs/installation/manual) diff --git a/.cursor/rules/xlsx-js-style.mdc b/.cursor/rules/xlsx-js-style.mdc index b93c988..cc1602e 100644 --- a/.cursor/rules/xlsx-js-style.mdc +++ b/.cursor/rules/xlsx-js-style.mdc @@ -3,3 +3,193 @@ description: globs: alwaysApply: false --- +# xlsx-js-style 스타일 보존 규칙 + +## **핵심 원칙** +- xlsx-js-style의 공식 API 구조를 직접 활용하여 스타일 변환 +- 복잡한 색상 변환 로직 대신 공식 COLOR_STYLE 형식 지원 +- 배경색과 테두리 색상 누락 방지를 위한 완전한 스타일 매핑 + +## **공식 xlsx-js-style API 활용** + +### **색상 처리 (COLOR_STYLE)** +```typescript +// ✅ DO: 공식 COLOR_STYLE 형식 모두 지원 +function convertXlsxColorToLuckysheet(colorObj: any): string { + // RGB 형태: {rgb: "FFCC00"} + if (colorObj.rgb) { /* RGB 처리 */ } + + // Theme 색상: {theme: 4} 또는 {theme: 1, tint: 0.4} + if (typeof colorObj.theme === 'number') { /* Theme 처리 */ } + + // Indexed 색상: Excel 기본 색상표 + if (typeof colorObj.indexed === 'number') { /* Indexed 처리 */ } +} + +// ❌ DON'T: 특정 색상 형식만 처리 +function badColorConvert(colorObj: any): string { + return colorObj.rgb || "rgb(0,0,0)"; // rgb만 처리하고 theme, indexed 무시 +} +``` + +### **스타일 객체 변환** +```typescript +// ✅ DO: 공식 스타일 속성 완전 매핑 +function convertXlsxStyleToLuckysheet(xlsxStyle: any): any { + const luckyStyle: any = {}; + + // 폰트: {name: "Courier", sz: 24, bold: true, color: {rgb: "FF0000"}} + if (xlsxStyle.font) { + if (xlsxStyle.font.name) luckyStyle.ff = xlsxStyle.font.name; + if (xlsxStyle.font.sz) luckyStyle.fs = xlsxStyle.font.sz; + if (xlsxStyle.font.bold) luckyStyle.bl = 1; + if (xlsxStyle.font.color) { + luckyStyle.fc = convertXlsxColorToLuckysheet(xlsxStyle.font.color); + } + } + + // 배경: {fgColor: {rgb: "E9E9E9"}} + if (xlsxStyle.fill?.fgColor) { + luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor); + } + + // 테두리: {top: {style: "thin", color: {rgb: "000000"}}} + if (xlsxStyle.border) { + luckyStyle.bd = {}; + if (xlsxStyle.border.top) { + luckyStyle.bd.t = { + style: convertBorderStyleToLuckysheet(xlsxStyle.border.top.style), + color: convertXlsxColorToLuckysheet(xlsxStyle.border.top.color) + }; + } + } + + return luckyStyle; +} + +// ❌ DON'T: 수동으로 스타일 속성 하나씩 처리 +luckyCell.v.s = { + ff: cell.s.font?.name || "Arial", + bg: cell.s.fill?.fgColor?.rgb || "rgb(255,255,255)" // 직접 rgb만 처리 +}; +``` + +## **배경색과 테두리 색상 누락 방지** + +### **배경색 처리** +```typescript +// ✅ DO: fgColor와 bgColor 모두 확인 +if (xlsxStyle.fill) { + if (xlsxStyle.fill.fgColor) { + luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor); + } else if (xlsxStyle.fill.bgColor) { + luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.bgColor); + } +} + +// ❌ DON'T: fgColor만 확인 +if (xlsxStyle.fill?.fgColor) { + luckyStyle.bg = xlsxStyle.fill.fgColor.rgb; // 다른 색상 형식 무시 +} +``` + +### **테두리 색상 처리** +```typescript +// ✅ DO: 모든 테두리 방향과 색상 형식 지원 +if (xlsxStyle.border) { + ['top', 'bottom', 'left', 'right'].forEach(side => { + if (xlsxStyle.border[side]) { + luckyStyle.bd[side[0]] = { + style: convertBorderStyleToLuckysheet(xlsxStyle.border[side].style), + color: convertXlsxColorToLuckysheet(xlsxStyle.border[side].color) + }; + } + }); +} + +// ❌ DON'T: 하드코딩된 색상 사용 +luckyStyle.bd.t = { + style: 1, + color: "rgb(0,0,0)" // 실제 색상 무시 +}; +``` + +## **Excel Tint 처리** +```typescript +// ✅ DO: Excel tint 공식 적용 +function applyTintToRgbColor(rgbColor: string, tint: number): string { + const applyTint = (color: number, tint: number): number => { + if (tint < 0) { + return Math.round(color * (1 + tint)); + } else { + return Math.round(color * (1 - tint) + (255 - 255 * (1 - tint))); + } + }; + // RGB 각 채널에 tint 적용 +} + +// ❌ DON'T: tint 무시 +if (colorObj.theme) { + return themeColors[colorObj.theme]; // tint 무시 +} +``` + +## **오류 방지 패턴** + +### **안전한 스타일 읽기** +```typescript +// ✅ DO: 옵셔널 체이닝과 타입 검사 +workbook = XLSX.read(arrayBuffer, { + cellStyles: true // 스타일 정보 보존 +}); + +// 스타일 정보 확인 +if (cell.s) { + console.log(`🎨 셀 ${cellAddress}에 스타일 정보:`, cell.s); + luckyCell.v.s = convertXlsxStyleToLuckysheet(cell.s); +} + +// ❌ DON'T: 스타일 옵션 누락 +workbook = XLSX.read(arrayBuffer); // cellStyles 옵션 없음 +``` + +### **스타일 쓰기 보존** +```typescript +// ✅ DO: 쓰기 시에도 스타일 보존 +const xlsxData = XLSX.write(workbook, { + type: "array", + bookType: "xlsx", + cellStyles: true // 스타일 정보 보존 +}); + +// ❌ DON'T: 쓰기 시 스타일 누락 +const xlsxData = XLSX.write(workbook, { + type: "array", + bookType: "xlsx" + // cellStyles 옵션 없음 +}); +``` + +## **디버깅 및 검증** + +### **스타일 정보 로깅** +```typescript +// ✅ DO: 개발 모드에서 스타일 정보 상세 분석 +if (import.meta.env.DEV && cell.s) { + console.log(`🎨 셀 ${cellAddress} 스타일:`, { + font: cell.s.font, + fill: cell.s.fill, + border: cell.s.border, + alignment: cell.s.alignment + }); +} + +// ❌ DON'T: 스타일 정보 무시 +// 스타일 관련 로그 없음 +``` + +## **참고 사항** +- [xlsx-js-style GitHub](https://github.com/gitbrent/xlsx-js-style) 공식 문서 참조 +- 공식 COLOR_STYLE 형식: `{rgb: "FFCC00"}`, `{theme: 4}`, `{theme: 1, tint: 0.4}` +- 공식 BORDER_STYLE 값: `thin`, `medium`, `thick`, `dotted`, `dashed` 등 +- Excel 테마 색상과 tint 처리는 공식 Excel 색상 공식 사용 diff --git a/src/App.tsx b/src/App.tsx index 77056bc..02b9bca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Button } from "./components/ui/button"; -import TestSheetViewer from "./components/sheet/TestSheetViewer"; +import HomeButton from "./components/ui/homeButton"; +import EditSheetViewer from "./components/sheet/EditSheetViewer"; function App() { const [showTestViewer, setShowTestViewer] = useState(false); @@ -12,7 +13,20 @@ function App() {
-

sheetEasy AI

+ {/* showTestViewer가 true일 때만 홈 버튼 노출 */} + {showTestViewer && ( + { + if (window.confirm("기본 화면으로 돌아가시겠습니까?")) { + setShowTestViewer(false); + } + }} + > + sheetEasy AI + + )}
{/* 테스트 뷰어 토글 버튼 */} @@ -22,12 +36,12 @@ function App() { onClick={() => setShowTestViewer(!showTestViewer)} className="bg-green-500 hover:bg-green-600 text-white border-green-500" > - 🧪 테스트 뷰어 + 🧪 에디트 뷰어 {!showTestViewer && ( - Univer CE 테스트 모드 + Univer CE 에디트 모드 )}
@@ -40,24 +54,24 @@ function App() { {showTestViewer ? ( // 테스트 뷰어 표시
- +
) : ( // 메인 페이지

- 🧪 Univer CE 테스트 모드 + 🧪 Univer CE 에디트 모드

- 현재 Univer CE 전용 테스트 뷰어를 사용해보세요 + 현재 Univer CE 전용 에디트 뷰어를 사용해보세요

diff --git a/src/components/sheet/TestSheetViewer.tsx b/src/components/sheet/EditSheetViewer.tsx similarity index 95% rename from src/components/sheet/TestSheetViewer.tsx rename to src/components/sheet/EditSheetViewer.tsx index 21a0df1..458745b 100644 --- a/src/components/sheet/TestSheetViewer.tsx +++ b/src/components/sheet/EditSheetViewer.tsx @@ -14,6 +14,7 @@ import { UniverSheetsNumfmtUIPlugin } from "@univerjs/sheets-numfmt-ui"; import { UniverUIPlugin } from "@univerjs/ui"; import { cn } from "../../lib/utils"; import LuckyExcel from "@zwight/luckyexcel"; +import PromptInput from "./PromptInput"; // 언어팩 import import DesignEnUS from "@univerjs/design/locale/en-US"; @@ -283,6 +284,7 @@ const TestSheetViewer: React.FC = () => { const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [currentFile, setCurrentFile] = useState(null); + const [prompt, setPrompt] = useState(""); // Univer 초기화 함수 const initializeUniver = useCallback(async (workbookData?: any) => { @@ -561,39 +563,27 @@ const TestSheetViewer: React.FC = () => {
{/* 헤더 */}
-

- 🧪 Univer CE + 파일 업로드 (Window 기반 관리) -

-
- - {isInitialized ? "✅ 초기화 완료" : "⏳ 초기화 중..."} - - +
{currentFile && ( - <> - - 📄 {currentFile.name} - - - + + 📄 {currentFile.name} + )} - - {/* 디버그 정보 */} -
- 전역 인스턴스: {UniverseManager.getInstance() ? "✅" : "❌"} | - 초기화 중: {UniverseManager.isInitializing() ? "⏳" : "❌"} -
+
@@ -602,12 +592,24 @@ const TestSheetViewer: React.FC = () => { ref={containerRef} className="flex-1 relative" style={{ - minHeight: "500px", + minHeight: "0", width: "100%", height: "100%", + flexGrow: 0.85, }} /> + {/* Univer와 입력창 사이 여백 */} +
+ + {/* 프롬프트 입력창 - Univer 하단에 이어서 */} + setPrompt(e.target.value)} + onExecute={() => {}} + disabled={true} + /> + {/* 파일 업로드 오버레이 - 레이어 분리 */} {showUploadOverlay && ( <> diff --git a/src/components/sheet/FileUpload.tsx.bak b/src/components/sheet/FileUpload.tsx.bak deleted file mode 100644 index b7c9dd2..0000000 --- a/src/components/sheet/FileUpload.tsx.bak +++ /dev/null @@ -1,421 +0,0 @@ -import React, { useCallback, useState, useRef } from "react"; -import { Card, CardContent } from "../ui/card"; -import { Button } from "../ui/button"; -import { FileErrorModal } from "../ui/modal"; -import { cn } from "../../lib/utils"; -import { useAppStore } from "../../stores/useAppStore"; -import { - processExcelFile, - getFileErrors, - filterValidFiles, -} from "../../utils/fileProcessor"; - -interface FileUploadProps { - className?: string; -} - -/** - * 파일 업로드 컴포넌트 - * - Drag & Drop 기능 지원 - * - .xls, .xlsx 파일 타입 제한 - * - 접근성 지원 (ARIA 라벨, 키보드 탐색) - * - 반응형 레이아웃 - * - 실제 파일 처리 로직 연결 - */ -export function FileUpload({ className }: FileUploadProps) { - const [isDragOver, setIsDragOver] = useState(false); - const [showErrorModal, setShowErrorModal] = useState(false); - const fileInputRef = useRef(null); - - // 스토어에서 상태 가져오기 - const { - isLoading, - error, - fileUploadErrors, - currentFile, - setLoading, - setError, - uploadFile, - clearFileUploadErrors, - } = useAppStore(); - - /** - * 파일 처리 로직 - */ - const handleFileProcessing = useCallback( - async (file: File) => { - setLoading(true, "파일을 처리하는 중..."); - setError(null); - clearFileUploadErrors(); - - try { - const result = await processExcelFile(file); - uploadFile(result); - - if (result.success) { - console.log("파일 업로드 성공:", result.fileName); - } - } catch (error) { - console.error("파일 처리 중 예상치 못한 오류:", error); - setError("파일 처리 중 예상치 못한 오류가 발생했습니다."); - } finally { - setLoading(false); - } - }, - [setLoading, setError, uploadFile, clearFileUploadErrors], - ); - - /** - * 파일 선택 처리 - */ - const handleFileSelection = useCallback( - async (files: FileList) => { - if (files.length === 0) return; - - // 유효하지 않은 파일들의 에러 수집 - const fileErrors = getFileErrors(files) || []; - const validFiles = filterValidFiles(files) || []; - - // 에러가 있는 파일들을 스토어에 저장 - fileErrors.forEach(({ file, error }) => { - useAppStore.getState().addFileUploadError(file.name, error); - }); - - // 에러가 있으면 모달 표시 - if (fileErrors.length > 0) { - setShowErrorModal(true); - } - - if (validFiles.length === 0) { - setError("업로드 가능한 파일이 없습니다."); - return; - } - - if (validFiles.length > 1) { - setError( - "한 번에 하나의 파일만 업로드할 수 있습니다. 첫 번째 파일을 사용합니다.", - ); - } - - // 첫 번째 유효한 파일 처리 - await handleFileProcessing(validFiles[0]); - }, - [handleFileProcessing, setError], - ); - - /** - * 드래그 앤 드롭 이벤트 핸들러 - */ - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { - setIsDragOver(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - if (isLoading) return; - - const files = e.dataTransfer.files; - if (files && files.length > 0) { - await handleFileSelection(files); - } - }, - [handleFileSelection, isLoading], - ); - - /** - * 파일 선택 버튼 클릭 핸들러 - */ - const handleFilePickerClick = useCallback(() => { - if (isLoading || !fileInputRef.current) return; - fileInputRef.current.click(); - }, [isLoading]); - - /** - * 파일 입력 변경 핸들러 - */ - const handleFileInputChange = useCallback( - async (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - await handleFileSelection(files); - } - // 입력 초기화 (같은 파일 재선택 가능하도록) - e.target.value = ""; - }, - [handleFileSelection], - ); - - /** - * 키보드 이벤트 핸들러 (접근성) - */ - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleFilePickerClick(); - } - }, - [handleFilePickerClick], - ); - - /** - * 에러 모달 닫기 핸들러 - */ - const handleCloseErrorModal = useCallback(() => { - setShowErrorModal(false); - clearFileUploadErrors(); - }, [clearFileUploadErrors]); - - // 파일이 이미 업로드된 경우 성공 상태 표시 - if (currentFile && !error) { - return ( -
- - -
-
- -
-

- 파일 업로드 완료 -

-

- - {currentFile.name} - -

-

- 파일 크기: {(currentFile.size / 1024 / 1024).toFixed(2)} MB -

- -
-
-
-
- ); - } - - return ( -
- - -
- {/* 아이콘 및 제목 */} -
-
- {isLoading ? ( - - ) : error ? ( - - ) : ( - - )} -
-

- {isLoading - ? "파일 처리 중..." - : error - ? "업로드 오류" - : "Excel 파일을 업로드하세요"} -

-

- {isLoading ? ( - 잠시만 기다려주세요... - ) : error ? ( - {error} - ) : ( - <> - - .xlsx, .xls - {" "} - 파일을 드래그 앤 드롭하거나 클릭하여 업로드 - - )} -

-
- - {/* 파일 업로드 에러 목록 */} - {fileUploadErrors.length > 0 && ( -
-

- 파일 업로드 오류: -

-
    - {fileUploadErrors.map((error, index) => ( -
  • - {error.fileName}:{" "} - {error.error} -
  • - ))} -
-
- )} - - {/* 드래그 앤 드롭 영역 */} -
-
-
- {isDragOver ? "📂" : "📄"} -
-
-

- {isDragOver - ? "파일을 여기에 놓으세요" - : "파일을 드래그하거나 클릭하세요"} -

-

- 최대 50MB까지 업로드 가능 -

-
-
-
- - {/* 숨겨진 파일 입력 */} - - - {/* 지원 형식 안내 */} -
-

지원 형식: Excel (.xlsx, .xls)

-

최대 파일 크기: 50MB

-
-
-
-
- - {/* 파일 에러 모달 */} - -
- ); -} diff --git a/src/components/sheet/PromptInput.tsx b/src/components/sheet/PromptInput.tsx new file mode 100644 index 0000000..5e271fc --- /dev/null +++ b/src/components/sheet/PromptInput.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +interface PromptInputProps { + value: string; + onChange?: (e: React.ChangeEvent) => void; + onExecute?: () => void; + disabled?: boolean; + maxLength?: number; +} + +/** + * 에디트 화면 하단 고정 프롬프트 입력창 컴포넌트 + * - 이미지 참고: 입력창, Execute 버튼, 안내문구, 글자수 카운트, 하단 고정 + */ +const PromptInput: React.FC = ({ + value, + onChange, + onExecute, + disabled = true, + maxLength = 500, +}) => { + return ( +
+
+