Files
sheeteasyAI/src/components/sheet/EditSheetViewer.tsx
sheetEasy AI Team 2f3515985d feat: 튜토리얼 UX 개선 - 네비게이션 및 프롬프트 피드백 강화
🔧 주요 개선사항:

1. Topbar 네비게이션 문제 해결
   - 튜토리얼 페이지에서 메뉴 항목 클릭 시 올바른 라우팅 구현
   - Tutorial 메뉴 클릭 시 페이지 리로드 기능 추가 (컴포넌트 리마운트)
   - 라우팅 우선, 스크롤 폴백 패턴 적용

2. PromptInput 플레이스홀더 개선
   - 튜토리얼 실행 후 실제 사용된 프롬프트를 플레이스홀더에 표시
   - 명확한 프롬프트 → 실행 → 결과 추적 가능
   - 새 튜토리얼 선택 시 이전 프롬프트 초기화

3. 새로운 튜토리얼 시스템 구축
   - TutorialSheetViewer: 단계별 튜토리얼 플로우 구현
   - TutorialCard: 개별 튜토리얼 카드 컴포넌트
   - TutorialExecutor: 튜토리얼 실행 엔진
   - TutorialDataGenerator: 10개 Excel 함수 데이터 생성

📁 변경된 파일들:
- src/App.tsx: 네비게이션 핸들러 추가
- src/components/ui/topbar.tsx: 라우팅 기반 네비게이션 구현
- src/components/sheet/PromptInput.tsx: 동적 플레이스홀더 추가
- src/components/TutorialSheetViewer.tsx: 튜토리얼 전용 뷰어 구현
- src/types/tutorial.ts: 튜토리얼 타입 정의
- .cursor/rules/tutorial-navigation-fix.mdc: 구현 패턴 문서화

 검증 완료:
- 모든 topbar 메뉴 정상 네비게이션
- 튜토리얼 페이지 리로드 기능 작동
- 실행된 프롬프트 플레이스홀더 표시
- AI 워크플로우 시뮬레이션 완성
2025-07-01 15:47:26 +09:00

