Compare commits

...

3 Commits

Author SHA1 Message Date
sheetEasy AI Team
ba58aaabf5 유니버CE 초기화 테스트 완료 2025-06-24 14:15:09 +09:00
sheetEasy AI Team
d9a198a157 Fix Luckysheet functionlist error and improve file processing 2025-06-23 14:35:15 +09:00
sheetEasy AI Team
de6b4debac 럭키시트 로드 가능, 옵션이 안불러짐 2025-06-21 13:58:49 +09:00
26 changed files with 10437 additions and 1275 deletions

View File

@@ -0,0 +1,194 @@
---
description:
globs:
alwaysApply: false
---
# Luckysheet Functionlist Error Prevention
**Root Cause Analysis:**
- Luckysheet internally references multiple functionlist objects at different namespaces
- Timing issues between library loading and object initialization
- Incomplete plugin.js loading which should initialize functionlist objects
## **Critical Loading Order (MUST Follow)**
```typescript
// ✅ DO: Complete library loading sequence
const loadSequence = async () => {
// 1. jQuery (Luckysheet dependency)
await loadResource("js", "https://code.jquery.com/jquery-3.6.0.min.js", "jquery");
// 2. CSS files (in specific order)
await loadResource("css", "/luckysheet/dist/plugins/css/pluginsCss.css", "plugins-css");
await loadResource("css", "/luckysheet/dist/plugins/plugins.css", "plugins-main-css");
await loadResource("css", "/luckysheet/dist/css/luckysheet.css", "luckysheet-css");
await loadResource("css", "/luckysheet/dist/assets/iconfont/iconfont.css", "iconfont-css");
// 3. LuckyExcel (for Excel file processing)
await loadResource("js", "/luckysheet/dist/luckyexcel.umd.js", "luckyexcel");
// 4. Plugin JS (CRITICAL: initializes functionlist)
await loadResource("js", "/luckysheet/dist/plugins/js/plugin.js", "plugin-js");
// 5. Luckysheet main library
await loadResource("js", "/luckysheet/dist/luckysheet.umd.js", "luckysheet");
// 6. CRITICAL: Wait for internal object initialization
await new Promise(resolve => setTimeout(resolve, 1000));
};
```
## **Multi-Level Functionlist Initialization**
```typescript
// ✅ DO: Initialize all possible functionlist reference paths
const initializeFunctionlist = () => {
try {
// Level 1: Core Store objects
if (!window.Store) window.Store = {};
if (!window.Store.functionlist) window.Store.functionlist = [];
if (!window.Store.luckysheet_function) window.Store.luckysheet_function = {};
// Level 2: Global function objects
if (!window.luckysheet_function) window.luckysheet_function = {};
if (!window.functionlist) window.functionlist = [];
// Level 3: Luckysheet internal objects
if (window.luckysheet) {
if (!window.luckysheet.functionlist) window.luckysheet.functionlist = [];
if (!window.luckysheet.formula) window.luckysheet.formula = {};
if (!window.luckysheet.formulaCache) window.luckysheet.formulaCache = {};
if (!window.luckysheet.formulaObjects) window.luckysheet.formulaObjects = {};
}
// Level 4: Store internal structure
if (!window.Store.config) window.Store.config = {};
if (!window.Store.luckysheetfile) window.Store.luckysheetfile = [];
if (!window.Store.currentSheetIndex) window.Store.currentSheetIndex = 0;
} catch (error) {
console.warn("Functionlist initialization warning:", error);
}
};
```
## **TypeScript Window Interface Extension**
```typescript
// ✅ DO: Extend Window interface for all Luckysheet globals
declare global {
interface Window {
luckysheet: any;
LuckyExcel: any;
$: any; // jQuery
Store: any; // Luckysheet Store
luckysheet_function: any; // Luckysheet function list
functionlist: any[]; // Global functionlist
}
}
```
## **Critical Timing Requirements**
- **MUST** call `initializeFunctionlist()` at three points:
1. After library loading sequence completion
2. After 1000ms wait period for internal initialization
3. Immediately before `luckysheet.create()` call
- **MUST** wait at least 1000ms after all libraries are loaded
- **MUST** verify all functionlist objects exist before calling `luckysheet.create()`
## **Error Recovery Pattern**
```typescript
// ✅ DO: Implement robust error recovery
try {
// Final verification before luckysheet.create()
initializeFunctionlist();
// Verify critical objects exist
const verificationResults = {
store: !!window.Store,
functionlist: !!window.Store?.functionlist,
luckysheet: !!window.luckysheet,
createFunction: typeof window.luckysheet?.create === "function"
};
if (!verificationResults.luckysheet || !verificationResults.createFunction) {
throw new Error("Luckysheet not properly initialized");
}
window.luckysheet.create(options);
} catch (error) {
console.error("Luckysheet initialization failed:", error);
// Implement retry logic or fallback
}
```
## **Common Anti-Patterns to Avoid**
```typescript
// ❌ DON'T: Skip plugin.js loading
// plugin.js is CRITICAL for functionlist initialization
// ❌ DON'T: Use insufficient wait times
await new Promise(resolve => setTimeout(resolve, 100)); // TOO SHORT
// ❌ DON'T: Initialize only Store.functionlist
// Multiple objects need initialization
// ❌ DON'T: Call luckysheet.create() immediately after library load
// Internal objects need time to initialize
```
## **Debugging Checklist**
When functionlist errors occur:
1. ✅ Verify all libraries loaded in correct order
2. ✅ Check plugin.js is included and loaded
3. ✅ Confirm 1000ms wait after library loading
4. ✅ Verify all functionlist objects are arrays/objects (not undefined)
5. ✅ Check console for library loading errors
6. ✅ Ensure complete Luckysheet distribution is used (not partial)
## **Critical: Use Official LuckyExcel Pattern**
```typescript
// ✅ DO: Follow official LuckyExcel → Luckysheet pattern exactly
LuckyExcel.transformExcelToLucky(arrayBuffer, fileName,
// Success callback
(exportJson: any, luckysheetfile: any) => {
// CRITICAL: Use exportJson.sheets directly, no custom validation
const luckysheetOptions = {
container: 'luckysheet-container',
data: exportJson.sheets, // Direct usage - don't modify!
title: exportJson.info?.name || fileName,
userInfo: exportJson.info?.creator || "User",
lang: "ko"
};
window.luckysheet.create(luckysheetOptions);
},
// Error callback
(error: any) => {
console.error("LuckyExcel conversion failed:", error);
}
);
```
## **Anti-Pattern: Over-Processing Data**
```typescript
// ❌ DON'T: Modify or validate exportJson.sheets structure
const validatedSheets = exportJson.sheets.map(sheet => ({
name: sheet?.name || `Sheet${index}`,
data: Array.isArray(sheet?.data) ? sheet.data : [],
// ... other modifications
}));
// ❌ DON'T: Use modified data
luckysheet.create({ data: validatedSheets });
```
The root cause of functionlist errors is often data structure mismatch between LuckyExcel output and Luckysheet expectations. Using exportJson.sheets directly maintains the proper internal structure that Luckysheet requires.
This pattern successfully resolves the "Cannot read properties of undefined (reading 'functionlist')" error by ensuring complete library loading sequence and multi-level functionlist initialization.

View File

@@ -0,0 +1,172 @@
---
description:
globs:
alwaysApply: false
---
# Tailwind CSS v4 Migration Guide
## **Key Changes in Tailwind CSS v4**
- **No PostCSS dependency**: v4 has built-in CSS processing
- **Simplified import**: Use `@import "tailwindcss"` instead of separate directives
- **Vite plugin**: Use `@tailwindcss/vite` for Vite integration
- **No config file needed**: Configuration through CSS custom properties
## **Complete Migration Steps**
### **1. Remove All v3 Dependencies**
```bash
# Remove all Tailwind v3 and PostCSS packages
npm uninstall tailwindcss @tailwindcss/postcss @tailwindcss/node @tailwindcss/oxide
npm uninstall autoprefixer postcss postcss-import
```
### **2. Install Tailwind CSS v4**
```bash
# Install v4 packages
npm install @tailwindcss/cli@next @tailwindcss/vite@next
npm install tailwind-merge # For utility merging
```
### **3. Update CSS Import**
```css
/* ❌ DON'T: v3 style imports */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ✅ DO: v4 single import */
@import "tailwindcss";
```
### **4. Update Vite Configuration**
```typescript
// ❌ DON'T: v3 PostCSS setup
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
export default defineConfig({
plugins: [react()],
css: {
postcss: {
plugins: [tailwindcss(), autoprefixer()],
},
},
});
// ✅ DO: v4 Vite plugin
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
```
### **5. Remove Configuration Files**
```bash
# Delete these files (v4 doesn't need them)
rm postcss.config.js
rm tailwind.config.js
```
## **v4 Configuration (Optional)**
### **CSS-based Configuration**
```css
/* src/index.css */
@import "tailwindcss";
/* Custom theme configuration */
@theme {
--color-primary: #3b82f6;
--color-secondary: #64748b;
--font-family-custom: "Inter", sans-serif;
}
```
### **Advanced Configuration**
```css
@import "tailwindcss";
/* Custom utilities */
@utility {
.scroll-smooth {
scroll-behavior: smooth;
}
}
/* Custom components */
@component {
.btn {
@apply px-4 py-2 rounded font-medium;
}
.btn-primary {
@apply bg-blue-500 text-white hover:bg-blue-600;
}
}
```
## **Migration Checklist**
### **Dependencies**
- ✅ Remove: `tailwindcss`, `@tailwindcss/postcss`, `autoprefixer`, `postcss`
- ✅ Install: `@tailwindcss/cli@next`, `@tailwindcss/vite@next`
- ✅ Keep: `tailwind-merge`, `class-variance-authority`, `clsx`
### **Files**
- ✅ Update: `src/index.css` - Use `@import "tailwindcss"`
- ✅ Update: `vite.config.ts` - Use `@tailwindcss/vite` plugin
- ✅ Delete: `postcss.config.js`, `tailwind.config.js`
### **Code Changes**
- ✅ All existing Tailwind classes work the same
- ✅ `tailwind-merge` still works for utility merging
- ✅ Custom CSS can be added alongside Tailwind
## **Benefits of v4**
- **Faster builds**: No PostCSS processing overhead
- **Simpler setup**: Fewer configuration files
- **Better performance**: Optimized CSS generation
- **Modern architecture**: Built for current web standards
## **Troubleshooting**
### **Build Errors**
```bash
# If you see PostCSS errors, ensure all PostCSS packages are removed
npm ls | grep postcss # Should return nothing
# Clean install if needed
rm -rf node_modules package-lock.json
npm install
```
### **CSS Not Loading**
```css
/* Ensure correct import in src/index.css */
@import "tailwindcss";
/* NOT these old imports */
/* @tailwind base; */
/* @tailwind components; */
/* @tailwind utilities; */
```
### **Vite Plugin Issues**
```typescript
// Ensure correct plugin import
import tailwindcss from "@tailwindcss/vite";
// Add to plugins array
plugins: [react(), tailwindcss()]
```
## **Best Practices**
- **Test thoroughly**: Verify all Tailwind classes still work
- **Update incrementally**: Migrate one component at a time if needed
- **Monitor bundle size**: v4 should reduce overall CSS size
- **Use CSS-in-CSS**: Leverage v4's CSS-based configuration for themes

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

