엑셀 파일 부르기 완료, 입력창 UI 설정 완료

This commit is contained in:
sheetEasy AI Team
2025-06-24 17:48:11 +09:00
parent 105265a384
commit 5712c40ec9
14 changed files with 456 additions and 5241 deletions

View File

@@ -0,0 +1,789 @@
import React, { useRef, useEffect, useState, useCallback } 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 { cn } from "../../lib/utils";
import LuckyExcel from "@zwight/luckyexcel";
import PromptInput from "./PromptInput";
// 언어팩 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";
// 전역 고유 키 생성
const GLOBAL_UNIVER_KEY = "__GLOBAL_UNIVER_INSTANCE__";
const GLOBAL_STATE_KEY = "__GLOBAL_UNIVER_STATE__";
// 전역 상태 인터페이스
interface GlobalUniverState {
instance: Univer | null;
isInitializing: boolean;
isDisposing: boolean;
initializationPromise: Promise<Univer> | null;
lastContainerId: string | null;
}
// Window 객체에 전역 상태 확장
declare global {
interface Window {
[GLOBAL_UNIVER_KEY]: Univer | null;
[GLOBAL_STATE_KEY]: GlobalUniverState;
__UNIVER_DEBUG__: {
getGlobalUniver: () => Univer | null;
getGlobalState: () => GlobalUniverState;
clearGlobalState: () => void;
forceReset: () => void;
};
}
}
// 전역 상태 초기화 함수
const initializeGlobalState = (): GlobalUniverState => {
if (!window[GLOBAL_STATE_KEY]) {
window[GLOBAL_STATE_KEY] = {
instance: null,
isInitializing: false,
isDisposing: false,
initializationPromise: null,
lastContainerId: null,
};
}
return window[GLOBAL_STATE_KEY];
};
// 전역 상태 가져오기
const getGlobalState = (): GlobalUniverState => {
return window[GLOBAL_STATE_KEY] || initializeGlobalState();
};
/**
* Window 객체 기반 강화된 전역 Univer 관리자
* 모듈 재로드와 HMR에도 안전하게 작동
*/
const UniverseManager = {
// 전역 인스턴스 생성 (완전 단일 인스턴스 보장)
async createInstance(container: HTMLElement): Promise<Univer> {
const state = getGlobalState();
const containerId = container.id || `container-${Date.now()}`;
console.log(`🚀 Univer 인스턴스 생성 요청 - Container: ${containerId}`);
// 이미 존재하는 인스턴스가 있고 같은 컨테이너면 재사용
if (state.instance && state.lastContainerId === containerId) {
console.log("✅ 기존 전역 Univer 인스턴스 재사용");
return state.instance;
}
// 초기화가 진행 중이면 대기
if (state.isInitializing && state.initializationPromise) {
console.log("⏳ 기존 초기화 프로세스 대기 중...");
return state.initializationPromise;
}
// 새로운 초기화 시작
state.isInitializing = true;
console.log("🔄 새로운 Univer 인스턴스 생성 시작");
// 기존 인스턴스 정리
if (state.instance) {
try {
console.log("🗑️ 기존 인스턴스 정리 중...");
await state.instance.dispose();
state.instance = null;
window[GLOBAL_UNIVER_KEY] = null;
} catch (error) {
console.warn("⚠️ 기존 인스턴스 정리 중 오류:", error);
}
}
// 초기화 Promise 생성
state.initializationPromise = (async () => {
try {
// 컨테이너 ID 설정
if (!container.id) {
container.id = containerId;
}
console.log("🛠️ Univer 인스턴스 생성 중...");
const univer = new Univer({
theme: defaultTheme,
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: {
...DesignEnUS,
...UIEnUS,
...DocsUIEnUS,
...SheetsEnUS,
...SheetsUIEnUS,
...SheetsFormulaUIEnUS,
...SheetsNumfmtUIEnUS,
},
},
});
// 플러그인 등록 순서 (중요: Core → UI → Sheets → Docs → Formula → NumFmt)
console.log("🔌 플러그인 등록 중...");
univer.registerPlugin(UniverRenderEnginePlugin);
univer.registerPlugin(UniverUIPlugin, { container });
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverSheetsUIPlugin);
univer.registerPlugin(UniverDocsPlugin);
univer.registerPlugin(UniverDocsUIPlugin);
univer.registerPlugin(UniverSheetsFormulaPlugin);
univer.registerPlugin(UniverSheetsFormulaUIPlugin);
univer.registerPlugin(UniverSheetsNumfmtPlugin);
univer.registerPlugin(UniverSheetsNumfmtUIPlugin);
// 전역 상태 업데이트
state.instance = univer;
state.lastContainerId = containerId;
window[GLOBAL_UNIVER_KEY] = univer;
console.log("✅ Univer 인스턴스 생성 완료");
return univer;
} catch (error) {
console.error("❌ Univer 인스턴스 생성 실패:", error);
throw error;
} finally {
state.isInitializing = false;
state.initializationPromise = null;
}
})();
return state.initializationPromise;
},
// 전역 인스턴스 정리
async disposeInstance(): Promise<void> {
const state = getGlobalState();
if (state.isDisposing) {
console.log("🔄 이미 dispose 진행 중...");
return;
}
if (!state.instance) {
console.log(" 정리할 인스턴스가 없음");
return;
}
state.isDisposing = true;
try {
console.log("🗑️ 전역 Univer 인스턴스 dispose 시작");
await state.instance.dispose();
state.instance = null;
state.lastContainerId = null;
window[GLOBAL_UNIVER_KEY] = null;
console.log("✅ 전역 Univer 인스턴스 dispose 완료");
} catch (error) {
console.error("❌ dispose 실패:", error);
} finally {
state.isDisposing = false;
}
},
// 현재 인스턴스 반환
getInstance(): Univer | null {
const state = getGlobalState();
return state.instance || window[GLOBAL_UNIVER_KEY] || null;
},
// 상태 확인 메서드들
isInitializing(): boolean {
return getGlobalState().isInitializing || false;
},
isDisposing(): boolean {
return getGlobalState().isDisposing || false;
},
// 강제 리셋 (디버깅용)
forceReset(): void {
const state = getGlobalState();
if (state.instance) {
try {
state.instance.dispose();
} catch (error) {
console.warn("강제 리셋 중 dispose 오류:", error);
}
}
state.instance = null;
state.isInitializing = false;
state.isDisposing = false;
state.initializationPromise = null;
state.lastContainerId = null;
window[GLOBAL_UNIVER_KEY] = null;
console.log("🔄 전역 Univer 상태 강제 리셋 완료");
},
};
// 전역 디버그 객체 설정
if (typeof window !== "undefined") {
// 전역 상태 초기화
initializeGlobalState();
// 디버그 객체 설정
window.__UNIVER_DEBUG__ = {
getGlobalUniver: () => UniverseManager.getInstance(),
getGlobalState: () => getGlobalState(),
clearGlobalState: () => {
const state = getGlobalState();
Object.assign(state, {
instance: null,
isInitializing: false,
isDisposing: false,
initializationPromise: null,
lastContainerId: null,
});
window[GLOBAL_UNIVER_KEY] = null;
console.log("🧹 전역 상태 정리 완료");
},
forceReset: () => UniverseManager.forceReset(),
};
console.log("🐛 디버그 객체 설정 완료: window.__UNIVER_DEBUG__");
}
/**
* Univer CE + 파일 업로드 오버레이
* Window 객체 기반 완전한 단일 인스턴스 관리
*/
const TestSheetViewer: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mountedRef = useRef<boolean>(false);
const [isInitialized, setIsInitialized] = useState(false);
const [showUploadOverlay, setShowUploadOverlay] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [prompt, setPrompt] = useState("");
// Univer 초기화 함수
const initializeUniver = useCallback(async (workbookData?: any) => {
if (!containerRef.current || !mountedRef.current) {
console.error("❌ 컨테이너가 준비되지 않았습니다!");
return;
}
try {
console.log("🚀 Univer 초기화 시작");
// 전역 인스턴스 생성 또는 재사용
const univer = await UniverseManager.createInstance(containerRef.current);
// 기본 워크북 데이터
const defaultWorkbook = {
id: `workbook-${Date.now()}`,
locale: LocaleType.EN_US,
name: "Sample Workbook",
sheetOrder: ["sheet-01"],
sheets: {
"sheet-01": {
type: 0,
id: "sheet-01",
name: "Sheet1",
tabColor: "",
hidden: 0,
rowCount: 100,
columnCount: 20,
zoomRatio: 1,
scrollTop: 0,
scrollLeft: 0,
defaultColumnWidth: 93,
defaultRowHeight: 27,
cellData: {},
rowData: {},
columnData: {},
showGridlines: 1,
rowHeader: { width: 46, hidden: 0 },
columnHeader: { height: 20, hidden: 0 },
selections: ["A1"],
rightToLeft: 0,
},
},
};
const workbookToUse = workbookData || defaultWorkbook;
// 기존 워크북 정리 (API 호환성 고려)
try {
const existingUnits =
(univer as any).getUnitsForType?.(UniverInstanceType.UNIVER_SHEET) ||
[];
for (const unit of existingUnits) {
(univer as any).disposeUnit?.(unit.getUnitId());
}
} catch (error) {
console.log(" 기존 워크북 정리 시 오류 (무시 가능):", error);
}
// 새 워크북 생성
const workbook = univer.createUnit(
UniverInstanceType.UNIVER_SHEET,
workbookToUse,
);
console.log("✅ 워크북 생성 완료:", workbook?.getUnitId());
setIsInitialized(true);
} catch (error) {
console.error("❌ Univer 초기화 실패:", error);
setIsInitialized(false);
}
}, []);
// 파일 처리 함수
const processFile = useCallback(
async (file: File) => {
// 강화된 상태 확인으로 중복 실행 완전 차단
if (
isProcessing ||
UniverseManager.isInitializing() ||
UniverseManager.isDisposing()
) {
console.log("⏸️ 처리 중이거나 상태 변경 중입니다. 현재 상태:", {
isProcessing,
isInitializing: UniverseManager.isInitializing(),
isDisposing: UniverseManager.isDisposing(),
});
return;
}
if (!file.name.toLowerCase().endsWith(".xlsx")) {
throw new Error("XLSX 파일만 지원됩니다.");
}
setIsProcessing(true);
console.log("📁 파일 처리 시작:", file.name);
try {
// LuckyExcel을 사용하여 Excel 파일을 Univer 데이터로 변환
await new Promise<void>((resolve, reject) => {
LuckyExcel.transformExcelToUniver(
file,
async (exportJson: any) => {
try {
console.log("📊 LuckyExcel 변환 완료:", exportJson);
// 변환된 데이터로 워크북 업데이트
if (exportJson && typeof exportJson === "object") {
await initializeUniver(exportJson);
} else {
console.warn(
"⚠️ 변환된 데이터가 유효하지 않음, 기본 워크북 사용",
);
await initializeUniver();
}
setCurrentFile(file);
setShowUploadOverlay(false);
resolve();
} catch (error) {
console.error("❌ 워크북 업데이트 오류:", error);
try {
await initializeUniver();
setCurrentFile(file);
setShowUploadOverlay(false);
resolve();
} catch (fallbackError) {
reject(fallbackError);
}
}
},
(error: any) => {
console.error("❌ LuckyExcel 변환 오류:", error);
reject(new Error("파일 변환 중 오류가 발생했습니다."));
},
);
});
} catch (error) {
console.error("❌ 파일 처리 오류:", error);
throw error;
} finally {
setIsProcessing(false);
console.log("✅ 파일 처리 완료");
}
},
[isProcessing, initializeUniver],
);
// 파일 입력 변경 처리
const handleFileInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
await processFile(file);
} catch (error) {
console.error("❌ 파일 처리 오류:", error);
alert(
error instanceof Error
? error.message
: "파일 처리 중 오류가 발생했습니다.",
);
} finally {
// 파일 입력 초기화로 중복 실행 방지
if (event.target) {
event.target.value = "";
}
}
},
[processFile],
);
// 파일 피커 클릭
const handleFilePickerClick = useCallback(() => {
if (isProcessing || UniverseManager.isInitializing()) return;
fileInputRef.current?.click();
}, [isProcessing]);
// 새 업로드 처리
const handleNewUpload = useCallback(() => {
if (isProcessing || UniverseManager.isInitializing()) return;
setShowUploadOverlay(true);
setCurrentFile(null);
}, [isProcessing]);
// 드래그 앤 드롭 이벤트 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length === 0) return;
const file = files[0];
if (!file.name.toLowerCase().endsWith(".xlsx")) {
alert("XLSX 파일만 업로드 가능합니다.");
return;
}
if (file.size > 50 * 1024 * 1024) {
alert("파일 크기는 50MB를 초과할 수 없습니다.");
return;
}
try {
await processFile(file);
} catch (error) {
console.error("❌ 파일 처리 오류:", error);
alert(
error instanceof Error
? error.message
: "파일 처리 중 오류가 발생했습니다.",
);
}
},
[processFile],
);
// 컴포넌트 마운트 시 초기화
useEffect(() => {
mountedRef.current = true;
console.log("🎯 컴포넌트 마운트됨");
// 기존 전역 인스턴스 확인 및 재사용
const existingUniver = UniverseManager.getInstance();
if (existingUniver && !UniverseManager.isInitializing()) {
console.log("♻️ 기존 전역 Univer 인스턴스 재사용");
setIsInitialized(true);
return;
}
// 컨테이너 준비 후 초기화
const initTimer = setTimeout(() => {
if (containerRef.current && !UniverseManager.isInitializing()) {
console.log("🚀 컴포넌트 마운트 시 Univer 초기화");
initializeUniver();
}
}, 100); // 짧은 지연으로 DOM 완전 준비 보장
return () => {
clearTimeout(initTimer);
mountedRef.current = false;
console.log("👋 컴포넌트 언마운트됨");
};
}, [initializeUniver]);
// 컴포넌트 언마운트 시 정리 (전역 인스턴스는 유지)
useEffect(() => {
return () => {
// 전역 인스턴스는 앱 종료 시에만 정리
// 여기서는 로컬 상태만 초기화
setIsInitialized(false);
console.log("🧹 로컬 상태 정리 완료");
};
}, []);
return (
<div className="w-full h-screen flex flex-col relative">
{/* 헤더 */}
<div className="bg-white border-b p-4 flex-shrink-0 relative z-10">
<div
className="mt-2 flex items-center gap-4"
style={{ position: "relative" }}
>
{currentFile && (
<span className="text-sm text-blue-600 font-medium">
📄 {currentFile.name}
</span>
)}
<button
onClick={handleNewUpload}
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
style={{
position: "absolute",
right: "0%",
top: "50%",
transform: "translateY(-50%)",
}}
>
</button>
</div>
</div>
{/* Univer 컨테이너 (항상 렌더링) */}
<div
ref={containerRef}
className="flex-1 relative"
style={{
minHeight: "0",
width: "100%",
height: "100%",
flexGrow: 0.85,
}}
/>
{/* Univer와 입력창 사이 여백 */}
<div style={{ height: "1rem" }} />
{/* 프롬프트 입력창 - Univer 하단에 이어서 */}
<PromptInput
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onExecute={() => {}}
disabled={true}
/>
{/* 파일 업로드 오버레이 - 레이어 분리 */}
{showUploadOverlay && (
<>
{/* 1. Univer CE 영역만 흐리게 하는 반투명 레이어 */}
<div
style={{
position: "absolute",
top: "0px", // 헤더 높이만큼 아래로 (헤더는 약 80px)
left: 0,
right: 0,
bottom: 0,
zIndex: 40,
backgroundColor: "rgba(255, 255, 255, 0.01)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)", // Safari 지원
pointerEvents: "auto",
}}
/>
{/* 2. Univer 영역 중앙의 업로드 UI */}
<div
style={{
position: "absolute",
top: "-100px", // 헤더 높이만큼 아래로
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
zIndex: 50,
}}
>
<div
className="max-w-2xl w-full"
style={{ transform: "scale(0.8)" }}
>
<div
className="bg-white rounded-lg shadow-xl border p-8 md:p-12"
style={{
backgroundColor: "#ffffff",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
}}
>
<div className="text-center">
{/* 아이콘 및 제목 */}
<div className="mb-8">
<div
className={cn(
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
isProcessing ? "bg-blue-100" : "bg-blue-50",
)}
>
{isProcessing ? (
<svg
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
)}
</div>
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-gray-900">
{isProcessing
? "파일 처리 중..."
: "Excel 파일을 업로드하세요"}
</h2>
<p className="text-sm md:text-base text-gray-600 mb-6">
{isProcessing ? (
<span className="text-blue-600">
...
</span>
) : (
<>
<span className="font-medium text-gray-900">
.xlsx
</span>{" "}
</>
)}
</p>
</div>
{/* 드래그 앤 드롭 영역 */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
"hover:border-blue-400 hover:bg-blue-50",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isDragOver
? "border-blue-500 bg-blue-100 scale-105"
: "border-gray-300",
isProcessing && "opacity-50 cursor-not-allowed",
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleFilePickerClick}
tabIndex={isProcessing ? -1 : 0}
role="button"
>
<div className="flex flex-col items-center justify-center space-y-4">
<div className="text-4xl md:text-6xl">
{isDragOver ? "📂" : "📄"}
</div>
<div className="text-center">
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
{isDragOver
? "파일을 여기에 놓으세요"
: "파일을 드래그하거나 클릭하세요"}
</p>
<p className="text-sm text-gray-600">
50MB까지
</p>
</div>
</div>
</div>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
onChange={handleFileInputChange}
className="hidden"
disabled={isProcessing}
/>
{/* 지원 형식 안내 */}
<div className="mt-6 text-xs text-gray-500">
<p> 형식: Excel (.xlsx)</p>
<p> 크기: 50MB</p>
<p className="mt-2 text-blue-600">
💡 window.__UNIVER_DEBUG__
</p>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default TestSheetViewer;