1234 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useEffect, useState, useCallback } from "react";
// 공식 문서 권장: presets 패키지 사용으로 간소화
import {
createUniver,
defaultTheme,
LocaleType,
merge,
} from "@univerjs/presets";
import { UniverSheetsCorePreset } from "@univerjs/presets/preset-sheets-core";
import UniverPresetSheetsCoreEnUS from "@univerjs/presets/preset-sheets-core/locales/en-US";
// Presets CSS import
import "@univerjs/presets/lib/styles/preset-sheets-core.css";
import { cn } from "../../lib/utils";
import LuckyExcel from "@zwight/luckyexcel";
import PromptInput from "./PromptInput";
import HistoryPanel from "../ui/historyPanel";
import { useAppStore } from "../../stores/useAppStore";
import { rangeToAddress } from "../../utils/cellUtils";
import { CellSelectionHandler } from "../../utils/cellSelectionHandler";
import { aiProcessor } from "../../utils/aiProcessor";
import { TutorialExecutor } from "../../utils/tutorialExecutor";
import type { HistoryEntry } from "../../types/ai";
// 전역 고유 키 생성
const GLOBAL_UNIVER_KEY = "__GLOBAL_UNIVER_INSTANCE__";
const GLOBAL_STATE_KEY = "__GLOBAL_UNIVER_STATE__";
// 전역 상태 인터페이스
interface GlobalUniverState {
instance: any | null; // createUniver 반환 타입
univerAPI: any | null; // univerAPI 별도 저장
isInitializing: boolean;
isDisposing: boolean;
initializationPromise: Promise<any> | null;
lastContainerId: string | null;
}
// Window 객체에 전역 상태 확장
declare global {
interface Window {
[GLOBAL_UNIVER_KEY]: any | null;
[GLOBAL_STATE_KEY]: GlobalUniverState;
__UNIVER_DEBUG__: {
getGlobalUniver: () => any | null;
getGlobalState: () => GlobalUniverState;
clearGlobalState: () => void;
forceReset: () => void;
completeCleanup: () => void;
};
}
}
// 전역 상태 초기화 함수
const initializeGlobalState = (): GlobalUniverState => {
if (!window[GLOBAL_STATE_KEY]) {
window[GLOBAL_STATE_KEY] = {
instance: null,
univerAPI: null,
isInitializing: false,
isDisposing: false,
initializationPromise: null,
lastContainerId: null,
};
}
return window[GLOBAL_STATE_KEY];
};
// 전역 상태 가져오기
const getGlobalState = (): GlobalUniverState => {
return window[GLOBAL_STATE_KEY] || initializeGlobalState();
};
/**
* Presets 기반 강화된 전역 Univer 관리자
* 공식 문서 권장사항에 따라 createUniver 사용
*/
export const UniverseManager = {
// 전역 인스턴스 생성 (완전 단일 인스턴스 보장)
async createInstance(container: HTMLElement): Promise<any> {
const state = getGlobalState();
const containerId = container.id || `container-${Date.now()}`;
console.log(`🚀 Univer 인스턴스 생성 요청 - Container: ${containerId}`);
// 이미 존재하는 인스턴스가 있고 같은 컨테이너면 재사용
if (
state.instance &&
state.univerAPI &&
state.lastContainerId === containerId
) {
console.log("✅ 기존 전역 Univer 인스턴스와 univerAPI 재사용");
return { univer: state.instance, univerAPI: state.univerAPI };
}
// 초기화가 진행 중이면 대기
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("🛠️ Presets 기반 Univer 인스턴스 생성 중...");
// 공식 문서 권장: createUniver 사용으로 대폭 간소화
const { univer, univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: merge({}, UniverPresetSheetsCoreEnUS),
},
theme: defaultTheme,
presets: [
UniverSheetsCorePreset({
container: container,
}),
],
});
// 전역 상태 업데이트 (univerAPI도 함께 저장)
state.instance = univer;
state.univerAPI = univerAPI;
state.lastContainerId = containerId;
window[GLOBAL_UNIVER_KEY] = univer;
console.log("✅ Presets 기반 Univer 인스턴스와 univerAPI 생성 완료");
return { univer, univerAPI };
} 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.univerAPI = null;
state.lastContainerId = null;
window[GLOBAL_UNIVER_KEY] = null;
console.log("✅ 전역 Univer 인스턴스 dispose 완료");
} catch (error) {
console.error("❌ dispose 실패:", error);
} finally {
state.isDisposing = false;
}
},
// 현재 인스턴스 반환
getInstance(): any | 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);
}
}
// REDI 전역 상태 완전 정리 시도
try {
// 브라우저의 전역 REDI 상태 정리 (가능한 경우)
if (typeof window !== "undefined") {
// REDI 관련 전역 객체들 정리
const globalKeys = Object.keys(window).filter(
(key) =>
key.includes("redi") ||
key.includes("REDI") ||
key.includes("univerjs") ||
key.includes("univer"),
);
globalKeys.forEach((key) => {
try {
delete (window as any)[key];
} catch (e) {
console.warn(`전역 키 ${key} 정리 실패:`, e);
}
});
console.log("🧹 REDI 전역 상태 정리 시도 완료");
}
} catch (error) {
console.warn("⚠️ REDI 전역 상태 정리 중 오류:", error);
}
state.instance = null;
state.univerAPI = null;
state.isInitializing = false;
state.isDisposing = false;
state.initializationPromise = null;
state.lastContainerId = null;
window[GLOBAL_UNIVER_KEY] = null;
console.log("🔄 전역 Univer 상태 강제 리셋 완료 (REDI 정리 포함)");
},
// 완전한 정리 메서드 (REDI 포함)
async completeCleanup(): Promise<void> {
const state = getGlobalState();
if (state.isDisposing) {
console.log("🔄 이미 정리 진행 중...");
return;
}
state.isDisposing = true;
try {
console.log("🗑️ 완전한 정리 시작 (REDI 포함)");
if (state.instance) {
await state.instance.dispose();
}
// 약간의 대기 후 REDI 상태 정리
await new Promise((resolve) => setTimeout(resolve, 100));
// REDI 전역 상태 정리 시도
this.forceReset();
console.log("✅ 완전한 정리 완료");
} catch (error) {
console.error("❌ 완전한 정리 실패:", error);
} finally {
state.isDisposing = false;
}
},
};
// 전역 디버그 객체 설정을 위한 헬퍼 함수 (모듈 레벨 실행 방지)
const setupDebugTools = (): void => {
if (typeof window !== "undefined" && !window.__UNIVER_DEBUG__) {
// 전역 상태 초기화 (필요한 경우에만)
initializeGlobalState();
// 디버그 객체 설정
window.__UNIVER_DEBUG__ = {
getGlobalUniver: () => UniverseManager.getInstance(),
getGlobalState: () => getGlobalState(),
clearGlobalState: () => {
const state = getGlobalState();
Object.assign(state, {
instance: null,
univerAPI: null,
isInitializing: false,
isDisposing: false,
initializationPromise: null,
lastContainerId: null,
});
window[GLOBAL_UNIVER_KEY] = null;
console.log("🧹 전역 상태 정리 완료");
},
forceReset: () => UniverseManager.forceReset(),
completeCleanup: () => UniverseManager.completeCleanup(),
};
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 [, setIsInitialized] = useState<boolean>(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("");
const [showPromptInput, setShowPromptInput] = useState(true);
const appStore = useAppStore();
// CellSelectionHandler 인스턴스 생성
const cellSelectionHandler = useRef(new CellSelectionHandler());
// TutorialExecutor 인스턴스 생성
const tutorialExecutor = useRef(new TutorialExecutor());
// 히스토리 관련 상태 추가
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
// 히스토리 관련 핸들러
const handleHistoryToggle = () => {
console.log("🔄 히스토리 토글:", !isHistoryOpen);
setIsHistoryOpen(!isHistoryOpen);
};
const handleHistoryClear = () => {
if (window.confirm("모든 히스토리를 삭제하시겠습니까?")) {
setHistory([]);
}
};
const handleHistoryReapply = useCallback(async (entry: HistoryEntry) => {
console.log("🔄 히스토리 재적용 시작:", entry);
// 1. 프롬프트 입력창에 기존 프롬프트 설정
setPrompt(entry.prompt);
// 2. 확인 대화상자
const confirmReapply = window.confirm(
`다음 프롬프트를 다시 실행하시겠습니까?\n\n"${entry.prompt}"\n\n범위: ${entry.range} | 시트: ${entry.sheetName}`,
);
if (!confirmReapply) {
return;
}
// 3. 재적용 히스토리 항목 생성
const reapplyHistoryId = addHistoryEntry(
`[재적용] ${entry.prompt}`,
entry.range,
entry.sheetName,
[],
"pending",
);
try {
// 4. AI 프로세서 실행
const result = await aiProcessor.processPrompt(entry.prompt, true);
// 5. 결과에 따라 히스토리 업데이트
if (result.success) {
updateHistoryEntry(reapplyHistoryId, {
status: "success",
actions:
result.appliedCells?.map((cell) => ({
type: "formula" as const,
range: cell,
formula: `=재적용 수식`, // TODO: 실제 수식 정보 저장
})) || [],
});
// 성공 시 팝업 제거 (히스토리 패널에서 확인 가능)
console.log(`✅ 재적용 성공: ${result.message}`);
} else {
updateHistoryEntry(reapplyHistoryId, {
status: "error",
error: result.message,
actions: [],
});
// 실패 알림
alert(`❌ 재적용 실패: ${result.message}`);
}
} catch (error) {
// 6. 예외 처리
const errorMessage =
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.";
updateHistoryEntry(reapplyHistoryId, {
status: "error",
error: errorMessage,
actions: [],
});
alert(`❌ 재적용 중 오류: ${errorMessage}`);
}
}, []);
// 새 히스토리 항목 추가 함수
const addHistoryEntry = (
prompt: string,
range: string,
sheetName: string,
actions: any[],
status: "success" | "error" | "pending",
error?: string,
) => {
const newEntry: HistoryEntry = {
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date(),
prompt,
range,
sheetName,
actions,
status,
error,
};
setHistory((prev) => [newEntry, ...prev]); // 최신 항목을 맨 위에
return newEntry.id; // 히스토리 ID 반환하여 나중에 업데이트 가능
};
// 기존 히스토리 항목 업데이트 함수
const updateHistoryEntry = (
id: string,
updates: Partial<Omit<HistoryEntry, "id" | "timestamp">>,
) => {
setHistory((prev) =>
prev.map((entry) => (entry.id === id ? { ...entry, ...updates } : entry)),
);
};
// AI 프롬프트 실행 핸들러 (히스토리 연동)
const handlePromptExecute = useCallback(async () => {
if (!prompt.trim()) {
alert("프롬프트를 입력해주세요.");
return;
}
const currentRange = appStore.selectedRange
? rangeToAddress(appStore.selectedRange.range)
: "A1";
const currentSheetName = "Sheet1"; // TODO: 실제 시트명 가져오기
// 1. 히스토리에 pending 상태로 추가
const historyId = addHistoryEntry(
prompt.trim(),
currentRange,
currentSheetName,
[],
"pending",
);
try {
// 2. AI 프로세서 실행
const result = await aiProcessor.processPrompt(prompt.trim(), true);
// 3. 결과에 따라 히스토리 업데이트
if (result.success) {
updateHistoryEntry(historyId, {
status: "success",
actions:
result.appliedCells?.map((cell) => ({
type: "formula" as const,
range: cell,
formula: `=수식`, // TODO: 실제 수식 정보 저장
})) || [],
});
// 성공 시 팝업 제거 (히스토리 패널에서 확인 가능)
console.log(`${result.message}`);
} else {
updateHistoryEntry(historyId, {
status: "error",
error: result.message,
actions: [],
});
// 실패 알림
alert(`${result.message}`);
}
} catch (error) {
// 4. 예외 처리
const errorMessage =
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.";
updateHistoryEntry(historyId, {
status: "error",
error: errorMessage,
actions: [],
});
alert(`❌ 처리 중 오류: ${errorMessage}`);
}
// 5. 프롬프트 입력창 초기화
setPrompt("");
}, [prompt, appStore.selectedRange]);
// Univer 초기화 함수 (Presets 기반)
const initializeUniver = useCallback(
async (workbookData?: any) => {
if (!containerRef.current || !mountedRef.current) {
console.error("❌ 컨테이너가 준비되지 않았습니다!");
return;
}
try {
console.log("🚀 Presets 기반 Univer 초기화 시작");
// 전역 인스턴스 생성 또는 재사용 (Presets 사용)
const { univer, univerAPI } = 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;
// Presets에서는 univerAPI가 자동으로 제공됨
try {
if (univerAPI) {
console.log("✅ Presets 기반 univerAPI 초기화 완료");
// 새 워크북 생성 (presets univerAPI 방식)
const workbook = univerAPI.createWorkbook(workbookToUse);
console.log("✅ Presets 기반 워크북 생성 완료:", workbook?.getId());
// 셀 선택 핸들러 초기화 - SRP에 맞춰 별도 클래스로 분리
cellSelectionHandler.current.initialize(univer);
// TutorialExecutor에 Univer API 설정
tutorialExecutor.current.setUniverAPI(univerAPI);
setIsInitialized(true);
} else {
console.warn("⚠️ univerAPI가 제공되지 않음");
setIsInitialized(false);
}
} catch (error) {
console.error("❌ Presets 기반 워크북 생성 오류:", error);
setIsInitialized(false);
}
} catch (error) {
console.error("❌ Presets 기반 Univer 초기화 실패:", error);
setIsInitialized(false);
}
},
[appStore],
);
// 파일 처리 함수
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("✅ 파일 처리 완료");
}
},
[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("🎯 컴포넌트 마운트됨");
// 디버그 도구 설정 (컴포넌트 마운트 시에만)
setupDebugTools();
// 강화된 기존 인스턴스 확인 및 재사용
const existingUniver = UniverseManager.getInstance();
const state = getGlobalState();
// 기존 인스턴스가 있고 정상 상태면 재사용
if (
existingUniver &&
state.univerAPI &&
!UniverseManager.isInitializing() &&
!UniverseManager.isDisposing()
) {
console.log("♻️ 기존 전역 Univer 인스턴스와 univerAPI 재사용");
setIsInitialized(true);
// 기존 인스턴스의 컨테이너가 현재 컨테이너와 다른 경우 갱신
if (
containerRef.current &&
state.lastContainerId !== containerRef.current.id
) {
console.log("🔄 컨테이너 정보 갱신");
state.lastContainerId = containerRef.current.id;
}
return;
}
// 초기화 중이거나 정리 중인 경우 대기
if (UniverseManager.isInitializing() || UniverseManager.isDisposing()) {
console.log("⏳ 초기화/정리 중이므로 대기");
const waitTimer = setInterval(() => {
if (
!UniverseManager.isInitializing() &&
!UniverseManager.isDisposing()
) {
clearInterval(waitTimer);
const currentUniver = UniverseManager.getInstance();
if (currentUniver && getGlobalState().univerAPI) {
console.log("✅ 대기 후 기존 인스턴스 재사용");
setIsInitialized(true);
} else if (containerRef.current && mountedRef.current) {
console.log("🚀 대기 후 새 인스턴스 초기화");
initializeUniver();
}
}
}, 100);
return () => clearInterval(waitTimer);
}
// 완전히 새로운 초기화가 필요한 경우만 진행
const initTimer = setTimeout(() => {
// 마운트 상태와 컨테이너 재확인
if (
containerRef.current &&
mountedRef.current &&
!UniverseManager.isInitializing()
) {
console.log("🚀 컴포넌트 마운트 시 새 Univer 초기화");
initializeUniver();
}
}, 100); // DOM 완전 준비 보장
return () => {
clearTimeout(initTimer);
mountedRef.current = false;
console.log("👋 컴포넌트 언마운트됨");
};
}, []); // 의존성 배열을 빈 배열로 변경하여 한 번만 실행
// 컴포넌트 언마운트 시 정리 (전역 인스턴스는 유지)
useEffect(() => {
return () => {
// 전역 인스턴스는 앱 종료 시에만 정리
// 여기서는 로컬 상태만 초기화
setIsInitialized(false);
console.log("🧹 로컬 상태 정리 완료");
};
}, []);
// 컴포넌트 언마운트 시 리소스 정리
useEffect(() => {
return () => {
// 셀 선택 핸들러 정리
if (cellSelectionHandler.current.isActive()) {
cellSelectionHandler.current.dispose();
}
};
}, []);
// 튜토리얼 자동 실행 로직
useEffect(() => {
const { tutorialSession } = appStore;
// 튜토리얼이 선택되었고 아직 실행되지 않은 경우
if (
tutorialSession.activeTutorial &&
tutorialSession.execution?.status === "준비중" &&
tutorialSession.isAutoMode &&
!tutorialExecutor.current.isCurrentlyExecuting()
) {
console.log(
"🎯 튜토리얼 자동 실행 시작:",
tutorialSession.activeTutorial.metadata.title,
);
const executeTutorial = async () => {
try {
// 상태를 실행중으로 업데이트
appStore.updateTutorialExecution("실행중", 1);
// 튜토리얼 실행
const result = await tutorialExecutor.current.startTutorial(
tutorialSession.activeTutorial!,
{
autoExecute: true,
stepDelay: 1500,
highlightDuration: 2000,
showFormula: true,
enableAnimation: true,
},
);
if (result.success) {
// 성공 시 상태 업데이트
appStore.updateTutorialExecution("완료", 3);
// 프롬프트 자동 입력
if (tutorialSession.activeTutorial!.prompt) {
setPrompt(tutorialSession.activeTutorial!.prompt);
setShowPromptInput(true);
}
console.log("✅ 튜토리얼 실행 완료:", result);
} else {
appStore.updateTutorialExecution(
"오류",
undefined,
"튜토리얼 실행 실패",
);
}
} catch (error) {
console.error("❌ 튜토리얼 실행 오류:", error);
appStore.updateTutorialExecution(
"오류",
undefined,
error instanceof Error ? error.message : "알 수 없는 오류",
);
}
};
// Univer가 초기화된 후 실행
if (getGlobalState().univerAPI) {
executeTutorial();
} else {
// Univer API가 준비될 때까지 대기
const checkInterval = setInterval(() => {
if (getGlobalState().univerAPI) {
clearInterval(checkInterval);
executeTutorial();
}
}, 500);
// 10초 후 타임아웃
setTimeout(() => {
clearInterval(checkInterval);
appStore.updateTutorialExecution(
"오류",
undefined,
"Univer API 초기화 타임아웃",
);
}, 10000);
}
}
}, [appStore.tutorialSession, appStore]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col relative">
{/* 헤더 - App.tsx와 동일한 스타일 적용 */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
{currentFile && (
<span className="text-sm text-blue-600 font-medium">
📄 {currentFile.name}
</span>
)}
</div>
<div className="flex items-center space-x-4">
<button
onClick={handleNewUpload}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
</button>
</div>
</div>
</div>
</header>
{/* 메인 콘텐츠 - App.tsx와 동일한 패턴 */}
<main className="h-[calc(100vh-4rem)]">
<div className="h-full">
{/* Univer 컨테이너 - 전체 화면 사용 */}
<div
ref={containerRef}
className="relative bg-white"
style={{
minHeight: "0",
width: "100%",
height: "100%", // 전체 높이 사용
}}
/>
{/* 히스토리 패널 - 파일이 업로드된 후에만 표시 */}
{!showUploadOverlay && (
<HistoryPanel
isOpen={isHistoryOpen}
onClose={() => setIsHistoryOpen(false)}
history={history}
onReapply={handleHistoryReapply}
onClear={handleHistoryClear}
/>
)}
{/* 파일 업로드 오버레이 - 레이어 분리 */}
{showUploadOverlay && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 40,
backgroundColor: "rgba(255, 255, 255, 0.01)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "auto",
}}
onClick={() => setShowUploadOverlay(false)}
>
<div
className="max-w-2xl w-full"
style={{ transform: "scale(0.8)" }}
onClick={(e) => e.stopPropagation()}
>
<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>
)}
{/* 프롬프트 입력창 - Univer 위 오버레이 */}
{showPromptInput && (
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-40">
<div className="bg-white/95 backdrop-blur-sm border border-gray-200 rounded-2xl shadow-2xl p-4 max-w-4xl w-[90vw] sm:w-[80vw] md:w-[70vw] lg:w-[60vw]">
<PromptInput
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onExecute={handlePromptExecute}
onHistoryToggle={handleHistoryToggle}
historyCount={history.length}
disabled={isProcessing}
/>
</div>
</div>
)}
{/* AI 프롬프트 토글 버튼 - 플로팅 */}
{!showUploadOverlay && (
<button
onClick={() => setShowPromptInput(!showPromptInput)}
className={`fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-2xl transition-all duration-300 flex items-center justify-center ${
showPromptInput
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-green-500 hover:bg-green-600 text-white"
}`}
aria-label={
showPromptInput ? "AI 프롬프트 닫기" : "AI 프롬프트 열기"
}
>
{showPromptInput ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
)}
</button>
)}
</div>
</main>
</div>
);
};
export default TestSheetViewer;