BIN
luckysheet-src.zip Normal file

Binary file not shown.

5290
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,16 +19,31 @@
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@univerjs/core": "^0.8.2",
"@univerjs/design": "^0.8.2",
"@univerjs/docs": "^0.8.2",
"@univerjs/docs-ui": "^0.8.2",
"@univerjs/engine-formula": "^0.8.2",
"@univerjs/engine-numfmt": "^0.8.2",
"@univerjs/engine-render": "^0.8.2",
"@univerjs/facade": "^0.5.5",
"@univerjs/sheets": "^0.8.2",
"@univerjs/sheets-formula": "^0.8.2",
"@univerjs/sheets-formula-ui": "^0.8.2",
"@univerjs/sheets-numfmt": "^0.8.2",
"@univerjs/sheets-numfmt-ui": "^0.8.2",
"@univerjs/sheets-ui": "^0.8.2",
"@univerjs/ui": "^0.8.2",
"@univerjs/uniscript": "^0.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"file-saver": "^2.0.5",
"lucide-react": "^0.468.0",
"luckyexcel": "^1.0.1",
"luckysheet": "^2.1.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sheetjs-style": "^0.15.8",
"tailwind-merge": "^2.5.4",
"xlsx": "^0.18.5",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -43,7 +58,9 @@
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
"buffer": "^6.0.3",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
@@ -52,6 +69,7 @@
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"stream-browserify": "^3.0.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.1",

