🔧 주요 개선사항: 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 워크플로우 시뮬레이션 완성
1234 lines
40 KiB
TypeScript
1234 lines
40 KiB
TypeScript
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;
|