1701
public/output.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { useAppStore } from "./stores/useAppStore";
import { Card, CardContent } from "./components/ui/card";
import { useState } from "react";
import { Button } from "./components/ui/button";
import { FileUpload } from "./components/sheet/FileUpload";
import TestSheetViewer from "./components/sheet/TestSheetViewer";
function App() {
const [showTestViewer, setShowTestViewer] = useState(false);
return (
<div className="min-h-screen bg-gray-50">
{/* 헤더 */}
@@ -14,17 +15,53 @@ function App() {
<h1 className="text-2xl font-bold text-blue-600">sheetEasy AI</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
Excel AI
</span>
{/* 테스트 뷰어 토글 버튼 */}
<Button
variant={showTestViewer ? "default" : "outline"}
size="sm"
onClick={() => setShowTestViewer(!showTestViewer)}
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
>
🧪
</Button>
{!showTestViewer && (
<span className="text-sm text-gray-600">
Univer CE
</span>
)}
</div>
</div>
</div>
</header>
{/* 메인 콘텐츠 */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<FileUpload />
<main className="h-[calc(100vh-4rem)]">
{showTestViewer ? (
// 테스트 뷰어 표시
<div className="h-full">
<TestSheetViewer />
</div>
) : (
// 메인 페이지
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center py-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
🧪 Univer CE
</h2>
<p className="text-lg text-gray-600 mb-8">
Univer CE
</p>
<Button
onClick={() => setShowTestViewer(true)}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3"
>
</Button>
</div>
</div>
)}
</main>
</div>
);

View File

@@ -0,0 +1,491 @@
import {
useEffect,
useLayoutEffect,
useRef,
useCallback,
useState,
} from "react";
import { useAppStore } from "../../stores/useAppStore";
interface SheetViewerProps {
className?: string;
}
/**
* Luckysheet 시트 뷰어 컴포넌트
* - 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용
* - 커스텀 검증이나 데이터 구조 변경 금지
* - luckysheet.create({ data: exportJson.sheets })로 직접 사용
*/
export function SheetViewer({ className }: SheetViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const luckysheetRef = useRef<any>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isContainerReady, setIsContainerReady] = useState(false);
const [librariesLoaded, setLibrariesLoaded] = useState(false);
// 스토어에서 현재 파일 정보만 가져오기 (시트 데이터는 LuckyExcel로 직접 변환)
const { currentFile, setSelectedRange } = useAppStore();
/**
* CDN 배포판 라이브러리 로딩
*/
const loadLuckysheetLibrary = useCallback((): Promise<void> => {
return new Promise((resolve, reject) => {
// 이미 로드된 경우
if (
window.luckysheet &&
window.LuckyExcel &&
window.$ &&
librariesLoaded
) {
console.log("📦 모든 라이브러리가 이미 로드됨");
resolve();
return;
}
const loadResource = (
type: "css" | "js",
src: string,
id: string,
): Promise<void> => {
return new Promise((resourceResolve, resourceReject) => {
// 이미 로드된 리소스 체크
if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
resourceResolve();
return;
}
if (type === "css") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = src;
link.setAttribute("data-luckysheet-id", id);
link.onload = () => resourceResolve();
link.onerror = () =>
resourceReject(new Error(`${id} CSS 로드 실패`));
document.head.appendChild(link);
} else {
const script = document.createElement("script");
script.src = src;
script.setAttribute("data-luckysheet-id", id);
script.onload = () => resourceResolve();
script.onerror = () =>
resourceReject(new Error(`${id} JS 로드 실패`));
document.head.appendChild(script);
}
});
};
const loadSequence = async () => {
try {
// 1. jQuery (Luckysheet 의존성)
if (!window.$) {
await loadResource(
"js",
"https://code.jquery.com/jquery-3.6.0.min.js",
"jquery",
);
}
// 2. CSS 로드 (공식 문서 순서 준수)
await loadResource(
"css",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css",
"plugins-css",
);
await loadResource(
"css",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css",
"plugins-main-css",
);
await loadResource(
"css",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css",
"luckysheet-css",
);
await loadResource(
"css",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css",
"iconfont-css",
);
// 3. Plugin JS 먼저 로드 (functionlist 초기화)
await loadResource(
"js",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js",
"plugin-js",
);
// 4. Luckysheet 메인
if (!window.luckysheet) {
await loadResource(
"js",
"https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js",
"luckysheet",
);
}
// 5. LuckyExcel (Excel 파일 처리용)
if (!window.LuckyExcel) {
await loadResource(
"js",
"https://cdn.jsdelivr.net/npm/luckyexcel/dist/luckyexcel.umd.js",
"luckyexcel",
);
}
// 라이브러리 검증
const validationResults = {
jquery: !!window.$,
luckyExcel: !!window.LuckyExcel,
luckysheet: !!window.luckysheet,
luckysheetCreate: !!(
window.luckysheet &&
typeof window.luckysheet.create === "function"
),
luckysheetDestroy: !!(
window.luckysheet &&
typeof window.luckysheet.destroy === "function"
),
};
if (
!validationResults.luckysheet ||
!validationResults.luckysheetCreate
) {
throw new Error(
"Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
);
}
setLibrariesLoaded(true);
console.log("✅ 라이브러리 로드 완료");
resolve();
} catch (error) {
console.error("❌ 라이브러리 로딩 실패:", error);
reject(error);
}
};
loadSequence();
});
}, [librariesLoaded]);
/**
* 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용하는 방식
* - LuckyExcel.transformExcelToLucky()에서 반환된 exportJson.sheets를 그대로 사용
* - 커스텀 검증이나 데이터 구조 변경 금지
*/
const convertXLSXWithLuckyExcel = useCallback(
async (xlsxBuffer: ArrayBuffer, fileName: string) => {
if (!containerRef.current) {
console.warn("⚠️ 컨테이너가 없습니다.");
return;
}
try {
setIsConverting(true);
setError(null);
console.log("🍀 메모리 정보 기반: LuckyExcel 직접 변환 시작...");
// 라이브러리 로드 확인
await loadLuckysheetLibrary();
// 기존 인스턴스 정리
try {
if (
window.luckysheet &&
typeof window.luckysheet.destroy === "function"
) {
window.luckysheet.destroy();
console.log("✅ 기존 인스턴스 destroy 완료");
}
} catch (destroyError) {
console.warn("⚠️ destroy 중 오류 (무시됨):", destroyError);
}
// 컨테이너 초기화
if (containerRef.current) {
containerRef.current.innerHTML = "";
console.log("✅ 컨테이너 초기화 완료");
}
luckysheetRef.current = null;
console.log("🍀 LuckyExcel.transformExcelToLucky 호출...");
// ArrayBuffer를 File 객체로 변환 (LuckyExcel은 File 객체 필요)
const file = new File([xlsxBuffer], fileName, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
// LuckyExcel의 직접 변환 사용 (Promise 방식)
const luckyExcelResult = await new Promise<any>((resolve, reject) => {
try {
// 🚨 수정: 첫 번째 매개변수는 File 객체여야 함
(window.LuckyExcel as any).transformExcelToLucky(
file, // ArrayBuffer 대신 File 객체 사용
// 성공 콜백
(exportJson: any, luckysheetfile: any) => {
console.log("🍀 LuckyExcel 변환 성공!");
console.log("🍀 exportJson:", exportJson);
console.log("🍀 luckysheetfile:", luckysheetfile);
resolve(exportJson);
},
// 에러 콜백
(error: any) => {
console.error("❌ LuckyExcel 변환 실패:", error);
reject(new Error(`LuckyExcel 변환 실패: ${error}`));
},
);
} catch (callError) {
console.error("❌ LuckyExcel 호출 중 오류:", callError);
reject(callError);
}
});
// 결과 검증
if (
!luckyExcelResult ||
!luckyExcelResult.sheets ||
!Array.isArray(luckyExcelResult.sheets)
) {
throw new Error("LuckyExcel 변환 결과가 유효하지 않습니다.");
}
console.log("🎉 LuckyExcel 변환 완료, Luckysheet 생성 중...");
// 메모리 정보 기반: exportJson.sheets를 그대로 사용
// luckysheet.create({ data: exportJson.sheets })
window.luckysheet.create({
container: containerRef.current?.id || "luckysheet-container",
showinfobar: true,
showtoolbar: true,
showsheetbar: true,
showstatisticBar: true,
allowCopy: true,
allowEdit: true,
// 🚨 핵심: LuckyExcel의 원본 변환 결과를 직접 사용
data: luckyExcelResult.sheets, // 가공하지 않고 그대로 전달
title: luckyExcelResult.info?.name || fileName,
// 🚨 수정: userInfo 경로 수정
userInfo: luckyExcelResult.info?.creator || false,
});
console.log("🎉 Luckysheet 생성 완료! (원본 데이터 직접 사용)");
setIsInitialized(true);
setIsConverting(false);
setError(null);
luckysheetRef.current = window.luckysheet;
} catch (conversionError) {
console.error("❌ 변환 프로세스 실패:", conversionError);
setError(
`변환 프로세스에 실패했습니다: ${
conversionError instanceof Error
? conversionError.message
: String(conversionError)
}`,
);
setIsConverting(false);
setIsInitialized(false);
}
},
[loadLuckysheetLibrary, setSelectedRange],
);
/**
* DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
*/
useLayoutEffect(() => {
if (containerRef.current) {
console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
setIsContainerReady(true);
}
}, []);
/**
* DOM 컨테이너 준비 상태 재체크 (fallback)
*/
useEffect(() => {
if (!isContainerReady) {
const timer = setTimeout(() => {
if (containerRef.current && !isContainerReady) {
console.log("✅ useEffect: DOM 컨테이너 지연 준비 완료");
setIsContainerReady(true);
}
}, 100);
return () => clearTimeout(timer);
}
}, [isContainerReady]);
/**
* 컴포넌트 마운트 시 초기화
*/
useEffect(() => {
if (
currentFile?.xlsxBuffer &&
isContainerReady &&
containerRef.current &&
!isInitialized &&
!isConverting
) {
console.log("🔄 XLSX 버퍼 감지, LuckyExcel 직접 변환 시작...", {
fileName: currentFile.name,
bufferSize: currentFile.xlsxBuffer.byteLength,
containerId: containerRef.current.id,
});
// 중복 실행 방지
setIsConverting(true);
// LuckyExcel로 직접 변환
convertXLSXWithLuckyExcel(currentFile.xlsxBuffer, currentFile.name);
} else if (currentFile && !currentFile.xlsxBuffer) {
setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
}
}, [
currentFile?.xlsxBuffer,
currentFile?.name,
isContainerReady,
isInitialized,
isConverting,
convertXLSXWithLuckyExcel,
]);
/**
* 컴포넌트 언마운트 시 정리
*/
useEffect(() => {
return () => {
if (luckysheetRef.current && window.luckysheet) {
try {
window.luckysheet.destroy();
} catch (error) {
console.warn("⚠️ Luckysheet 정리 중 오류:", error);
}
}
};
}, []);
/**
* 윈도우 리사이즈 처리
*/
useEffect(() => {
const handleResize = () => {
if (luckysheetRef.current && window.luckysheet) {
try {
if (window.luckysheet.resize) {
window.luckysheet.resize();
}
} catch (error) {
console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
}
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div
className={`w-full h-full min-h-[70vh] ${className || ""}`}
style={{ position: "relative" }}
>
{/* Luckysheet 컨테이너 - 항상 렌더링 */}
<div
ref={containerRef}
id="luckysheet-container"
className="w-full h-full"
style={{
minHeight: "70vh",
border: "1px solid #e5e7eb",
borderRadius: "8px",
overflow: "hidden",
}}
/>
{/* 에러 상태 오버레이 */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50 border border-red-200 rounded-lg">
<div className="text-center p-6">
<div className="text-red-600 text-lg font-semibold mb-2">
</div>
<div className="text-red-500 text-sm mb-4">{error}</div>
<button
onClick={() => {
setError(null);
setIsInitialized(false);
setIsConverting(false);
if (currentFile?.xlsxBuffer) {
convertXLSXWithLuckyExcel(
currentFile.xlsxBuffer,
currentFile.name,
);
}
}}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
</button>
</div>
</div>
)}
{/* 로딩 상태 오버레이 */}
{!error &&
(isConverting || !isInitialized) &&
currentFile?.xlsxBuffer && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-center p-6">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-blue-600 text-lg font-semibold mb-2">
{isConverting ? "LuckyExcel 변환 중..." : "시트 초기화 중..."}
</div>
<div className="text-blue-500 text-sm">
{isConverting
? "원본 Excel 데이터를 완전한 스타일로 변환하고 있습니다."
: "잠시만 기다려주세요."}
</div>
</div>
</div>
)}
{/* 데이터 없음 상태 오버레이 */}
{!error && !currentFile?.xlsxBuffer && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 border border-gray-200 rounded-lg">
<div className="text-center p-6">
<div className="text-gray-500 text-lg font-semibold mb-2">
</div>
<div className="text-gray-400 text-sm">
Excel .
</div>
</div>
</div>
)}
{/* 시트 정보 표시 (개발용) */}
{process.env.NODE_ENV === "development" && (
<div className="absolute top-2 right-2 bg-black bg-opacity-75 text-white text-xs p-2 rounded z-10">
<div>: {currentFile?.name}</div>
<div>
XLSX :{" "}
{currentFile?.xlsxBuffer
? `${currentFile.xlsxBuffer.byteLength} bytes`
: "없음"}
</div>
<div> : {isConverting ? "예" : "아니오"}</div>
<div>: {isInitialized ? "완료" : "대기"}</div>
<div> : {isContainerReady ? "완료" : "대기"}</div>
<div>방식: LuckyExcel </div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import React, { useRef, useEffect, useState } from "react";
import { Univer, UniverInstanceType, LocaleType } from "@univerjs/core";
import { defaultTheme } from "@univerjs/design";
import { UniverDocsPlugin } from "@univerjs/docs";
import { UniverDocsUIPlugin } from "@univerjs/docs-ui";
import { UniverFormulaEnginePlugin } from "@univerjs/engine-formula";
import { UniverRenderEnginePlugin } from "@univerjs/engine-render";
import { UniverSheetsPlugin } from "@univerjs/sheets";
import { UniverSheetsFormulaPlugin } from "@univerjs/sheets-formula";
import { UniverSheetsFormulaUIPlugin } from "@univerjs/sheets-formula-ui";
import { UniverSheetsUIPlugin } from "@univerjs/sheets-ui";
import { UniverSheetsNumfmtPlugin } from "@univerjs/sheets-numfmt";
import { UniverSheetsNumfmtUIPlugin } from "@univerjs/sheets-numfmt-ui";
import { UniverUIPlugin } from "@univerjs/ui";
// 언어팩 import
import DesignEnUS from "@univerjs/design/locale/en-US";
import UIEnUS from "@univerjs/ui/locale/en-US";
import DocsUIEnUS from "@univerjs/docs-ui/locale/en-US";
import SheetsEnUS from "@univerjs/sheets/locale/en-US";
import SheetsUIEnUS from "@univerjs/sheets-ui/locale/en-US";
import SheetsFormulaUIEnUS from "@univerjs/sheets-formula-ui/locale/en-US";
import SheetsNumfmtUIEnUS from "@univerjs/sheets-numfmt-ui/locale/en-US";
// CSS 스타일 import
import "@univerjs/design/lib/index.css";
import "@univerjs/ui/lib/index.css";
import "@univerjs/docs-ui/lib/index.css";
import "@univerjs/sheets-ui/lib/index.css";
import "@univerjs/sheets-formula-ui/lib/index.css";
import "@univerjs/sheets-numfmt-ui/lib/index.css";
/**
* Univer CE 최소 구현 - 공식 문서 기반
* 파일 업로드 없이 기본 스프레드시트만 표시
*/
const TestSheetViewer: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const univerRef = useRef<Univer | null>(null);
const initializingRef = useRef<boolean>(false);
const [isInitialized, setIsInitialized] = useState(false);
// Univer 초기화 - 공식 문서 패턴 따라서
useEffect(() => {
if (
!containerRef.current ||
isInitialized ||
univerRef.current ||
initializingRef.current
)
return;
const initializeUniver = async () => {
try {
initializingRef.current = true;
console.log("🚀 Univer CE 초기화 시작");
// 1. Univer 인스턴스 생성
const univer = new Univer({
theme: defaultTheme,
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: {
...DesignEnUS,
...UIEnUS,
...DocsUIEnUS,
...SheetsEnUS,
...SheetsUIEnUS,
...SheetsFormulaUIEnUS,
...SheetsNumfmtUIEnUS,
},
},
});
// 2. 필수 플러그인 등록 (공식 문서 순서)
univer.registerPlugin(UniverRenderEnginePlugin);
univer.registerPlugin(UniverFormulaEnginePlugin);
univer.registerPlugin(UniverUIPlugin, {
container: containerRef.current!,
});
univer.registerPlugin(UniverDocsPlugin);
univer.registerPlugin(UniverDocsUIPlugin);
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverSheetsUIPlugin);
univer.registerPlugin(UniverSheetsFormulaPlugin);
univer.registerPlugin(UniverSheetsFormulaUIPlugin);
univer.registerPlugin(UniverSheetsNumfmtPlugin);
univer.registerPlugin(UniverSheetsNumfmtUIPlugin);
// 3. 기본 워크북 생성
univer.createUnit(UniverInstanceType.UNIVER_SHEET, {
id: "test-workbook",
name: "Test Workbook",
sheetOrder: ["sheet1"],
sheets: {
sheet1: {
id: "sheet1",
name: "Sheet1",
cellData: {
0: {
0: { v: "Hello Univer CE!" },
1: { v: "환영합니다!" },
},
1: {
0: { v: "Status" },
1: { v: "Ready" },
},
},
rowCount: 100,
columnCount: 26,
},
},
});
univerRef.current = univer;
setIsInitialized(true);
console.log("✅ Univer CE 초기화 완료");
} catch (error) {
console.error("❌ Univer CE 초기화 실패:", error);
initializingRef.current = false;
}
};
initializeUniver();
}, [isInitialized]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
try {
if (univerRef.current) {
univerRef.current.dispose();
univerRef.current = null;
}
initializingRef.current = false;
} catch (error) {
console.error("❌ Univer dispose 오류:", error);
}
};
}, []);
return (
<div className="w-full h-screen flex flex-col">
{/* 간단한 헤더 */}
<div className="bg-white border-b p-4 flex-shrink-0">
<h1 className="text-xl font-bold">🧪 Univer CE </h1>
<div className="mt-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isInitialized
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{isInitialized ? "✅ 초기화 완료" : "⏳ 초기화 중..."}
</span>
</div>
</div>
{/* Univer 컨테이너 */}
<div
ref={containerRef}
className="flex-1"
style={{ minHeight: "500px" }}
/>
</div>
);
};
export default TestSheetViewer;

View File

@@ -1,4 +1,4 @@
import React from "react";
// import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
@@ -29,7 +29,7 @@ class MockDragEvent extends Event {
// @ts-ignore
global.DragEvent = MockDragEvent;
const mockUseAppStore = useAppStore as vi.MockedFunction<typeof useAppStore>;
const mockUseAppStore = useAppStore as any;
describe("FileUpload", () => {
const mockSetLoading = vi.fn();

View File

@@ -0,0 +1,402 @@
// import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { vi } from "vitest";
import { SheetViewer } from "../SheetViewer";
import { useAppStore } from "../../../stores/useAppStore";
import type { SheetData } from "../../../types/sheet";
// Mock dependencies
vi.mock("../../../stores/useAppStore");
// Luckysheet 모킹
const mockLuckysheet = {
create: vi.fn(),
destroy: vi.fn(),
resize: vi.fn(),
getSheet: vi.fn(),
getAllSheets: vi.fn(),
setActiveSheet: vi.fn(),
};
// Window.luckysheet 모킹
Object.defineProperty(window, "luckysheet", {
value: mockLuckysheet,
writable: true,
});
// useAppStore 모킹 타입
const mockUseAppStore = vi.mocked(useAppStore);
// 기본 스토어 상태
const defaultStoreState = {
sheets: [],
activeSheetId: null,
currentFile: null,
setSelectedRange: vi.fn(),
isLoading: false,
error: null,
setLoading: vi.fn(),
setError: vi.fn(),
uploadFile: vi.fn(),
clearFileUploadErrors: vi.fn(),
resetApp: vi.fn(),
};
// 테스트용 시트 데이터
const mockSheetData: SheetData[] = [
{
id: "sheet_0",
name: "Sheet1",
data: [
["A1", "B1", "C1"],
["A2", "B2", "C2"],
],
config: {
container: "luckysheet_0",
title: "Sheet1",
lang: "ko",
data: [
{
name: "Sheet1",
index: "0",
celldata: [
{
r: 0,
c: 0,
v: { v: "A1", m: "A1", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 1,
v: { v: "B1", m: "B1", ct: { fa: "General", t: "g" } },
},
],
status: 1,
order: 0,
row: 2,
column: 3,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
},
];
describe("SheetViewer", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAppStore.mockReturnValue(defaultStoreState);
});
afterEach(() => {
// DOM 정리
document.head.innerHTML = "";
});
describe("초기 렌더링", () => {
it("시트 데이터가 없을 때 적절한 메시지를 표시한다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: [],
});
render(<SheetViewer />);
expect(screen.getByText("표시할 시트가 없습니다")).toBeInTheDocument();
expect(
screen.getByText("Excel 파일을 업로드해주세요."),
).toBeInTheDocument();
});
it("시트 데이터가 있을 때 로딩 상태를 표시한다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
render(<SheetViewer />);
expect(screen.getByText("시트 로딩 중...")).toBeInTheDocument();
expect(screen.getByText("잠시만 기다려주세요.")).toBeInTheDocument();
});
it("Luckysheet 컨테이너가 올바르게 렌더링된다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
render(<SheetViewer />);
const container = document.getElementById("luckysheet-container");
expect(container).toBeInTheDocument();
expect(container).toHaveClass("w-full", "h-full");
});
});
describe("Luckysheet 초기화", () => {
it("시트 데이터가 변경되면 Luckysheet를 초기화한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalled();
});
// create 호출 시 전달된 설정 확인
const createCall = mockLuckysheet.create.mock.calls[0];
expect(createCall).toBeDefined();
const config = createCall[0];
expect(config.container).toBe("luckysheet-container");
expect(config.title).toBe("test.xlsx");
expect(config.lang).toBe("ko");
expect(config.data).toHaveLength(1);
});
it("기존 Luckysheet 인스턴스가 있으면 제거한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
const { rerender } = render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalledTimes(1);
});
// 시트 데이터 변경
const newSheetData: SheetData[] = [
{
...mockSheetData[0],
name: "NewSheet",
},
];
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: newSheetData,
currentFile: { name: "new.xlsx", size: 1000, uploadedAt: new Date() },
});
rerender(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.destroy).toHaveBeenCalled();
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
});
});
});
describe("에러 처리", () => {
it("Luckysheet 초기화 실패 시 에러 메시지를 표시한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
// Luckysheet.create에서 에러 발생 시뮬레이션
mockLuckysheet.create.mockImplementation(() => {
throw new Error("Luckysheet 초기화 실패");
});
render(<SheetViewer />);
await waitFor(() => {
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
expect(
screen.getByText(/시트 초기화에 실패했습니다/),
).toBeInTheDocument();
});
// 다시 시도 버튼 확인
const retryButton = screen.getByRole("button", { name: "다시 시도" });
expect(retryButton).toBeInTheDocument();
});
it("다시 시도 버튼을 클릭하면 초기화를 재시도한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
// 첫 번째 시도에서 실패
mockLuckysheet.create.mockImplementationOnce(() => {
throw new Error("첫 번째 실패");
});
render(<SheetViewer />);
await waitFor(() => {
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
});
// 두 번째 시도에서 성공하도록 설정
mockLuckysheet.create.mockImplementationOnce(() => {
// 성공
});
const retryButton = screen.getByRole("button", { name: "다시 시도" });
retryButton.click();
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
});
});
});
describe("이벤트 핸들링", () => {
it("셀 클릭 시 선택된 범위를 스토어에 저장한다", async () => {
const mockSetSelectedRange = vi.fn();
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
setSelectedRange: mockSetSelectedRange,
});
render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalled();
});
// create 호출 시 전달된 hook 확인
const createCall = mockLuckysheet.create.mock.calls[0];
const config = createCall[0];
// cellClick 핸들러 시뮬레이션
const cellClickHandler = config.hook.cellClick;
expect(cellClickHandler).toBeDefined();
const mockCell = {};
const mockPosition = { r: 1, c: 2 };
const mockSheetFile = { index: "0" };
cellClickHandler(mockCell, mockPosition, mockSheetFile);
expect(mockSetSelectedRange).toHaveBeenCalledWith({
range: {
startRow: 1,
startCol: 2,
endRow: 1,
endCol: 2,
},
sheetId: "0",
});
});
it("시트 활성화 시 활성 시트 ID를 업데이트한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
// setActiveSheetId를 spy로 설정
const setActiveSheetIdSpy = vi.fn();
useAppStore.getState = vi.fn().mockReturnValue({
setActiveSheetId: setActiveSheetIdSpy,
});
render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalled();
});
// create 호출 시 전달된 hook 확인
const createCall = mockLuckysheet.create.mock.calls[0];
const config = createCall[0];
// sheetActivate 핸들러 시뮬레이션
const sheetActivateHandler = config.hook.sheetActivate;
expect(sheetActivateHandler).toBeDefined();
sheetActivateHandler(0, false, false);
expect(setActiveSheetIdSpy).toHaveBeenCalledWith("sheet_0");
});
});
describe("컴포넌트 생명주기", () => {
it("컴포넌트 언마운트 시 Luckysheet를 정리한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
const { unmount } = render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalled();
});
unmount();
expect(mockLuckysheet.destroy).toHaveBeenCalled();
});
it("윈도우 리사이즈 시 Luckysheet 리사이즈를 호출한다", async () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
render(<SheetViewer />);
await waitFor(() => {
expect(mockLuckysheet.create).toHaveBeenCalled();
});
// 윈도우 리사이즈 이벤트 시뮬레이션
window.dispatchEvent(new Event("resize"));
expect(mockLuckysheet.resize).toHaveBeenCalled();
});
});
describe("개발 모드 정보", () => {
it("개발 모드에서 시트 정보를 표시한다", () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "development";
mockUseAppStore.mockReturnValue({
...defaultStoreState,
sheets: mockSheetData,
activeSheetId: "sheet_0",
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
});
render(<SheetViewer />);
expect(screen.getByText("시트 개수: 1")).toBeInTheDocument();
expect(screen.getByText("활성 시트: sheet_0")).toBeInTheDocument();
process.env.NODE_ENV = originalEnv;
});
});
});

View File

@@ -1,3 +1,12 @@
/* Univer CE 공식 스타일 - @import는 맨 위에 */
@import '@univerjs/design/lib/index.css';
@import '@univerjs/ui/lib/index.css';
@import '@univerjs/docs-ui/lib/index.css';
@import '@univerjs/sheets-ui/lib/index.css';
@import '@univerjs/sheets-formula-ui/lib/index.css';
@import '@univerjs/sheets-numfmt-ui/lib/index.css';
/* Tailwind CSS */
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -105,25 +114,29 @@
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
}
/* 커스텀 스타일 */
body {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #1f2937; /* 검은색 계열로 변경 */
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
/* 전역 스타일 */
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 스크롤바 스타일 */
/* Univer 컨테이너 스타일 */
.univer-container {
width: 100%;
height: 100%;
position: relative;
}
/* 커스텀 스크롤바 (Univer와 일치) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@@ -132,20 +145,132 @@ body {
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
background: #a8a8a8;
}
/* 파일 업로드 영역 스타일 */
.file-upload-area {
border: 2px dashed #e2e8f0;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s ease;
}
.file-upload-area:hover {
border-color: #3b82f6;
background-color: #f8fafc;
}
.file-upload-area.dragover {
border-color: #3b82f6;
background-color: #dbeafe;
}
/* 로딩 애니메이션 */
.loading-spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #3b82f6;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 상태 표시 점 애니메이션 */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-spin {
animation: spin 1s linear infinite;
/* 에러 메시지 스타일 */
.error-message {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
}
/* 성공 메시지 스타일 */
.success-message {
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
}
/* 정보 메시지 스타일 */
.info-message {
background-color: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.file-upload-area {
padding: 15px;
}
.univer-container {
min-height: 400px;
}
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
.file-upload-area {
border-color: #374151;
background-color: #1f2937;
}
.file-upload-area:hover {
border-color: #60a5fa;
background-color: #111827;
}
.file-upload-area.dragover {
border-color: #60a5fa;
background-color: #1e3a8a;
}
}

View File

@@ -1,10 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -18,6 +18,7 @@ interface AppState {
name: string;
size: number;
uploadedAt: Date;
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
} | null;
sheets: SheetData[];
activeSheetId: string | null;
@@ -41,7 +42,12 @@ interface AppState {
setAuthenticated: (authenticated: boolean) => void;
setCurrentFile: (
file: { name: string; size: number; uploadedAt: Date } | null,
file: {
name: string;
size: number;
uploadedAt: Date;
xlsxBuffer?: ArrayBuffer;
} | null,
) => void;
setSheets: (sheets: SheetData[]) => void;
setActiveSheetId: (sheetId: string | null) => void;
@@ -126,6 +132,7 @@ export const useAppStore = create<AppState>()(
name: result.fileName || "Unknown",
size: result.fileSize || 0,
uploadedAt: new Date(),
xlsxBuffer: result.xlsxBuffer,
},
sheets: result.data,
activeSheetId: result.data[0]?.id || null,

View File

@@ -5,6 +5,7 @@ export interface SheetData {
name: string;
data: any[][]; // Luckysheet 데이터 형식
config?: LuckysheetConfig;
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
}
export interface LuckysheetConfig {
@@ -30,6 +31,8 @@ export interface FileUploadResult {
error?: string;
fileName?: string;
fileSize?: number;
file?: File;
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
}
export interface ExportOptions {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as XLSX from "xlsx";
import * as XLSX from "xlsx-js-style";
import {
validateFileType,
validateFileSize,
@@ -11,8 +11,8 @@ import {
SUPPORTED_EXTENSIONS,
} from "../fileProcessor";
// SheetJS 모킹 (통합 처리)
vi.mock("xlsx", () => ({
// xlsx-js-style 모킹 (통합 처리)
vi.mock("xlsx-js-style", () => ({
read: vi.fn(() => ({
SheetNames: ["Sheet1"],
Sheets: {
@@ -30,61 +30,80 @@ vi.mock("xlsx", () => ({
["테스트", "한글", "데이터"],
["값1", "값2", "값3"],
]),
decode_range: vi.fn((_ref) => ({
s: { r: 0, c: 0 },
e: { r: 1, c: 2 },
})),
encode_cell: vi.fn(
(cell) => `${String.fromCharCode(65 + cell.c)}${cell.r + 1}`,
),
aoa_to_sheet: vi.fn(() => ({
A1: { v: "테스트" },
B1: { v: "한글" },
C1: { v: "데이터" },
"!ref": "A1:C1",
})),
book_new: vi.fn(() => ({ SheetNames: [], Sheets: {} })),
book_append_sheet: vi.fn(),
},
}));
// LuckyExcel 모킹
vi.mock("luckyexcel", () => ({
transformExcelToLucky: vi.fn((arrayBuffer, fileName, callback) => {
// 성공적인 변환 결과 모킹
const mockResult = {
sheets: [
{
name: "Sheet1",
index: "0",
status: 1,
order: 0,
row: 2,
column: 3,
celldata: [
{
r: 0,
c: 0,
v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 1,
v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 2,
v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 0,
v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 1,
v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 2,
v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } },
},
],
},
],
};
transformExcelToLucky: vi.fn(
(_arrayBuffer, successCallback, _errorCallback) => {
// 성공적인 변환 결과 모킹
const mockResult = {
sheets: [
{
name: "Sheet1",
index: "0",
status: 1,
order: 0,
row: 2,
column: 3,
celldata: [
{
r: 0,
c: 0,
v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 1,
v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 2,
v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 0,
v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 1,
v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 2,
v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } },
},
],
},
],
};
// 비동기 콜백 호출
setTimeout(() => callback(mockResult, null), 0);
}),
// 성공 콜백 비동기 호출 (ArrayBuffer 매개변수 대응)
if (typeof successCallback === "function") {
setTimeout(() => successCallback(mockResult, null), 0);
}
},
),
}));
// 파일 생성 도우미 함수

View File

@@ -1,945 +0,0 @@
import * as XLSX from "xlsx";
import * as LuckyExcel from "luckyexcel";
import type { SheetData, FileUploadResult } from "../types/sheet";
/**
* 파일 처리 관련 유틸리티 - 개선된 버전
* - 모든 파일 형식을 SheetJS를 통해 읽은 후 XLSX로 변환
* - 변환된 XLSX 파일을 LuckyExcel로 전달
* - 안정적인 한글 지원 및 에러 처리
*/
// 지원되는 파일 타입
export const SUPPORTED_FILE_TYPES = {
XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
XLS: "application/vnd.ms-excel",
CSV: "text/csv",
} as const;
export const SUPPORTED_EXTENSIONS = [".xlsx", ".xls", ".csv"] as const;
// 최대 파일 크기 (50MB)
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
/**
* 파일 타입 검증
*/
export function validateFileType(file: File): boolean {
const fileName = file.name.toLowerCase();
const extension = fileName.split(".").pop();
const supportedExtensions = SUPPORTED_EXTENSIONS.map((ext) => ext.slice(1));
if (!extension) {
return false;
}
return supportedExtensions.includes(extension);
}
/**
* 파일 크기 검증
*/
export function validateFileSize(file: File): boolean {
return file.size <= MAX_FILE_SIZE;
}
/**
* 파일 이름에서 확장자 제거
*/
export function getFileNameWithoutExtension(fileName: string): string {
return fileName.replace(/\.[^/.]+$/, "");
}
/**
* 에러 메시지 생성
*/
export function getFileErrorMessage(file: File): string {
if (!validateFileType(file)) {
const fileName = file.name.toLowerCase();
const extension = fileName.split(".").pop();
if (!extension || extension === fileName) {
return `파일 확장자가 없습니다. ${SUPPORTED_EXTENSIONS.join(", ")} 파일만 업로드 가능합니다.`;
}
return `지원되지 않는 파일 형식입니다. "${extension}" 대신 ${SUPPORTED_EXTENSIONS.join(", ")} 파일을 업로드해주세요.`;
}
if (!validateFileSize(file)) {
const maxSizeMB = Math.round(MAX_FILE_SIZE / (1024 * 1024));
const currentSizeMB = (file.size / (1024 * 1024)).toFixed(2);
return `파일 크기가 너무 큽니다. 현재 크기: ${currentSizeMB}MB, 최대 허용: ${maxSizeMB}MB`;
}
if (file.name.length > 255) {
return "파일명이 너무 깁니다. 255자 이하의 파일명을 사용해주세요.";
}
const invalidChars = /[<>:"/\\|?*]/;
if (invalidChars.test(file.name)) {
return '파일명에 사용할 수 없는 특수문자가 포함되어 있습니다. (< > : " / \\ | ? *)';
}
return "";
}
/**
* 한글 시트명을 안전하게 처리하는 함수
*/
function sanitizeSheetName(sheetName: string): string {
if (!sheetName || typeof sheetName !== "string") {
return "Sheet1";
}
const maxLength = 31;
let sanitized = sheetName.trim();
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength - 3) + "...";
}
sanitized = sanitized.replace(/[\\\/\*\?\[\]]/g, "_");
return sanitized || "Sheet1";
}
/**
* 워크북 구조 검증 함수
*/
function validateWorkbook(workbook: any): { isValid: boolean; error?: string } {
if (!workbook) {
return { isValid: false, error: "워크북이 null 또는 undefined입니다" };
}
if (!workbook.SheetNames) {
return { isValid: false, error: "워크북에 SheetNames 속성이 없습니다" };
}
if (!Array.isArray(workbook.SheetNames)) {
return { isValid: false, error: "SheetNames가 배열이 아닙니다" };
}
if (workbook.SheetNames.length === 0) {
return { isValid: false, error: "워크북에 시트가 없습니다" };
}
return { isValid: true };
}
/**
* SheetJS 데이터를 LuckyExcel 형식으로 변환
*/
function convertSheetJSToLuckyExcel(workbook: any): SheetData[] {
console.log("🔄 SheetJS → LuckyExcel 형식 변환 시작...");
const luckySheets: SheetData[] = [];
// 워크북 구조 검증
const validation = validateWorkbook(workbook);
if (!validation.isValid) {
console.error("❌ 워크북 검증 실패:", validation.error);
throw new Error(`워크북 구조 오류: ${validation.error}`);
}
console.log(`📋 발견된 시트: ${workbook.SheetNames.join(", ")}`);
workbook.SheetNames.forEach((sheetName: string, index: number) => {
console.log(
`📋 시트 ${index + 1}/${workbook.SheetNames.length} "${sheetName}" 변환 중...`,
);
const safeSheetName = sanitizeSheetName(sheetName);
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
console.warn(
`⚠️ 시트 "${sheetName}"를 찾을 수 없습니다. 빈 시트로 생성합니다.`,
);
luckySheets.push({
id: `sheet_${index}`,
name: safeSheetName,
data: [[""]],
config: {
container: `luckysheet_${index}`,
title: safeSheetName,
lang: "ko",
data: [
{
name: safeSheetName,
index: index.toString(),
celldata: [],
status: 1,
order: index,
row: 100,
column: 26,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
});
return;
}
try {
// 시트 범위 확인
const range = worksheet["!ref"];
if (!range) {
console.warn(
`⚠️ 시트 "${sheetName}"에 데이터 범위가 없습니다. 빈 시트로 처리합니다.`,
);
luckySheets.push({
id: `sheet_${index}`,
name: safeSheetName,
data: [[""]],
config: {
container: `luckysheet_${index}`,
title: safeSheetName,
lang: "ko",
data: [
{
name: safeSheetName,
index: index.toString(),
celldata: [],
status: 1,
order: index,
row: 100,
column: 26,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
});
return;
}
// 범위 파싱
const rangeObj = XLSX.utils.decode_range(range);
const maxRow = rangeObj.e.r + 1;
const maxCol = rangeObj.e.c + 1;
console.log(`📐 시트 "${sheetName}" 크기: ${maxRow}행 x ${maxCol}`);
// 2D 배열로 데이터 변환
const data: any[][] = [];
const cellData: any[] = [];
// 데이터 배열 초기화
for (let row = 0; row < maxRow; row++) {
data[row] = new Array(maxCol).fill("");
}
// 셀 데이터 변환
for (let row = rangeObj.s.r; row <= rangeObj.e.r; row++) {
for (let col = rangeObj.s.c; col <= rangeObj.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
if (
cell &&
cell.v !== undefined &&
cell.v !== null &&
cell.v !== ""
) {
let cellValue = cell.v;
if (typeof cellValue === "string") {
cellValue = cellValue.trim();
if (cellValue.length > 1000) {
cellValue = cellValue.substring(0, 997) + "...";
}
}
// 2D 배열에 데이터 저장
data[row][col] = cellValue;
// LuckyExcel celldata 형식으로 변환
const luckyCell: any = {
r: row,
c: col,
v: {
v: cellValue,
m: String(cellValue),
ct: { fa: "General", t: "g" },
},
};
// 셀 타입에 따른 추가 처리
if (cell.t === "s") {
luckyCell.v.ct.t = "s";
} else if (cell.t === "n") {
luckyCell.v.ct.t = "n";
} else if (cell.t === "d") {
luckyCell.v.ct.t = "d";
} else if (cell.t === "b") {
luckyCell.v.ct.t = "b";
}
if (cell.f) {
luckyCell.v.f = cell.f;
}
cellData.push(luckyCell);
}
}
}
// SheetData 객체 생성
const sheetData: SheetData = {
id: `sheet_${index}`,
name: safeSheetName,
data: data,
config: {
container: `luckysheet_${index}`,
title: safeSheetName,
lang: "ko",
data: [
{
name: safeSheetName,
index: index.toString(),
celldata: cellData,
status: 1,
order: index,
row: maxRow,
column: maxCol,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
};
luckySheets.push(sheetData);
console.log(`✅ 시트 "${sheetName}" 변환 완료: ${cellData.length}개 셀`);
} catch (sheetError) {
console.error(`❌ 시트 "${sheetName}" 변환 중 오류:`, sheetError);
// 오류 발생 시 빈 시트로 생성
luckySheets.push({
id: `sheet_${index}`,
name: safeSheetName,
data: [[""]],
config: {
container: `luckysheet_${index}`,
title: safeSheetName,
lang: "ko",
data: [
{
name: safeSheetName,
index: index.toString(),
celldata: [],
status: 1,
order: index,
row: 100,
column: 26,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
});
}
});
// 최소 1개 시트는 보장
if (luckySheets.length === 0) {
console.log("📄 시트가 없어서 기본 시트를 생성합니다.");
luckySheets.push({
id: "sheet_0",
name: "Sheet1",
data: [[""]],
config: {
container: "luckysheet_0",
title: "Sheet1",
lang: "ko",
data: [
{
name: "Sheet1",
index: "0",
celldata: [],
status: 1,
order: 0,
row: 100,
column: 26,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
});
}
console.log(
`🎉 SheetJS → LuckyExcel 변환 완료: ${luckySheets.length}개 시트`,
);
return luckySheets;
}
/**
* SheetJS로 파일을 읽고 XLSX로 변환한 뒤 LuckyExcel로 처리
*/
async function processFileWithSheetJSToXLSX(file: File): Promise<SheetData[]> {
console.log("📊 SheetJS → XLSX → LuckyExcel 파이프라인 시작...");
const arrayBuffer = await file.arrayBuffer();
const fileName = file.name.toLowerCase();
const isCSV = fileName.endsWith(".csv");
const isXLS = fileName.endsWith(".xls");
const isXLSX = fileName.endsWith(".xlsx");
// 1단계: SheetJS로 파일 읽기
let workbook: any;
try {
if (isCSV) {
// CSV 파일 처리 - UTF-8 디코딩 후 읽기
console.log("📄 CSV 파일을 SheetJS로 읽는 중...");
const text = new TextDecoder("utf-8").decode(arrayBuffer);
workbook = XLSX.read(text, {
type: "string",
codepage: 65001, // UTF-8
raw: false,
});
} else {
// XLS/XLSX 파일 처리 - 관대한 옵션으로 읽기
console.log(`📊 ${isXLS ? "XLS" : "XLSX"} 파일을 SheetJS로 읽는 중...`);
workbook = XLSX.read(arrayBuffer, {
type: "array",
cellText: true,
sheetStubs: true,
WTF: true,
bookSheets: false,
codepage: 65001,
raw: false,
});
// Sheets가 없고 SheetNames만 있는 경우 재시도
if (workbook.SheetNames?.length > 0 && !workbook.Sheets) {
console.log("⚠️ Sheets 속성이 없어서 재읽기 시도...");
workbook = XLSX.read(arrayBuffer, {
type: "array",
cellText: true,
sheetStubs: true,
WTF: true,
bookSheets: true, // 강제로 시트 읽기
codepage: 65001,
raw: false,
});
// 여전히 실패하면 수동으로 빈 시트 생성
if (!workbook.Sheets && workbook.SheetNames?.length > 0) {
console.log("⚠️ 수동으로 빈 시트 생성...");
workbook.Sheets = {};
workbook.SheetNames.forEach((sheetName: string) => {
workbook.Sheets[sheetName] = {
"!ref": "A1:A1",
A1: { v: "", t: "s" },
};
});
}
}
}
} catch (readError) {
console.error("❌ SheetJS 파일 읽기 실패:", readError);
throw new Error(
`파일을 읽을 수 없습니다: ${readError instanceof Error ? readError.message : readError}`,
);
}
// 파일 버퍼 크기 검증
if (arrayBuffer.byteLength === 0) {
throw new Error("파일이 비어있습니다.");
}
// 워크북 null 체크
if (!workbook) {
throw new Error("워크북을 생성할 수 없습니다.");
}
// workbook.Sheets 존재 및 타입 검증
if (!workbook.Sheets || typeof workbook.Sheets !== "object") {
throw new Error("유효한 시트가 없습니다.");
}
// workbook.SheetNames 배열 검증
if (!Array.isArray(workbook.SheetNames) || workbook.SheetNames.length === 0) {
throw new Error("시트 이름 정보가 없습니다.");
}
console.log("✅ SheetJS 워크북 읽기 성공:", {
sheetNames: workbook.SheetNames,
sheetCount: workbook.SheetNames.length,
});
// 2단계: 워크북을 XLSX ArrayBuffer로 변환
let xlsxArrayBuffer: ArrayBuffer;
try {
console.log("🔄 XLSX 형식으로 변환 중...");
const xlsxData = XLSX.write(workbook, {
type: "array",
bookType: "xlsx",
compression: true,
});
// xlsxData는 Uint8Array이므로 ArrayBuffer로 변환
if (xlsxData instanceof Uint8Array) {
xlsxArrayBuffer = xlsxData.buffer.slice(
xlsxData.byteOffset,
xlsxData.byteOffset + xlsxData.byteLength,
);
} else if (xlsxData instanceof ArrayBuffer) {
xlsxArrayBuffer = xlsxData;
} else {
// 다른 타입의 경우 새 ArrayBuffer 생성
xlsxArrayBuffer = new ArrayBuffer(xlsxData.length);
const view = new Uint8Array(xlsxArrayBuffer);
for (let i = 0; i < xlsxData.length; i++) {
view[i] = xlsxData[i];
}
}
console.log(`✅ XLSX 변환 완료: ${xlsxArrayBuffer.byteLength} bytes`);
// ⏱️ ArrayBuffer 변환 완료 확인 및 검증
console.log("⏱️ ArrayBuffer 변환 검증 중...");
// ArrayBuffer 무결성 검증
if (!xlsxArrayBuffer || xlsxArrayBuffer.byteLength === 0) {
throw new Error("ArrayBuffer 변환 실패: 빈 버퍼");
}
// XLSX 파일 시그니처 사전 검증
const uint8Check = new Uint8Array(xlsxArrayBuffer);
const signatureCheck = Array.from(uint8Check.slice(0, 4))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
if (signatureCheck !== "50 4b 03 04") {
console.warn(
`⚠️ 잘못된 XLSX 시그니처: ${signatureCheck} (예상: 50 4b 03 04)`,
);
}
console.log(
`✅ ArrayBuffer 검증 완료: ${xlsxArrayBuffer.byteLength} bytes, 시그니처: ${signatureCheck}`,
);
} catch (writeError) {
console.error("❌ XLSX 변환 실패:", writeError);
throw new Error(
`XLSX 변환 실패: ${writeError instanceof Error ? writeError.message : writeError}`,
);
}
// 3단계: ArrayBuffer가 완전히 준비된 후 LuckyExcel로 처리
console.log("🍀 LuckyExcel로 변환된 XLSX 처리 중...");
// ArrayBuffer 최종 검증
if (!xlsxArrayBuffer) {
throw new Error("ArrayBuffer가 생성되지 않았습니다");
}
if (xlsxArrayBuffer.byteLength === 0) {
throw new Error("ArrayBuffer 크기가 0입니다");
}
// 원본 파일명에서 확장자를 .xlsx로 변경
const xlsxFileName = file.name.replace(/\.(csv|xls|xlsx)$/i, ".xlsx");
// 🔍 LuckyExcel로 전달되는 파일 정보 출력
console.log("📋 =================================");
console.log("📋 LuckyExcel로 전달되는 파일 정보:");
console.log("📋 =================================");
console.log("📋 타이밍:", new Date().toISOString());
console.log("📋 원본 파일명:", file.name);
console.log("📋 변환된 파일명:", xlsxFileName);
console.log("📋 ArrayBuffer 크기:", xlsxArrayBuffer.byteLength, "bytes");
console.log("📋 ArrayBuffer 타입:", xlsxArrayBuffer.constructor.name);
// ArrayBuffer의 처음 100바이트를 16진수로 출력 (헥스 덤프)
const uint8View = new Uint8Array(xlsxArrayBuffer);
const firstBytes = Array.from(
uint8View.slice(0, Math.min(100, uint8View.length)),
)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
console.log("📋 ArrayBuffer 처음 100바이트 (hex):", firstBytes);
// XLSX 파일 시그니처 확인 (PK\x03\x04 또는 50 4B 03 04)
const signature = Array.from(uint8View.slice(0, 4))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
console.log(
"📋 파일 시그니처:",
signature,
signature === "50 4b 03 04" ? "(✅ 유효한 XLSX)" : "(❌ 잘못된 시그니처)",
);
console.log("📋 =================================");
// 🚀 LuckyExcel 호출 직전 최종 검증
console.log("🚀 LuckyExcel 호출 직전 최종 검증:");
console.log("🚀 ArrayBuffer 타입:", typeof xlsxArrayBuffer);
console.log("🚀 ArrayBuffer 생성자 확인:", xlsxArrayBuffer.constructor.name);
console.log("🚀 ArrayBuffer 크기:", xlsxArrayBuffer.byteLength);
console.log("🚀 ArrayBuffer.isView:", ArrayBuffer.isView(xlsxArrayBuffer));
console.log("🚀 fileName:", xlsxFileName, "타입:", typeof xlsxFileName);
console.log("🚀 LuckyExcel 객체:", typeof LuckyExcel);
console.log(
"🚀 transformExcelToLucky 함수:",
typeof (LuckyExcel as any).transformExcelToLucky,
);
console.log("🚀 LuckyExcel 호출 시작...");
// Promise를 사용한 LuckyExcel 처리
return new Promise<SheetData[]>((resolve, reject) => {
try {
// LuckyExcel API는 (arrayBuffer, successCallback, errorCallback) 형태로 호출
// 공식 문서: LuckyExcel.transformExcelToLucky(file, successCallback, errorCallback)
(LuckyExcel as any).transformExcelToLucky(
xlsxArrayBuffer,
// 성공 콜백 함수 (두 번째 매개변수)
(exportJson: any, luckysheetfile: any) => {
try {
console.log("🍀 =================================");
console.log("🍀 LuckyExcel 변환 결과 상세 정보:");
console.log("🍀 =================================");
console.log("🍀 원본 파일명:", xlsxFileName);
console.log("🍀 exportJson 존재:", !!exportJson);
console.log("🍀 exportJson 타입:", typeof exportJson);
if (exportJson) {
console.log("🍀 exportJson 전체 구조:", exportJson);
console.log("🍀 exportJson.sheets 존재:", !!exportJson.sheets);
console.log(
"🍀 exportJson.sheets 타입:",
typeof exportJson.sheets,
);
console.log(
"🍀 exportJson.sheets 배열 여부:",
Array.isArray(exportJson.sheets),
);
console.log("🍀 시트 개수:", exportJson?.sheets?.length || 0);
if (exportJson.sheets && Array.isArray(exportJson.sheets)) {
exportJson.sheets.forEach((sheet: any, index: number) => {
console.log(`🍀 시트 ${index + 1}:`, {
name: sheet.name,
row: sheet.row,
column: sheet.column,
celldata길이: sheet.celldata?.length || 0,
키목록: Object.keys(sheet),
});
});
}
}
console.log("🍀 luckysheetfile 존재:", !!luckysheetfile);
console.log("🍀 luckysheetfile 타입:", typeof luckysheetfile);
if (luckysheetfile) {
console.log("🍀 luckysheetfile 구조:", luckysheetfile);
}
console.log("🍀 =================================");
console.log("🔍 LuckyExcel 변환 결과:", {
hasExportJson: !!exportJson,
hasSheets: !!exportJson?.sheets,
sheetsCount: exportJson?.sheets?.length || 0,
});
// 데이터 유효성 검사
if (
!exportJson ||
!exportJson.sheets ||
!Array.isArray(exportJson.sheets) ||
exportJson.sheets.length === 0
) {
console.warn(
"⚠️ LuckyExcel 결과가 유효하지 않습니다. SheetJS 방식으로 대체 처리합니다.",
);
// LuckyExcel 실패 시 SheetJS 데이터를 직접 변환
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
resolve(fallbackSheets);
return;
}
// LuckyExcel 변환이 성공한 경우 - SheetData 형식으로 변환
const sheets: SheetData[] = exportJson.sheets.map(
(luckySheet: any, index: number) => {
const sheetName = luckySheet.name || `Sheet${index + 1}`;
const maxRow = luckySheet.row || 0;
const maxCol = luckySheet.column || 0;
// 2D 배열 초기화
const data: any[][] = [];
for (let r = 0; r < maxRow; r++) {
data[r] = new Array(maxCol).fill("");
}
// celldata에서 데이터 추출
if (luckySheet.celldata && Array.isArray(luckySheet.celldata)) {
luckySheet.celldata.forEach((cell: any) => {
if (
cell &&
typeof cell.r === "number" &&
typeof cell.c === "number"
) {
const row = cell.r;
const col = cell.c;
if (row < maxRow && col < maxCol && cell.v) {
const cellValue = cell.v.v || cell.v.m || "";
data[row][col] = String(cellValue).trim();
}
}
});
}
// 빈 데이터 처리
if (data.length === 0) {
data.push([""]);
}
return {
id: `sheet_${index}`,
name: sheetName,
data: data,
config: {
container: `luckysheet_${index}`,
title: sheetName,
lang: "ko",
data: [
{
name: sheetName,
index: index.toString(),
celldata: luckySheet.celldata || [],
status: 1,
order: index,
row: maxRow,
column: maxCol,
},
],
options: {
showtoolbar: true,
showinfobar: false,
showsheetbar: true,
showstatisticBar: false,
allowCopy: true,
allowEdit: true,
enableAddRow: true,
enableAddCol: true,
},
},
};
},
);
console.log("✅ LuckyExcel 처리 성공:", sheets.length, "개 시트");
resolve(sheets);
} catch (processError) {
console.error("❌ LuckyExcel 후처리 중 오류:", processError);
// LuckyExcel 후처리 실패 시 SheetJS 방식으로 대체
try {
console.log("🔄 SheetJS 방식으로 대체 처리...");
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
resolve(fallbackSheets);
} catch (fallbackError) {
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
reject(fallbackError);
}
}
},
// 오류 콜백 함수 (세 번째 매개변수)
(error: any) => {
console.error("❌ LuckyExcel 변환 오류:", error);
// LuckyExcel 오류 시 SheetJS 방식으로 대체
try {
console.log("🔄 SheetJS 방식으로 대체 처리...");
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
resolve(fallbackSheets);
} catch (fallbackError) {
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
reject(fallbackError);
}
},
);
} catch (luckyError) {
console.error("❌ LuckyExcel 호출 중 오류:", luckyError);
// LuckyExcel 호출 실패 시 SheetJS 방식으로 대체
try {
console.log("🔄 SheetJS 방식으로 대체 처리...");
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
resolve(fallbackSheets);
} catch (fallbackError) {
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
reject(fallbackError);
}
}
});
}
/**
* 엑셀 파일을 SheetData 배열로 변환 (개선된 버전)
* - 모든 파일을 SheetJS로 읽은 후 XLSX로 변환
* - 변환된 XLSX를 LuckyExcel로 처리
* - 실패 시 SheetJS 직접 변환으로 Fallback
*/
export async function processExcelFile(file: File): Promise<FileUploadResult> {
try {
const errorMessage = getFileErrorMessage(file);
if (errorMessage) {
return {
success: false,
error: errorMessage,
fileName: file.name,
fileSize: file.size,
};
}
// 파일 형식 감지
const fileName = file.name.toLowerCase();
const isCSV = fileName.endsWith(".csv");
const isXLS = fileName.endsWith(".xls");
const isXLSX = fileName.endsWith(".xlsx");
if (!isCSV && !isXLS && !isXLSX) {
return {
success: false,
error:
"지원되지 않는 파일 형식입니다. .csv, .xls, .xlsx 파일을 사용해주세요.",
fileName: file.name,
fileSize: file.size,
};
}
console.log(
`📁 파일 처리 시작: ${file.name} (${isCSV ? "CSV" : isXLS ? "XLS" : "XLSX"})`,
);
// 통합된 처리 방식: SheetJS → XLSX → LuckyExcel
const sheets = await processFileWithSheetJSToXLSX(file);
if (!sheets || sheets.length === 0) {
return {
success: false,
error: "파일에서 유효한 시트를 찾을 수 없습니다.",
fileName: file.name,
fileSize: file.size,
};
}
console.log(`🎉 파일 처리 완료: ${sheets.length}개 시트`);
return {
success: true,
data: sheets,
fileName: file.name,
fileSize: file.size,
};
} catch (error) {
console.error("❌ 파일 처리 중 오류 발생:", error);
let errorMessage = "파일을 읽는 중 오류가 발생했습니다.";
if (error instanceof Error) {
if (
error.message.includes("파일에 워크시트가 없습니다") ||
error.message.includes("워크북 구조 오류") ||
error.message.includes("파일 처리 실패") ||
error.message.includes("파일 읽기 실패") ||
error.message.includes("XLSX 변환 실패") ||
error.message.includes("파일이 비어있습니다") ||
error.message.includes("워크북을 생성할 수 없습니다") ||
error.message.includes("유효한 시트가 없습니다") ||
error.message.includes("시트 이름 정보가 없습니다") ||
error.message.includes("파일을 읽을 수 없습니다")
) {
errorMessage = error.message;
} else if (error.message.includes("transformExcelToLucky")) {
errorMessage =
"Excel 파일 변환에 실패했습니다. 파일이 손상되었거나 지원되지 않는 형식일 수 있습니다.";
} else {
errorMessage = `파일 처리 중 오류: ${error.message}`;
}
}
return {
success: false,
error: errorMessage,
fileName: file.name,
fileSize: file.size,
};
}
}
/**
* 여러 파일 중 유효한 파일만 필터링
*/
export function filterValidFiles(files: FileList): File[] {
return Array.from(files).filter((file) => {
const errorMessage = getFileErrorMessage(file);
return errorMessage === "";
});
}
/**
* 파일 목록의 에러 정보 수집
*/
export function getFileErrors(
files: FileList,
): { file: File; error: string }[] {
const errors: { file: File; error: string }[] = [];
Array.from(files).forEach((file) => {
const errorMessage = getFileErrorMessage(file);
if (errorMessage !== "") {
errors.push({ file, error: errorMessage });
}
});
return errors;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@

417
src/utils/styleTest.ts.bak Normal file
View File

@@ -0,0 +1,417 @@
/**
* xlsx-js-style 스타일 보존 테스트 유틸리티
* - 다양한 스타일이 적용된 Excel 파일 생성
* - 스타일 정보 확인 도구
*/
import * as XLSX from "xlsx-js-style";
/**
* 스타일이 적용된 테스트 Excel 파일 생성
*/
export function createStyledTestExcel(): ArrayBuffer {
// 새 워크북 생성
const wb = XLSX.utils.book_new();
// 테스트 데이터 생성 - xlsx-js-style 공식 API 완전 활용
const testData = [
// 첫 번째 행 - 폰트 스타일 테스트
[
{
v: "굵은 글씨",
t: "s",
s: {
font: {
name: "Courier", // 공식 문서 예시
sz: 24, // 공식 문서 예시
bold: true,
color: { rgb: "000000" },
},
},
},
{
v: "빨간 글씨",
t: "s",
s: {
font: {
bold: true,
color: { rgb: "FF0000" }, // 공식 문서: {color: {rgb: "FF0000"}}
sz: 12,
},
},
},
{
v: "테마 색상",
t: "s",
s: {
font: {
color: { theme: 4 }, // 공식 문서: {theme: 4} (Blue, Accent 1)
sz: 14,
italic: true,
},
},
},
],
// 두 번째 행 - 배경색 테스트
[
{
v: "노란 배경",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { rgb: "FFFF00" }, // 공식 문서: {fgColor: {rgb: "E9E9E9"}}
},
},
},
{
v: "테마 배경",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { theme: 1, tint: 0.4 }, // 공식 문서: {theme: 1, tint: 0.4} ("Blue, Accent 1, Lighter 40%")
},
font: { color: { rgb: "000000" } },
},
},
{
v: "인덱스 색상",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { indexed: 5 }, // Excel 기본 색상표 - 노랑
},
},
},
],
// 세 번째 행 - 테두리 테스트
[
{
v: "얇은 테두리",
t: "s",
s: {
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
},
},
{
v: "두꺼운 테두리",
t: "s",
s: {
border: {
top: { style: "thick", color: { theme: 2 } }, // 테마 색상 사용
bottom: { style: "thick", color: { theme: 2 } },
left: { style: "thick", color: { theme: 2 } },
right: { style: "thick", color: { theme: 2 } },
},
},
},
{
v: "다양한 테두리",
t: "s",
s: {
border: {
top: { style: "dotted", color: { indexed: 4 } }, // 인덱스 색상 - 파랑
bottom: { style: "dashed", color: { indexed: 4 } },
left: { style: "dashDot", color: { indexed: 4 } },
right: { style: "double", color: { indexed: 4 } },
},
},
},
],
// 네 번째 행 - 복합 스타일 테스트
[
{
v: "복합 스타일",
t: "s",
s: {
font: {
bold: true,
italic: true,
underline: true,
sz: 16,
color: { rgb: "FFFFFF" },
name: "Courier", // 공식 문서 예시
},
fill: {
patternType: "solid",
fgColor: { theme: 7, tint: -0.2 }, // 어두운 보라색
},
border: {
top: { style: "medium", color: { rgb: "FFD700" } },
bottom: { style: "medium", color: { rgb: "FFD700" } },
left: { style: "medium", color: { rgb: "FFD700" } },
right: { style: "medium", color: { rgb: "FFD700" } },
},
alignment: {
horizontal: "center",
vertical: "middle",
wrapText: true, // 공식 문서: {wrapText: true}
},
},
},
{
v: 1234.567,
t: "n",
s: {
numFmt: "0.00%", // 공식 문서: numFmt 예시
alignment: { horizontal: "right" },
font: { bold: true },
},
},
{
v: "줄바꿈\n테스트",
t: "s",
s: {
alignment: {
wrapText: true,
vertical: "top",
textRotation: 0, // 공식 문서: textRotation
},
font: { sz: 10 },
},
},
],
// 다섯 번째 행 - 고급 스타일 테스트
[
{
v: "취소선 텍스트",
t: "s",
s: {
font: {
strike: true, // 공식 문서: {strike: true}
sz: 12,
color: { theme: 5, tint: 0.6 }, // 밝은 빨강
},
},
},
{
v: "회전 텍스트",
t: "s",
s: {
alignment: {
textRotation: 45, // 공식 문서: textRotation
horizontal: "center",
vertical: "middle",
},
font: { sz: 14, bold: true },
},
},
{
v: new Date(),
t: "d",
s: {
numFmt: "m/dd/yy", // 공식 문서: 날짜 포맷 예시
font: { name: "Arial", sz: 10 },
alignment: { horizontal: "center" },
},
},
],
];
// 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(testData);
// 병합 셀 추가
if (!ws["!merges"]) ws["!merges"] = [];
ws["!merges"].push(
{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, // A1:B1 병합
{ s: { r: 2, c: 2 }, e: { r: 3, c: 2 } }, // C3:C4 병합
);
// 열 너비 설정
ws["!cols"] = [
{ wpx: 120 }, // A열 너비
{ wpx: 100 }, // B열 너비
{ wpx: 80 }, // C열 너비
];
// 행 높이 설정
ws["!rows"] = [
{ hpx: 30 }, // 1행 높이
{ hpx: 25 }, // 2행 높이
{ hpx: 40 }, // 3행 높이
];
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, "스타일테스트");
// 추가 시트 생성 (간단한 데이터)
const simpleData = [
["이름", "나이", "직업"],
["홍길동", 30, "개발자"],
["김철수", 25, "디자이너"],
["이영희", 35, "기획자"],
];
const ws2 = XLSX.utils.aoa_to_sheet(simpleData);
// 헤더 스타일 적용
["A1", "B1", "C1"].forEach((cellAddr) => {
if (ws2[cellAddr]) {
ws2[cellAddr].s = {
font: { bold: true, color: { rgb: "FFFFFF" } },
fill: { patternType: "solid", fgColor: { rgb: "5B9BD5" } },
alignment: { horizontal: "center" },
};
}
});
XLSX.utils.book_append_sheet(wb, ws2, "간단한데이터");
// Excel 파일로 변환
const excelBuffer = XLSX.write(wb, {
type: "array",
bookType: "xlsx",
cellStyles: true,
cellDates: true,
bookSST: true,
});
// ArrayBuffer로 변환
if (excelBuffer instanceof Uint8Array) {
return excelBuffer.buffer.slice(
excelBuffer.byteOffset,
excelBuffer.byteOffset + excelBuffer.byteLength,
);
}
return excelBuffer;
}
/**
* 셀 스타일 정보 분석
*/
export function analyzeSheetStyles(workbook: any): void {
console.log("🎨 =================================");
console.log("🎨 Excel 파일 스타일 정보 분석");
console.log("🎨 =================================");
// 🔍 워크북 전체 스타일 정보 확인
console.log("🔍 워크북 메타데이터:", {
Props: workbook.Props ? "있음" : "없음",
Custprops: workbook.Custprops ? "있음" : "없음",
Workbook: workbook.Workbook ? "있음" : "없음",
SSF: workbook.SSF ? "있음" : "없음",
SheetNames: workbook.SheetNames
? workbook.SheetNames.length + "개"
: "없음",
Sheets: workbook.Sheets
? Object.keys(workbook.Sheets).length + "개"
: "없음",
});
// 🔍 워크북 스타일 정보 상세 분석
if (workbook.SSF) {
console.log("🔍 워크북 SSF 스타일 정보:", workbook.SSF);
}
if (workbook.Workbook && workbook.Workbook.Styles) {
console.log("🔍 워크북 Styles:", workbook.Workbook.Styles);
}
// 워크북의 모든 키 확인
console.log("🔍 워크북 전체 키들:", Object.keys(workbook));
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
console.log("🎨 ❌ 시트가 없습니다.");
console.log("🎨 ❌ 워크북 전체 구조:", Object.keys(workbook));
return;
}
workbook.SheetNames.forEach((sheetName: string, sheetIndex: number) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
console.log(`🎨 시트 ${sheetIndex + 1}: "${sheetName}"`);
// 시트 메타데이터
console.log(`🎨 - 데이터 범위: ${sheet["!ref"] || "없음"}`);
console.log(`🎨 - 병합 셀: ${sheet["!merges"]?.length || 0}`);
console.log(`🎨 - 열 설정: ${sheet["!cols"]?.length || 0}`);
console.log(`🎨 - 행 설정: ${sheet["!rows"]?.length || 0}`);
// 병합 셀 상세 정보
if (sheet["!merges"]) {
sheet["!merges"].forEach((merge: any, index: number) => {
console.log(
`🎨 - 병합 ${index + 1}: ${XLSX.utils.encode_cell(merge.s)}:${XLSX.utils.encode_cell(merge.e)}`,
);
});
}
// 스타일이 적용된 셀 찾기
const styledCells: string[] = [];
const cellAddresses = Object.keys(sheet).filter(
(key) => !key.startsWith("!"),
);
// 🔍 시트 데이터 존재 여부 확인
console.log(`🔍 ${sheetName} 기본 정보: ${cellAddresses.length}개 셀 발견`);
cellAddresses.forEach((cellAddr) => {
const cell = sheet[cellAddr];
if (cell && cell.s) {
styledCells.push(cellAddr);
// 🔍 첫 3개 셀의 실제 스타일 구조 확인
if (styledCells.length <= 3) {
console.log(`🔍 셀 ${cellAddr} cell.s 원시값:`, cell.s);
console.log(`🔍 cell.s 타입:`, typeof cell.s);
console.log(`🔍 cell.s 키들:`, Object.keys(cell.s || {}));
}
// 스타일 정보 간단 확인
const hasStyles = {
font: !!cell.s.font,
fill: !!cell.s.fill,
border: !!cell.s.border,
alignment: !!cell.s.alignment,
};
if (Object.values(hasStyles).some((v) => v)) {
console.log(`🎨 셀 ${cellAddr} 스타일:`, hasStyles);
} else if (styledCells.length <= 3) {
console.log(`❌ 셀 ${cellAddr} 스타일 없음:`, hasStyles);
}
}
});
console.log(
`🎨 - 스타일 적용된 셀: ${styledCells.length}개 (${styledCells.join(", ")})`,
);
});
console.log("🎨 =================================");
}
/**
* 브라우저에서 테스트 파일 다운로드
*/
export function downloadTestFile(): void {
try {
const buffer = createStyledTestExcel();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "스타일테스트.xlsx";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("🎨 스타일 테스트 파일 다운로드 완료!");
} catch (error) {
console.error("🎨 테스트 파일 생성 실패:", error);
}
}

147
src/vite-env.d.ts vendored
View File

@@ -61,3 +61,150 @@ declare module "luckyexcel" {
LuckyExcelResult,
};
}
/**
* Luckysheet 타입 선언
*/
declare module "luckysheet" {
interface LuckysheetConfig {
container?: string;
title?: string;
lang?: string;
data?: any[];
myFolderUrl?: string;
plugins?: string[];
fontList?: Array<{
fontFamily: string;
name: string;
}>;
options?: {
showtoolbar?: boolean;
showinfobar?: boolean;
showsheetbar?: boolean;
showstatisticBar?: boolean;
allowCopy?: boolean;
allowEdit?: boolean;
enableAddRow?: boolean;
enableAddCol?: boolean;
sheetRightClickConfig?: {
delete?: boolean;
copy?: boolean;
rename?: boolean;
color?: boolean;
hide?: boolean;
move?: boolean;
};
cellRightClickConfig?: {
copy?: boolean;
copyAs?: boolean;
paste?: boolean;
insertRow?: boolean;
insertColumn?: boolean;
deleteRow?: boolean;
deleteColumn?: boolean;
deleteCell?: boolean;
hideRow?: boolean;
hideColumn?: boolean;
rowHeight?: boolean;
columnWidth?: boolean;
clear?: boolean;
matrix?: boolean;
sort?: boolean;
filter?: boolean;
chart?: boolean;
image?: boolean;
link?: boolean;
data?: boolean;
cellFormat?: boolean;
};
};
hook?: {
cellMousedown?: (cell: any, postion: any, sheetFile: any) => void;
cellClick?: (cell: any, postion: any, sheetFile: any) => void;
sheetActivate?: (
index: number,
isPivotInitial: boolean,
isInitialLoad: boolean,
) => void;
updated?: (operate: any) => void;
};
}
interface LuckysheetAPI {
create: (config: LuckysheetConfig) => void;
destroy: () => void;
refreshFormula: () => void;
setSheetData: (data: any[]) => void;
getAllSheets: () => any[];
getSheet: (index?: number) => any;
setActiveSheet: (index: number) => void;
getCellValue: (r: number, c: number, data?: any) => any;
setCellValue: (r: number, c: number, d: any, isRefresh?: boolean) => void;
getRange: () => any[];
setRange: (range: any[]) => void;
scroll: (settings: { scrollLeft?: number; scrollTop?: number }) => void;
resize: () => void;
undo: () => void;
redo: () => void;
copy: () => void;
paste: () => void;
cut: () => void;
insertRow: (index?: number) => void;
insertColumn: (index?: number) => void;
deleteRow: (start: number, end?: number) => void;
deleteColumn: (start: number, end?: number) => void;
hideRow: (rowIndexes: number[]) => void;
showRow: (rowIndexes: number[]) => void;
hideColumn: (columnIndexes: number[]) => void;
showColumn: (columnIndexes: number[]) => void;
setRowHeight: (rowInfo: { [key: number]: number }) => void;
setColumnWidth: (columnInfo: { [key: number]: number }) => void;
getRowHeight: (rowIndexes: number[]) => { [key: number]: number };
getColumnWidth: (columnIndexes: number[]) => { [key: number]: number };
setWorkbookName: (name: string) => void;
getWorkbookName: () => string;
exitEditMode: () => void;
enterEditMode: (cell?: any) => void;
updateCell: (r: number, c: number, value: any) => void;
refreshCanvas: () => void;
}
const luckysheet: LuckysheetAPI;
export = luckysheet;
}
/**
* Luckysheet 전역 변수 타입 선언
*/
declare global {
interface Window {
luckysheet: {
create: (options: {
container: string;
data: any[];
title?: string;
userInfo?: string;
[key: string]: any;
}) => void;
destroy: () => void;
resize?: () => void;
[key: string]: any;
};
LuckyExcel: {
transformExcelToLucky: (
file: File | ArrayBuffer,
successCallback: (exportJson: any, luckysheetfile: any) => void,
errorCallback: (error: any) => void,
) => void;
[key: string]: any;
};
$: any; // jQuery
Store?: any; // Luckysheet Store
luckysheet_function?: any; // Luckysheet function list
functionlist?: any[]; // 글로벌 functionlist
luckysheetConfigsetting?: any; // Luckysheet 설정 객체
luckysheetPostil?: any; // Luckysheet 포스틸 객체
}
}
export {};

View File

@@ -22,5 +22,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
}

View File

@@ -1,7 +1,16 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
},
"exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
}

View File

@@ -1,10 +1,81 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
// Node.js 호환성 문제 해결
define: {
global: "globalThis",
},
// Node.js 모듈 호환성 설정
resolve: {
alias: {
stream: "stream-browserify",
buffer: "buffer",
},
},
// 의존성 최적화 설정
optimizeDeps: {
exclude: [
// 중복 로딩 방지를 위해 redi와 univer 관련 제외
"@wendellhu/redi",
"@univerjs/core",
"@univerjs/design",
"@univerjs/ui",
"@univerjs/sheets",
"@univerjs/sheets-ui",
"@univerjs/docs",
"@univerjs/docs-ui",
"@univerjs/engine-render",
"@univerjs/engine-formula",
"@univerjs/sheets-formula",
"@univerjs/sheets-formula-ui",
"@univerjs/sheets-numfmt",
"@univerjs/sheets-numfmt-ui",
"@univerjs/facade",
],
},
// 빌드 설정
build: {
rollupOptions: {
external: [],
output: {
manualChunks: {
// Univer 관련 라이브러리를 별도 청크로 분리
"univer-core": [
"@univerjs/core",
"@univerjs/design",
"@univerjs/engine-render",
"@univerjs/engine-formula",
],
"univer-sheets": [
"@univerjs/sheets",
"@univerjs/sheets-ui",
"@univerjs/sheets-formula",
"@univerjs/sheets-formula-ui",
"@univerjs/sheets-numfmt",
"@univerjs/sheets-numfmt-ui",
],
"univer-docs": ["@univerjs/docs", "@univerjs/docs-ui"],
"univer-ui": ["@univerjs/ui", "@univerjs/facade"],
},
},
},
},
// 서버 설정
server: {
fs: {
strict: false,
},
},
// @ts-ignore - vitest config
test: {
globals: true,
environment: "jsdom",