diff --git a/.cursor/rules/luckysheet-functionlist-error-fix.mdc b/.cursor/rules/luckysheet-functionlist-error-fix.mdc
new file mode 100644
index 0000000..b93c988
--- /dev/null
+++ b/.cursor/rules/luckysheet-functionlist-error-fix.mdc
@@ -0,0 +1,5 @@
+---
+description:
+globs:
+alwaysApply: false
+---
diff --git a/.cursor/rules/tailwind-v4-migration.mdc b/.cursor/rules/tailwind-v4-migration.mdc
new file mode 100644
index 0000000..2d99452
--- /dev/null
+++ b/.cursor/rules/tailwind-v4-migration.mdc
@@ -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
diff --git a/luckysheet-src.zip b/luckysheet-src.zip
new file mode 100644
index 0000000..760a85c
Binary files /dev/null and b/luckysheet-src.zip differ
diff --git a/package-lock.json b/package-lock.json
index 0e80e7a..f1748cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"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",
@@ -34,6 +35,7 @@
"@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",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
@@ -309,7 +311,6 @@
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1424,6 +1425,13 @@
"node": ">=14"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
@@ -2782,6 +2790,28 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@vitest/ui": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
+ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "fflate": "^0.8.2",
+ "flatted": "^3.3.3",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.1",
+ "tinyglobby": "^0.2.14",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "3.2.4"
+ }
+ },
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
@@ -3496,6 +3526,12 @@
"node": ">=12"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.13",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -4120,6 +4156,13 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4183,6 +4226,12 @@
"node": ">=16"
}
},
+ "node_modules/flatpickr": {
+ "version": "4.6.13",
+ "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
+ "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
+ "license": "MIT"
+ },
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
@@ -5151,6 +5200,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jquery": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
+ "integrity": "sha512-lBHj60ezci2u1v2FqnZIraShGgEXq35qCzMv4lITyHGppTnA13rwR0MgwyNJh9TnDs3aXUvd1xjAotfraMHX/Q==",
+ "deprecated": "This version is deprecated. Please upgrade to the latest version or find support at https://www.herodevs.com/support/jquery-nes.",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5633,6 +5689,19 @@
"jszip": "^3.5.0"
}
},
+ "node_modules/luckysheet": {
+ "version": "2.1.13",
+ "resolved": "https://registry.npmjs.org/luckysheet/-/luckysheet-2.1.13.tgz",
+ "integrity": "sha512-ZotItRKh3fxEtYz0GrZxkf97jeQSGsJpFNAu1I0NMDQ6rVrHAWKeggFak5pClGQ3DP62Gi8kd+8rzOpyY/UNZw==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.1",
+ "dayjs": "^1.9.6",
+ "flatpickr": "^4.6.6",
+ "jquery": "^2.2.4",
+ "numeral": "^2.0.6",
+ "pako": "^1.0.11"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -5769,6 +5838,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5840,6 +5919,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/numeral": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz",
+ "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/nwsapi": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
@@ -6672,6 +6760,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
+ "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -7197,6 +7300,16 @@
"node": ">=8.0"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
diff --git a/package.json b/package.json
index 8a1f7e6..efad1c5 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"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",
@@ -43,6 +44,7 @@
"@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",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
diff --git a/public/output.css b/public/output.css
new file mode 100644
index 0000000..85d0637
--- /dev/null
+++ b/public/output.css
@@ -0,0 +1,1701 @@
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+/*
+! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+6. Use the user's configured `sans` font-variation-settings by default.
+7. Disable tap highlights on iOS
+*/
+
+html,
+:host {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+ font-variation-settings: normal;
+ /* 6 */
+ -webkit-tap-highlight-color: transparent;
+ /* 7 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font-family by default.
+2. Use the user's configured `mono` font-feature-settings by default.
+3. Use the user's configured `mono` font-variation-settings by default.
+4. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-feature-settings: normal;
+ /* 2 */
+ font-variation-settings: normal;
+ /* 3 */
+ font-size: 1em;
+ /* 4 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-feature-settings: inherit;
+ /* 1 */
+ font-variation-settings: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ letter-spacing: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+input:where([type='button']),
+input:where([type='reset']),
+input:where([type='submit']) {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden]:where(:not([hidden="until-found"])) {
+ display: none;
+}
+
+.container {
+ width: 100%;
+}
+
+@media (min-width: 640px) {
+ .container {
+ max-width: 640px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container {
+ max-width: 768px;
+ }
+}
+
+@media (min-width: 1024px) {
+ .container {
+ max-width: 1024px;
+ }
+}
+
+@media (min-width: 1280px) {
+ .container {
+ max-width: 1280px;
+ }
+}
+
+@media (min-width: 1536px) {
+ .container {
+ max-width: 1536px;
+ }
+}
+
+.fixed {
+ position: fixed;
+}
+
+.absolute {
+ position: absolute;
+}
+
+.relative {
+ position: relative;
+}
+
+.inset-0 {
+ inset: 0px;
+}
+
+.z-50 {
+ z-index: 50;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mb-3 {
+ margin-bottom: 0.75rem;
+}
+
+.mb-4 {
+ margin-bottom: 1rem;
+}
+
+.mb-6 {
+ margin-bottom: 1.5rem;
+}
+
+.mb-8 {
+ margin-bottom: 2rem;
+}
+
+.mt-1 {
+ margin-top: 0.25rem;
+}
+
+.mt-6 {
+ margin-top: 1.5rem;
+}
+
+.flex {
+ display: flex;
+}
+
+.inline-flex {
+ display: inline-flex;
+}
+
+.hidden {
+ display: none;
+}
+
+.h-10 {
+ height: 2.5rem;
+}
+
+.h-11 {
+ height: 2.75rem;
+}
+
+.h-16 {
+ height: 4rem;
+}
+
+.h-20 {
+ height: 5rem;
+}
+
+.h-6 {
+ height: 1.5rem;
+}
+
+.h-9 {
+ height: 2.25rem;
+}
+
+.min-h-\[60vh\] {
+ min-height: 60vh;
+}
+
+.min-h-screen {
+ min-height: 100vh;
+}
+
+.w-10 {
+ width: 2.5rem;
+}
+
+.w-20 {
+ width: 5rem;
+}
+
+.w-6 {
+ width: 1.5rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-7xl {
+ max-width: 80rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.max-w-md {
+ max-width: 28rem;
+}
+
+.flex-1 {
+ flex: 1 1 0%;
+}
+
+.flex-shrink-0 {
+ flex-shrink: 0;
+}
+
+.scale-105 {
+ --tw-scale-x: 1.05;
+ --tw-scale-y: 1.05;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+.cursor-not-allowed {
+ cursor: not-allowed;
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.items-start {
+ align-items: flex-start;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.space-x-2 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(0.5rem * var(--tw-space-x-reverse));
+ margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
+.space-x-3 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(0.75rem * var(--tw-space-x-reverse));
+ margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
+.space-x-4 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(1rem * var(--tw-space-x-reverse));
+ margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
+.space-y-1 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
+}
+
+.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.375rem * var(--tw-space-y-reverse));
+}
+
+.space-y-2 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
+}
+
+.space-y-4 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(1rem * var(--tw-space-y-reverse));
+}
+
+.whitespace-nowrap {
+ white-space: nowrap;
+}
+
+.rounded-full {
+ border-radius: 9999px;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.rounded-md {
+ border-radius: calc(0.5rem - 2px);
+}
+
+.border {
+ border-width: 1px;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-b {
+ border-bottom-width: 1px;
+}
+
+.border-dashed {
+ border-style: dashed;
+}
+
+.border-blue-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(191 219 254 / var(--tw-border-opacity, 1));
+}
+
+.border-blue-500 {
+ --tw-border-opacity: 1;
+ border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
+}
+
+.border-gray-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
+}
+
+.border-input {
+ --tw-border-opacity: 1;
+ border-color: hsl(214.3 31.8% 91.4% / var(--tw-border-opacity, 1));
+}
+
+.border-red-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(254 202 202 / var(--tw-border-opacity, 1));
+}
+
+.bg-background {
+ --tw-bg-opacity: 1;
+ background-color: hsl(0 0% 100% / var(--tw-bg-opacity, 1));
+}
+
+.bg-black\/50 {
+ background-color: rgb(0 0 0 / 0.5);
+}
+
+.bg-blue-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
+}
+
+.bg-blue-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
+}
+
+.bg-card {
+ --tw-bg-opacity: 1;
+ background-color: hsl(0 0% 100% / var(--tw-bg-opacity, 1));
+}
+
+.bg-destructive {
+ --tw-bg-opacity: 1;
+ background-color: hsl(0 84.2% 60.2% / var(--tw-bg-opacity, 1));
+}
+
+.bg-gray-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
+}
+
+.bg-green-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1));
+}
+
+.bg-primary {
+ --tw-bg-opacity: 1;
+ background-color: hsl(222.2 47.4% 11.2% / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1));
+}
+
+.bg-secondary {
+ --tw-bg-opacity: 1;
+ background-color: hsl(210 40% 96% / var(--tw-bg-opacity, 1));
+}
+
+.bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
+}
+
+.p-3 {
+ padding: 0.75rem;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-6 {
+ padding: 1.5rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-2 {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.py-8 {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+.pb-4 {
+ padding-bottom: 1rem;
+}
+
+.pt-0 {
+ padding-top: 0px;
+}
+
+.pt-2 {
+ padding-top: 0.5rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.leading-none {
+ line-height: 1;
+}
+
+.tracking-tight {
+ letter-spacing: -0.025em;
+}
+
+.text-blue-600 {
+ --tw-text-opacity: 1;
+ color: rgb(37 99 235 / var(--tw-text-opacity, 1));
+}
+
+.text-blue-700 {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity, 1));
+}
+
+.text-blue-800 {
+ --tw-text-opacity: 1;
+ color: rgb(30 64 175 / var(--tw-text-opacity, 1));
+}
+
+.text-card-foreground {
+ --tw-text-opacity: 1;
+ color: hsl(222.2 84% 4.9% / var(--tw-text-opacity, 1));
+}
+
+.text-destructive-foreground {
+ --tw-text-opacity: 1;
+ color: hsl(210 40% 98% / var(--tw-text-opacity, 1));
+}
+
+.text-gray-500 {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-600 {
+ --tw-text-opacity: 1;
+ color: rgb(75 85 99 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-900 {
+ --tw-text-opacity: 1;
+ color: rgb(17 24 39 / var(--tw-text-opacity, 1));
+}
+
+.text-green-600 {
+ --tw-text-opacity: 1;
+ color: rgb(22 163 74 / var(--tw-text-opacity, 1));
+}
+
+.text-green-800 {
+ --tw-text-opacity: 1;
+ color: rgb(22 101 52 / var(--tw-text-opacity, 1));
+}
+
+.text-muted-foreground {
+ --tw-text-opacity: 1;
+ color: hsl(215.4 16.3% 46.9% / var(--tw-text-opacity, 1));
+}
+
+.text-primary {
+ --tw-text-opacity: 1;
+ color: hsl(222.2 47.4% 11.2% / var(--tw-text-opacity, 1));
+}
+
+.text-primary-foreground {
+ --tw-text-opacity: 1;
+ color: hsl(210 40% 98% / var(--tw-text-opacity, 1));
+}
+
+.text-red-600 {
+ --tw-text-opacity: 1;
+ color: rgb(220 38 38 / var(--tw-text-opacity, 1));
+}
+
+.text-red-700 {
+ --tw-text-opacity: 1;
+ color: rgb(185 28 28 / var(--tw-text-opacity, 1));
+}
+
+.text-red-800 {
+ --tw-text-opacity: 1;
+ color: rgb(153 27 27 / var(--tw-text-opacity, 1));
+}
+
+.text-secondary-foreground {
+ --tw-text-opacity: 1;
+ color: hsl(222.2 84% 4.9% / var(--tw-text-opacity, 1));
+}
+
+.underline-offset-4 {
+ text-underline-offset: 4px;
+}
+
+.opacity-25 {
+ opacity: 0.25;
+}
+
+.opacity-50 {
+ opacity: 0.5;
+}
+
+.opacity-75 {
+ opacity: 0.75;
+}
+
+.shadow-sm {
+ --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.outline {
+ outline-style: solid;
+}
+
+.ring-offset-background {
+ --tw-ring-offset-color: hsl(0 0% 100%);
+}
+
+.filter {
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.backdrop-blur-sm {
+ --tw-backdrop-blur: blur(4px);
+ -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+ backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+}
+
+.transition-all {
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.transition-colors {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.duration-200 {
+ transition-duration: 200ms;
+}
+
+/* 필요한 색상 클래스들 추가 */
+
+.text-gray-500 {
+ color: #6b7280;
+}
+
+.text-gray-600 {
+ color: #4b5563;
+}
+
+.text-gray-900 {
+ color: #111827;
+}
+
+.text-blue-600 {
+ color: #2563eb;
+}
+
+.text-blue-700 {
+ color: #1d4ed8;
+}
+
+.text-blue-800 {
+ color: #1e40af;
+}
+
+.bg-gray-50 {
+ background-color: #f9fafb;
+}
+
+.bg-blue-50 {
+ background-color: #eff6ff;
+}
+
+.bg-blue-100 {
+ background-color: #dbeafe;
+}
+
+.bg-blue-200 {
+ background-color: #bfdbfe;
+}
+
+.border-gray-300 {
+ border-color: #d1d5db;
+}
+
+.border-blue-200 {
+ border-color: #bfdbfe;
+}
+
+.border-blue-400 {
+ border-color: #60a5fa;
+}
+
+.border-blue-500 {
+ border-color: #3b82f6;
+}
+
+.hover\:border-blue-400:hover {
+ border-color: #60a5fa;
+}
+
+.hover\:bg-blue-50:hover {
+ background-color: #eff6ff;
+}
+
+.focus\:ring-blue-500:focus {
+ --tw-ring-color: #3b82f6;
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+}
+
+/* 추가 유틸리티 클래스들 */
+
+.max-w-7xl {
+ max-width: 80rem;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.max-w-md {
+ max-width: 28rem;
+}
+
+.h-16 {
+ height: 4rem;
+}
+
+.h-20 {
+ height: 5rem;
+}
+
+.h-24 {
+ height: 6rem;
+}
+
+.w-20 {
+ width: 5rem;
+}
+
+.w-24 {
+ width: 6rem;
+}
+
+.h-6 {
+ height: 1.5rem;
+}
+
+.w-6 {
+ width: 1.5rem;
+}
+
+.h-10 {
+ height: 2.5rem;
+}
+
+.w-10 {
+ width: 2.5rem;
+}
+
+.h-12 {
+ height: 3rem;
+}
+
+.w-12 {
+ width: 3rem;
+}
+
+.space-x-4 > :not([hidden]) ~ :not([hidden]) {
+ margin-left: 1rem;
+}
+
+.space-x-2 > :not([hidden]) ~ :not([hidden]) {
+ margin-left: 0.5rem;
+}
+
+.space-y-1 > :not([hidden]) ~ :not([hidden]) {
+ margin-top: 0.25rem;
+}
+
+.space-y-2 > :not([hidden]) ~ :not([hidden]) {
+ margin-top: 0.5rem;
+}
+
+.space-y-4 > :not([hidden]) ~ :not([hidden]) {
+ margin-top: 1rem;
+}
+
+.p-3 {
+ padding: 0.75rem;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.p-12 {
+ padding: 3rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.py-8 {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mb-3 {
+ margin-bottom: 0.75rem;
+}
+
+.mb-4 {
+ margin-bottom: 1rem;
+}
+
+.mb-6 {
+ margin-bottom: 1.5rem;
+}
+
+.mb-8 {
+ margin-bottom: 2rem;
+}
+
+.mt-6 {
+ margin-top: 1.5rem;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+}
+
+.text-6xl {
+ font-size: 3.75rem;
+ line-height: 1;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.shadow-sm {
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+}
+
+@media (min-width: 640px) {
+ .sm\:px-6 {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .md\:h-24 {
+ height: 6rem;
+ }
+
+ .md\:w-24 {
+ width: 6rem;
+ }
+
+ .md\:h-12 {
+ height: 3rem;
+ }
+
+ .md\:w-12 {
+ width: 3rem;
+ }
+
+ .md\:p-12 {
+ padding: 3rem;
+ }
+
+ .md\:text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ }
+
+ .md\:text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ }
+
+ .md\:text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ }
+
+ .md\:text-6xl {
+ font-size: 3.75rem;
+ line-height: 1;
+ }
+}
+
+@media (min-width: 1024px) {
+ .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;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* 스크롤바 스타일링 */
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #a1a1a1;
+}
+
+/* 로딩 애니메이션 */
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+.hover\:border-blue-400:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(96 165 250 / var(--tw-border-opacity, 1));
+}
+
+.hover\:bg-accent:hover {
+ --tw-bg-opacity: 1;
+ background-color: hsl(210 40% 96% / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-blue-50:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-destructive\/90:hover {
+ background-color: hsl(0 84.2% 60.2% / 0.9);
+}
+
+.hover\:bg-primary\/90:hover {
+ background-color: hsl(222.2 47.4% 11.2% / 0.9);
+}
+
+.hover\:bg-secondary\/80:hover {
+ background-color: hsl(210 40% 96% / 0.8);
+}
+
+.hover\:text-accent-foreground:hover {
+ --tw-text-opacity: 1;
+ color: hsl(222.2 84% 4.9% / var(--tw-text-opacity, 1));
+}
+
+.hover\:underline:hover {
+ text-decoration-line: underline;
+}
+
+.focus\:outline-none:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
+.focus\:ring-2:focus {
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.focus\:ring-blue-500:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1));
+}
+
+.focus\:ring-offset-2:focus {
+ --tw-ring-offset-width: 2px;
+}
+
+.focus-visible\:outline-none:focus-visible {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
+.focus-visible\:ring-2:focus-visible {
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.focus-visible\:ring-ring:focus-visible {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: hsl(222.2 84% 4.9% / var(--tw-ring-opacity, 1));
+}
+
+.focus-visible\:ring-offset-2:focus-visible {
+ --tw-ring-offset-width: 2px;
+}
+
+.disabled\:pointer-events-none:disabled {
+ pointer-events: none;
+}
+
+.disabled\:opacity-50:disabled {
+ opacity: 0.5;
+}
+
+@media (min-width: 640px) {
+ .sm\:px-6 {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .md\:h-12 {
+ height: 3rem;
+ }
+
+ .md\:h-24 {
+ height: 6rem;
+ }
+
+ .md\:w-12 {
+ width: 3rem;
+ }
+
+ .md\:w-24 {
+ width: 6rem;
+ }
+
+ .md\:p-12 {
+ padding: 3rem;
+ }
+
+ .md\:text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ }
+
+ .md\:text-6xl {
+ font-size: 3.75rem;
+ line-height: 1;
+ }
+
+ .md\:text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ }
+
+ .md\:text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .lg\:px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 781db67..77ca87d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,8 +2,14 @@ import { useAppStore } from "./stores/useAppStore";
import { Card, CardContent } from "./components/ui/card";
import { Button } from "./components/ui/button";
import { FileUpload } from "./components/sheet/FileUpload";
+import { SheetViewer } from "./components/sheet/SheetViewer";
function App() {
+ const { currentFile, sheets, resetApp } = useAppStore();
+
+ // 파일이 업로드되어 시트 데이터가 있는 경우와 없는 경우 구분
+ const hasSheetData = currentFile && sheets && sheets.length > 0;
+
return (
{/* 헤더 */}
@@ -14,17 +20,44 @@ function App() {
sheetEasy AI
-
- Excel 파일 AI 처리 도구
-
+ {hasSheetData && (
+ <>
+
+ {currentFile.name}
+
+
+ >
+ )}
+ {!hasSheetData && (
+
+ Excel 파일 AI 처리 도구
+
+ )}
{/* 메인 콘텐츠 */}
-
-
+
+ {hasSheetData ? (
+ // 파일이 업로드된 경우: SheetViewer 표시 (전체화면)
+
+
+
+ ) : (
+ // 파일이 업로드되지 않은 경우: FileUpload 표시 (중앙 정렬)
+
+
+
+ )}
);
diff --git a/src/components/sheet/SheetViewer.tsx b/src/components/sheet/SheetViewer.tsx
new file mode 100644
index 0000000..26ee64b
--- /dev/null
+++ b/src/components/sheet/SheetViewer.tsx
@@ -0,0 +1,516 @@
+import React, {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useCallback,
+ useState,
+} from "react";
+import { useAppStore } from "../../stores/useAppStore";
+import type { SheetData } from "../../types/sheet";
+
+// Window 타입 확장
+declare global {
+ interface Window {
+ luckysheet: any;
+ LuckyExcel: any;
+ $: any; // jQuery
+ Store: any; // Luckysheet Store
+ luckysheet_function: any; // Luckysheet function list
+ functionlist: any[]; // 글로벌 functionlist
+ luckysheetConfigsetting: any; // Luckysheet 설정 객체
+ luckysheetPostil: any; // Luckysheet 포스틸 객체
+ }
+}
+
+interface SheetViewerProps {
+ className?: string;
+}
+
+/**
+ * Luckysheet 시트 뷰어 컴포넌트
+ * - 참고 내용 기반: 완전한 라이브러리 로딩 순서 적용
+ * - functionlist 오류 방지를 위한 완전한 초기화
+ * - 필수 플러그인과 CSS 포함
+ */
+export function SheetViewer({ className }: SheetViewerProps) {
+ const containerRef = useRef(null);
+ const luckysheetRef = useRef(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [isConverting, setIsConverting] = useState(false);
+ const [error, setError] = useState(null);
+ const [isContainerReady, setIsContainerReady] = useState(false);
+ const [librariesLoaded, setLibrariesLoaded] = useState(false);
+
+ // 스토어에서 시트 데이터 가져오기
+ const { sheets, activeSheetId, currentFile, setSelectedRange } =
+ useAppStore();
+
+ /**
+ * CDN 배포판 + functionlist 직접 초기화 방식
+ */
+ const loadLuckysheetLibrary = useCallback((): Promise => {
+ return new Promise((resolve, reject) => {
+ // 이미 로드된 경우
+ if (
+ window.luckysheet &&
+ window.LuckyExcel &&
+ window.$ &&
+ librariesLoaded
+ ) {
+ console.log("📦 모든 라이브러리가 이미 로드됨");
+ resolve();
+ return;
+ }
+
+ // console.log("📦 CDN 배포판 + functionlist 직접 초기화 방식...");
+
+ const loadResource = (
+ type: "css" | "js",
+ src: string,
+ id: string,
+ ): Promise => {
+ return new Promise((resourceResolve, resourceReject) => {
+ // 이미 로드된 리소스 체크
+ if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
+ // console.log(`📦 ${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 = () => {
+ // console.log(`✅ ${id} CSS 로드 완료`);
+ resourceResolve();
+ };
+ link.onerror = (error) => {
+ // console.error(`❌ ${id} CSS 로드 실패:`, error);
+ 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 = () => {
+ // console.log(`✅ ${id} JS 로드 완료`);
+ resourceResolve();
+ };
+ script.onerror = (error) => {
+ // console.error(`❌ ${id} JS 로드 실패:`, error);
+ resourceReject(new Error(`${id} JS 로드 실패`));
+ };
+ document.head.appendChild(script);
+ }
+ });
+ };
+
+ // CDN 배포판 로딩 + functionlist 직접 초기화
+ 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 로드 (CDN 방식 - 공식 문서 순서 준수)
+ 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",
+ );
+
+ // 👉 Plugin.js 로드 완료 후 바로 다음 단계로 진행
+ console.log("✅ Plugin.js 로드 완료");
+
+ // 4. LuckyExcel (Excel 파일 처리용 - 공식 문서 방식)
+ if (!window.LuckyExcel) {
+ await loadResource(
+ "js",
+ "https://cdn.jsdelivr.net/npm/luckyexcel/dist/luckyexcel.umd.js",
+ "luckyexcel",
+ );
+ }
+
+ // 5. Luckysheet 메인 (functionlist 준비 후 - 공식 문서 방식)
+ if (!window.luckysheet) {
+ await loadResource(
+ "js",
+ "https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js",
+ "luckysheet",
+ );
+ }
+
+ // 라이브러리 로드 후 검증
+ // console.log("🔍 라이브러리 로드 후 검증 중...");
+
+ // NOTE: plugin.js 가 실제 functionlist 를 채웠으므로 별도 지연 대기 불필요
+ // 필수 객체 검증
+ 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"
+ ),
+ };
+
+ // console.log("🔍 라이브러리 검증 결과:", validationResults);
+
+ if (
+ !validationResults.luckysheet ||
+ !validationResults.luckysheetCreate
+ ) {
+ throw new Error(
+ "Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
+ );
+ }
+
+ // 🚀 초기화 없이 바로 라이브러리 검증
+ console.log("🚀 라이브러리 로드 완료, 검증 중...");
+
+ setLibrariesLoaded(true);
+ // console.log("✅ CDN 배포판 + functionlist 초기화 완료");
+ resolve();
+ } catch (error) {
+ // console.error("❌ 라이브러리 로딩 실패:", error);
+ reject(error);
+ }
+ };
+
+ loadSequence();
+ });
+ }, [librariesLoaded]);
+
+ /**
+ * 참고 내용 기반: 올바른 데이터 구조로 Luckysheet 초기화
+ */
+ const convertXLSXToLuckysheet = useCallback(
+ async (xlsxBuffer: ArrayBuffer, fileName: string) => {
+ if (!containerRef.current) {
+ console.warn("⚠️ 컨테이너가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsConverting(true);
+ setError(null);
+
+ // console.log(
+ // "🔄 참고 내용 기반: XLSX → LuckyExcel → Luckysheet 변환 시작...",
+ // );
+
+ // 라이브러리 로드 확인
+ await loadLuckysheetLibrary();
+
+ // 기존 인스턴스 정리 (참고 내용 권장사항)
+ // console.log("🧹 기존 Luckysheet 인스턴스 정리...");
+ 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 호출...");
+
+ // fileProcessor에서 이미 변환된 데이터를 사용하여 직접 생성
+ try {
+ console.log("🍀 이미 변환된 시트 데이터 사용:", currentFile?.name);
+
+ // 기존 인스턴스 정리
+ if (
+ window.luckysheet &&
+ typeof window.luckysheet.destroy === "function"
+ ) {
+ window.luckysheet.destroy();
+ }
+
+ // store에서 sheets 데이터를 가져와서 luckysheet 형식으로 변환
+ const sheets = useAppStore.getState().sheets;
+ const luckysheetData = sheets.map((sheet, index) => ({
+ name: sheet.name,
+ index: index.toString(),
+ status: 1,
+ order: index,
+ celldata: sheet.config?.data?.[0]?.celldata || [],
+ row: sheet.config?.data?.[0]?.row || 50,
+ column: sheet.config?.data?.[0]?.column || 26,
+ }));
+
+ window.luckysheet.create({
+ container: containerRef.current?.id || "luckysheet-container",
+ showinfobar: false,
+ showtoolbar: false,
+ data: luckysheetData,
+ });
+
+ console.log("🎉 Luckysheet 생성 완료!");
+ setIsInitialized(true);
+ setIsConverting(false);
+ setError(null);
+ luckysheetRef.current = window.luckysheet;
+ } catch (createError) {
+ console.error("❌ Luckysheet 생성 실패:", createError);
+ setError(
+ `Luckysheet 생성 실패: ${createError instanceof Error ? createError.message : "알 수 없는 오류"}`,
+ );
+ setIsInitialized(false);
+ setIsConverting(false);
+ }
+ } catch (conversionError) {
+ console.error("❌ 변환 프로세스 실패:", conversionError);
+ setError(
+ `변환 프로세스에 실패했습니다: ${
+ conversionError instanceof Error
+ ? conversionError.message
+ : String(conversionError)
+ }`,
+ );
+ setIsConverting(false);
+ setIsInitialized(false);
+ }
+ },
+ [loadLuckysheetLibrary, setSelectedRange],
+ );
+
+ /**
+ * DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
+ */
+ useLayoutEffect(() => {
+ // console.log("🔍 useLayoutEffect: DOM 컨테이너 체크 시작...");
+ if (containerRef.current) {
+ // console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
+ setIsContainerReady(true);
+ } else {
+ // console.warn("⚠️ useLayoutEffect: DOM 컨테이너가 아직 준비되지 않음");
+ }
+ }, []);
+
+ /**
+ * DOM 컨테이너 준비 상태 재체크 (fallback)
+ */
+ useEffect(() => {
+ if (!isContainerReady) {
+ // console.log("🔄 useEffect: DOM 컨테이너 재체크...");
+ 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 감지, Luckysheet 초기화 시작...", {
+ fileName: currentFile.name,
+ bufferSize: currentFile.xlsxBuffer.byteLength,
+ containerId: containerRef.current.id,
+ });
+
+ // 중복 실행 방지를 위해 즉시 상태 변경
+ setIsConverting(true);
+
+ // 변환된 XLSX ArrayBuffer를 사용하여 직접 변환
+ convertXLSXToLuckysheet(currentFile.xlsxBuffer, currentFile.name);
+ } else if (currentFile && !currentFile.xlsxBuffer) {
+ setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
+ }
+ }, [
+ currentFile?.xlsxBuffer,
+ currentFile?.name,
+ isContainerReady,
+ isInitialized,
+ isConverting,
+ ]);
+
+ /**
+ * 컴포넌트 언마운트 시 정리
+ */
+ useEffect(() => {
+ return () => {
+ if (luckysheetRef.current && window.luckysheet) {
+ // console.log("🧹 컴포넌트 언마운트: Luckysheet 정리 중...");
+ try {
+ window.luckysheet.destroy();
+ } catch (error) {
+ // console.warn("⚠️ Luckysheet 정리 중 오류:", error);
+ }
+ }
+ };
+ }, []);
+
+ /**
+ * 윈도우 리사이즈 처리
+ */
+ useEffect(() => {
+ const handleResize = () => {
+ if (luckysheetRef.current && window.luckysheet) {
+ try {
+ window.luckysheet.resize();
+ } catch (error) {
+ // console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
+ }
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return (
+
+ {/* Luckysheet 컨테이너 - 항상 렌더링 */}
+
+
+ {/* 에러 상태 오버레이 */}
+ {error && (
+
+
+
+ 시트 로드 오류
+
+
{error}
+
+
+
+ )}
+
+ {/* 로딩 상태 오버레이 */}
+ {!error &&
+ (isConverting || !isInitialized) &&
+ currentFile?.xlsxBuffer && (
+
+
+
+
+ {isConverting ? "XLSX 변환 중..." : "시트 초기화 중..."}
+
+
+ {isConverting
+ ? "변환된 XLSX를 Luckysheet로 처리하고 있습니다."
+ : "잠시만 기다려주세요."}
+
+
+
+ )}
+
+ {/* 데이터 없음 상태 오버레이 */}
+ {!error && !currentFile?.xlsxBuffer && (
+
+
+
+ 표시할 시트가 없습니다
+
+
+ Excel 파일을 업로드해주세요.
+
+
+
+ )}
+
+ {/* 시트 정보 표시 (개발용) */}
+ {process.env.NODE_ENV === "development" && (
+
+
파일: {currentFile?.name}
+
+ XLSX 버퍼:{" "}
+ {currentFile?.xlsxBuffer
+ ? `${currentFile.xlsxBuffer.byteLength} bytes`
+ : "없음"}
+
+
변환 중: {isConverting ? "예" : "아니오"}
+
초기화: {isInitialized ? "완료" : "대기"}
+
컨테이너 준비: {isContainerReady ? "완료" : "대기"}
+
+ )}
+
+ );
+}
diff --git a/src/components/sheet/SheetViewer.tsx.bak b/src/components/sheet/SheetViewer.tsx.bak
new file mode 100644
index 0000000..8dfe67c
--- /dev/null
+++ b/src/components/sheet/SheetViewer.tsx.bak
@@ -0,0 +1,838 @@
+import React, {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useCallback,
+ useState,
+} from "react";
+import { useAppStore } from "../../stores/useAppStore";
+import type { SheetData } from "../../types/sheet";
+
+// Window 타입 확장
+declare global {
+ interface Window {
+ luckysheet: any;
+ LuckyExcel: any;
+ $: any; // jQuery
+ Store: any; // Luckysheet Store
+ luckysheet_function: any; // Luckysheet function list
+ functionlist: any[]; // 글로벌 functionlist
+ luckysheetConfigsetting: any; // Luckysheet 설정 객체
+ luckysheetPostil: any; // Luckysheet 포스틸 객체
+ }
+}
+
+interface SheetViewerProps {
+ className?: string;
+}
+
+/**
+ * Luckysheet 시트 뷰어 컴포넌트
+ * - 참고 내용 기반: 완전한 라이브러리 로딩 순서 적용
+ * - functionlist 오류 방지를 위한 완전한 초기화
+ * - 필수 플러그인과 CSS 포함
+ */
+export function SheetViewer({ className }: SheetViewerProps) {
+ const containerRef = useRef(null);
+ const luckysheetRef = useRef(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [isConverting, setIsConverting] = useState(false);
+ const [error, setError] = useState(null);
+ const [isContainerReady, setIsContainerReady] = useState(false);
+ const [librariesLoaded, setLibrariesLoaded] = useState(false);
+
+ // 스토어에서 시트 데이터 가져오기
+ const { sheets, activeSheetId, currentFile, setSelectedRange } =
+ useAppStore();
+
+ /**
+ * CDN 배포판 + functionlist 직접 초기화 방식
+ */
+ const loadLuckysheetLibrary = useCallback((): Promise => {
+ return new Promise((resolve, reject) => {
+ // 이미 로드된 경우
+ if (
+ window.luckysheet &&
+ window.LuckyExcel &&
+ window.$ &&
+ librariesLoaded
+ ) {
+ console.log("📦 모든 라이브러리가 이미 로드됨");
+ resolve();
+ return;
+ }
+
+ // console.log("📦 CDN 배포판 + functionlist 직접 초기화 방식...");
+
+ const loadResource = (
+ type: "css" | "js",
+ src: string,
+ id: string,
+ ): Promise => {
+ return new Promise((resourceResolve, resourceReject) => {
+ // 이미 로드된 리소스 체크
+ if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
+ // console.log(`📦 ${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 = () => {
+ // console.log(`✅ ${id} CSS 로드 완료`);
+ resourceResolve();
+ };
+ link.onerror = (error) => {
+ // console.error(`❌ ${id} CSS 로드 실패:`, error);
+ 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 = () => {
+ // console.log(`✅ ${id} JS 로드 완료`);
+ resourceResolve();
+ };
+ script.onerror = (error) => {
+ // console.error(`❌ ${id} JS 로드 실패:`, error);
+ resourceReject(new Error(`${id} JS 로드 실패`));
+ };
+ document.head.appendChild(script);
+ }
+ });
+ };
+
+ // CDN 배포판 로딩 + functionlist 직접 초기화
+ 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",
+ "/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. Plugin JS 먼저 로드 (functionlist 초기화 우선)
+ await loadResource(
+ "js",
+ "/luckysheet/dist/plugins/js/plugin.js",
+ "plugin-js",
+ );
+
+ // 👉 plugin.js 로드 후 실제 functionlist 가 채워졌는지 polling 으로 확인 (최대 3초)
+ const waitForFunctionlistReady = (
+ timeout = 3000,
+ interval = 50,
+ ): Promise => {
+ return new Promise((res, rej) => {
+ let waited = 0;
+ const timer = setInterval(() => {
+ if (window.Store?.functionlist?.length) {
+ clearInterval(timer);
+ res();
+ } else if ((waited += interval) >= timeout) {
+ clearInterval(timer);
+ rej(new Error("functionlist 초기화 시간 초과"));
+ }
+ }, interval);
+ });
+ };
+
+ await waitForFunctionlistReady();
+
+ // 4. LuckyExcel (Excel 파일 처리용)
+ if (!window.LuckyExcel) {
+ await loadResource(
+ "js",
+ "/luckysheet/dist/luckyexcel.umd.js",
+ "luckyexcel",
+ );
+ }
+
+ // 5. Luckysheet 메인 (functionlist 준비 후)
+ if (!window.luckysheet) {
+ await loadResource(
+ "js",
+ "/luckysheet/dist/luckysheet.umd.js",
+ "luckysheet",
+ );
+ }
+
+ // 라이브러리 로드 후 검증
+ // console.log("🔍 라이브러리 로드 후 검증 중...");
+
+ // NOTE: plugin.js 가 실제 functionlist 를 채웠으므로 별도 지연 대기 불필요
+ // 필수 객체 검증
+ 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"
+ ),
+ };
+
+ // console.log("🔍 라이브러리 검증 결과:", validationResults);
+
+ if (
+ !validationResults.luckysheet ||
+ !validationResults.luckysheetCreate
+ ) {
+ throw new Error(
+ "Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
+ );
+ }
+
+ // 🔧 강력한 functionlist 초기화 (메모리 해결책 적용)
+ // console.log("🔧 강력한 functionlist 및 모든 필수 객체 초기화 중...");
+ try {
+ // 1. Store 객체 강제 생성
+ if (!window.Store) {
+ window.Store = {};
+ }
+
+ // 2. functionlist 다중 레벨 초기화
+ if (!window.Store.functionlist) {
+ window.Store.functionlist = [];
+ }
+
+ // 3. luckysheet_function 다중 레벨 초기화
+ if (!window.luckysheet_function) {
+ window.luckysheet_function = {};
+ }
+ if (!window.Store.luckysheet_function) {
+ window.Store.luckysheet_function = {};
+ }
+
+ // 4. Luckysheet 내부에서 사용하는 추가 functionlist 객체들 초기화
+ if (window.luckysheet && !window.luckysheet.functionlist) {
+ window.luckysheet.functionlist = [];
+ }
+
+ // 5. 글로벌 functionlist 초기화 (다양한 참조 경로 대응)
+ if (!window.functionlist) {
+ window.functionlist = [];
+ }
+
+ // 6. Store 내부 구조 완전 초기화
+ if (!window.Store.config) {
+ window.Store.config = {};
+ }
+ if (!window.Store.luckysheetfile) {
+ window.Store.luckysheetfile = [];
+ }
+ if (!window.Store.currentSheetIndex) {
+ window.Store.currentSheetIndex = 0;
+ }
+
+ // 7. Luckysheet 모듈별 초기화 확인
+ if (window.luckysheet) {
+ // 함수 관련 모듈 초기화
+ if (!window.luckysheet.formula) {
+ window.luckysheet.formula = {};
+ }
+ if (!window.luckysheet.formulaCache) {
+ window.luckysheet.formulaCache = {};
+ }
+ if (!window.luckysheet.formulaObjects) {
+ window.luckysheet.formulaObjects = {};
+ }
+ }
+
+ // console.log("✅ 강력한 functionlist 및 모든 필수 객체 초기화 완료");
+ } catch (functionError) {
+ // console.warn(
+ // "⚠️ 강력한 functionlist 초기화 중 오류 (무시됨):",
+ // functionError,
+ // );
+ }
+
+ setLibrariesLoaded(true);
+ // console.log("✅ CDN 배포판 + functionlist 초기화 완료");
+ resolve();
+ } catch (error) {
+ // console.error("❌ 라이브러리 로딩 실패:", error);
+ reject(error);
+ }
+ };
+
+ loadSequence();
+ });
+ }, [librariesLoaded]);
+
+ /**
+ * 참고 내용 기반: 올바른 데이터 구조로 Luckysheet 초기화
+ */
+ const convertXLSXToLuckysheet = useCallback(
+ async (xlsxBuffer: ArrayBuffer, fileName: string) => {
+ if (!containerRef.current) {
+ console.warn("⚠️ 컨테이너가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsConverting(true);
+ setError(null);
+
+ // console.log(
+ // "🔄 참고 내용 기반: XLSX → LuckyExcel → Luckysheet 변환 시작...",
+ // );
+
+ // 라이브러리 로드 확인
+ await loadLuckysheetLibrary();
+
+ // 기존 인스턴스 정리 (참고 내용 권장사항)
+ // console.log("🧹 기존 Luckysheet 인스턴스 정리...");
+ 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 호출...");
+
+ // LuckyExcel 변환 (참고 내용의 블로그 포스트 방식)
+ window.LuckyExcel.transformExcelToLucky(
+ xlsxBuffer,
+ // 성공 콜백 - 변환 완료 후에만 Luckysheet 초기화
+ (exportJson: any, luckysheetfile: any) => {
+ try {
+ // console.log("✅ LuckyExcel 변환 완료:", {
+ // hasExportJson: !!exportJson,
+ // hasSheets: !!exportJson?.sheets,
+ // sheetsCount: exportJson?.sheets?.length || 0,
+ // sheetsStructure:
+ // exportJson?.sheets?.map((sheet: any, index: number) => ({
+ // index,
+ // name: sheet?.name,
+ // hasData: !!sheet?.data,
+ // dataLength: Array.isArray(sheet?.data)
+ // ? sheet.data.length
+ // : 0,
+ // })) || [],
+ // });
+
+ // 공식 LuckyExcel 방식: 기본 검증만 수행 (과도한 변환 방지)
+ if (
+ !exportJson ||
+ !exportJson.sheets ||
+ !Array.isArray(exportJson.sheets)
+ ) {
+ throw new Error(
+ "LuckyExcel 변환 결과가 유효하지 않습니다: sheets 배열이 없음",
+ );
+ }
+
+ if (exportJson.sheets.length === 0) {
+ throw new Error("변환된 시트가 없습니다.");
+ }
+
+ // console.log("✅ LuckyExcel 변환 결과 검증 완료:", {
+ // sheetsCount: exportJson.sheets.length,
+ // hasInfo: !!exportJson.info,
+ // infoName: exportJson.info?.name,
+ // infoCreator: exportJson.info?.creator,
+ // });
+
+ // console.log(
+ // "🎯 functionlist 초기화 완료: Luckysheet 초기화 시작...",
+ // );
+
+ // 메모리 해결책: 최종 완전한 functionlist 및 모든 Luckysheet 내부 객체 초기화
+ try {
+ // Level 1: Store 객체 완전 초기화
+ if (!window.Store) window.Store = {};
+ if (!window.Store.functionlist) window.Store.functionlist = [];
+ if (!window.Store.luckysheet_function)
+ window.Store.luckysheet_function = {};
+ if (!window.Store.config) window.Store.config = {};
+ if (!window.Store.luckysheetfile)
+ window.Store.luckysheetfile = [];
+
+ // Level 2: 글로벌 function 객체들 완전 초기화
+ if (!window.luckysheet_function)
+ window.luckysheet_function = {};
+ if (!window.functionlist) window.functionlist = [];
+
+ // Level 3: Luckysheet 내부 깊은 레벨 초기화
+ 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 = {};
+
+ // Store 레퍼런스 초기화
+ if (!window.luckysheet.Store)
+ window.luckysheet.Store = window.Store;
+ if (!window.luckysheet.luckysheetfile)
+ window.luckysheet.luckysheetfile = [];
+
+ // 내부 모듈들 초기화
+ if (!window.luckysheet.menuButton)
+ window.luckysheet.menuButton = {};
+ if (!window.luckysheet.server) window.luckysheet.server = {};
+ if (!window.luckysheet.selection)
+ window.luckysheet.selection = {};
+ }
+
+ // Level 4: 추가적인 깊은 레벨 객체들 (Luckysheet 내부에서 사용할 수 있는)
+ if (!window.luckysheetConfigsetting)
+ window.luckysheetConfigsetting = {};
+ if (!window.luckysheetPostil) window.luckysheetPostil = {};
+ if (!window.Store.visibledatarow)
+ window.Store.visibledatarow = [];
+ if (!window.Store.visibledatacolumn)
+ window.Store.visibledatacolumn = [];
+ if (!window.Store.defaultcollen)
+ window.Store.defaultcollen = 73;
+ if (!window.Store.defaultrowlen)
+ window.Store.defaultrowlen = 19;
+
+ console.log("✅ 완전한 Luckysheet 내부 객체 초기화 완료");
+
+ // 극한의 방법: Luckysheet 내부 코드 직접 패치 (임시)
+ if (
+ window.luckysheet &&
+ typeof window.luckysheet.create === "function"
+ ) {
+ // Luckysheet 내부에서 사용하는 모든 가능한 functionlist 경로 강제 생성
+ const originalCreate = window.luckysheet.create;
+ window.luckysheet.create = function (options: any) {
+ try {
+ // 생성 직전 모든 functionlist 경로 재검증
+ if (!window.Store) window.Store = {};
+ if (!window.Store.functionlist)
+ window.Store.functionlist = [];
+ if (!this.functionlist) this.functionlist = [];
+ if (!this.Store) this.Store = window.Store;
+ if (!this.Store.functionlist)
+ this.Store.functionlist = [];
+
+ // 원본 함수 호출
+ return originalCreate.call(this, options);
+ } catch (error) {
+ console.error(
+ "Luckysheet create 패치된 함수에서 오류:",
+ error,
+ );
+ throw error;
+ }
+ };
+ console.log("🔧 Luckysheet.create 함수 패치 완료");
+ }
+ } catch (finalInitError) {
+ console.warn(
+ "⚠️ 완전한 functionlist 초기화 중 오류:",
+ finalInitError,
+ );
+ }
+
+ // 공식 LuckyExcel 방식: exportJson.sheets를 직접 사용
+ const luckysheetOptions = {
+ container: containerRef.current?.id || "luckysheet-container",
+ title: exportJson.info?.name || fileName || "Sheet Easy AI",
+ lang: "ko",
+ data: exportJson.sheets, // 공식 방식: LuckyExcel 결과를 직접 사용
+
+ // userInfo도 공식 예시대로 설정
+ userInfo: exportJson.info?.creator || "Sheet Easy AI User",
+
+ // UI 설정 (모든 기능 활성화)
+ showinfobar: false,
+ showtoolbar: true, // 툴바 활성화
+ showsheetbar: true,
+ showstatisticBar: false,
+ showConfigWindowResize: true,
+
+ // 편집 기능 활성화
+ allowCopy: true,
+ allowEdit: true,
+ enableAddRow: true, // 행/열 추가 활성화
+ enableAddCol: true,
+
+ // 함수 기능 활성화
+ allowUpdate: true,
+ enableAddBackTop: true,
+ showFormulaBar: true, // 수식바 활성화
+
+ // 이벤트 핸들러 (모든 기능)
+ hook: {
+ cellClick: (cell: any, position: any, sheetFile: any) => {
+ // console.log("🖱️ 셀 클릭:", { cell, position, sheetFile });
+ if (
+ position &&
+ typeof position.r === "number" &&
+ typeof position.c === "number"
+ ) {
+ setSelectedRange({
+ range: {
+ startRow: position.r,
+ startCol: position.c,
+ endRow: position.r,
+ endCol: position.c,
+ },
+ sheetId: sheetFile?.index || "0",
+ });
+ }
+ },
+ sheetActivate: (
+ index: number,
+ isPivotInitial: boolean,
+ isInitialLoad: boolean,
+ ) => {
+ // console.log("📋 시트 활성화:", {
+ // index,
+ // isPivotInitial,
+ // isInitialLoad,
+ // });
+ if (exportJson.sheets[index]) {
+ useAppStore.getState().setActiveSheetId(`sheet_${index}`);
+ }
+ },
+ updated: (operate: any) => {
+ // console.log("🔄 시트 업데이트:", operate);
+ },
+ },
+ };
+
+ // === 상세 디버깅 정보 ===
+ console.log("=== LuckyExcel → Luckysheet 디버깅 정보 ===");
+ console.log("📋 exportJson 구조:", {
+ hasExportJson: !!exportJson,
+ hasInfo: !!exportJson?.info,
+ hasSheets: !!exportJson?.sheets,
+ sheetsCount: exportJson?.sheets?.length,
+ infoKeys: Object.keys(exportJson?.info || {}),
+ firstSheetKeys: Object.keys(exportJson?.sheets?.[0] || {}),
+ });
+ console.log("🔧 Functionlist 상태 상세 검사:", {
+ windowStore: !!window.Store,
+ storeFunctionlist: !!window.Store?.functionlist,
+ storeFunctionlistLength: window.Store?.functionlist?.length,
+ luckysheetFunction: !!window.luckysheet_function,
+ globalFunctionlist: !!window.functionlist,
+ globalFunctionlistLength: window.functionlist?.length,
+ luckysheetStoreFunctionlist:
+ !!window.luckysheet?.Store?.functionlist,
+ luckysheetOwnFunctionlist: !!window.luckysheet?.functionlist,
+ allWindowKeys: Object.keys(window).filter((key) =>
+ key.includes("function"),
+ ),
+ allStoreKeys: Object.keys(window.Store || {}),
+ allLuckysheetKeys: Object.keys(window.luckysheet || {}).slice(
+ 0,
+ 10,
+ ),
+ });
+ console.log("🎯 Luckysheet 객체:", {
+ hasLuckysheet: !!window.luckysheet,
+ hasCreate: typeof window.luckysheet?.create === "function",
+ methodsCount: Object.keys(window.luckysheet || {}).length,
+ });
+ console.log("=== 디버깅 정보 끝 ===");
+
+ // Luckysheet 생성
+ window.luckysheet.create(luckysheetOptions);
+
+ luckysheetRef.current = window.luckysheet;
+ setIsInitialized(true);
+ setIsConverting(false);
+
+ // console.log(
+ // "✅ functionlist 초기화 완료: Luckysheet 초기화 완료!",
+ // );
+ } catch (initError) {
+ console.error("❌ Luckysheet 초기화 실패:", initError);
+ setError(
+ `시트 초기화에 실패했습니다: ${
+ initError instanceof Error
+ ? initError.message
+ : String(initError)
+ }`,
+ );
+ setIsInitialized(false);
+ setIsConverting(false);
+ }
+ },
+ // 오류 콜백
+ (error: any) => {
+ console.error("❌ LuckyExcel 변환 실패:", error);
+ setError(
+ `XLSX 변환에 실패했습니다: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ setIsConverting(false);
+ setIsInitialized(false);
+ },
+ );
+ } catch (conversionError) {
+ console.error("❌ 변환 프로세스 실패:", conversionError);
+ setError(
+ `변환 프로세스에 실패했습니다: ${
+ conversionError instanceof Error
+ ? conversionError.message
+ : String(conversionError)
+ }`,
+ );
+ setIsConverting(false);
+ setIsInitialized(false);
+ }
+ },
+ [loadLuckysheetLibrary, setSelectedRange],
+ );
+
+ /**
+ * DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
+ */
+ useLayoutEffect(() => {
+ // console.log("🔍 useLayoutEffect: DOM 컨테이너 체크 시작...");
+ if (containerRef.current) {
+ // console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
+ setIsContainerReady(true);
+ } else {
+ // console.warn("⚠️ useLayoutEffect: DOM 컨테이너가 아직 준비되지 않음");
+ }
+ }, []);
+
+ /**
+ * DOM 컨테이너 준비 상태 재체크 (fallback)
+ */
+ useEffect(() => {
+ if (!isContainerReady) {
+ // console.log("🔄 useEffect: DOM 컨테이너 재체크...");
+ const timer = setTimeout(() => {
+ if (containerRef.current && !isContainerReady) {
+ // console.log("✅ useEffect: DOM 컨테이너 지연 준비 완료");
+ setIsContainerReady(true);
+ }
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [isContainerReady]);
+
+ /**
+ * 컴포넌트 마운트 시 초기화 - 블로그 포스트 방식 적용
+ */
+ useEffect(() => {
+ // console.log("🔍 useEffect 실행 조건 체크:", {
+ // hasCurrentFile: !!currentFile,
+ // hasXlsxBuffer: !!currentFile?.xlsxBuffer,
+ // hasContainer: !!containerRef.current,
+ // isContainerReady,
+ // currentFileName: currentFile?.name,
+ // bufferSize: currentFile?.xlsxBuffer?.byteLength,
+ // });
+
+ if (currentFile?.xlsxBuffer && isContainerReady && containerRef.current) {
+ // console.log("🔄 변환된 XLSX 감지, LuckyExcel → Luckysheet 시작...", {
+ // fileName: currentFile.name,
+ // bufferSize: currentFile.xlsxBuffer.byteLength,
+ // containerId: containerRef.current.id,
+ // });
+
+ // 변환된 XLSX ArrayBuffer를 사용하여 직접 변환 (블로그 포스트 방식)
+ convertXLSXToLuckysheet(currentFile.xlsxBuffer, currentFile.name);
+ } else if (currentFile && !currentFile.xlsxBuffer) {
+ // console.warn(
+ // "⚠️ currentFile은 있지만 xlsxBuffer가 없습니다:",
+ // currentFile,
+ // );
+ setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
+ } else if (!currentFile) {
+ // console.log("ℹ️ currentFile이 없습니다. 파일을 업로드해주세요.");
+ } else if (currentFile?.xlsxBuffer && !isContainerReady) {
+ // console.log("⏳ DOM 컨테이너 준비 대기 중...");
+ }
+ }, [
+ currentFile?.xlsxBuffer,
+ currentFile?.name,
+ isContainerReady,
+ convertXLSXToLuckysheet,
+ ]);
+
+ /**
+ * 컴포넌트 언마운트 시 정리
+ */
+ useEffect(() => {
+ return () => {
+ if (luckysheetRef.current && window.luckysheet) {
+ // console.log("🧹 컴포넌트 언마운트: Luckysheet 정리 중...");
+ try {
+ window.luckysheet.destroy();
+ } catch (error) {
+ // console.warn("⚠️ Luckysheet 정리 중 오류:", error);
+ }
+ }
+ };
+ }, []);
+
+ /**
+ * 윈도우 리사이즈 처리
+ */
+ useEffect(() => {
+ const handleResize = () => {
+ if (luckysheetRef.current && window.luckysheet) {
+ try {
+ window.luckysheet.resize();
+ } catch (error) {
+ // console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
+ }
+ }
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return (
+
+ {/* Luckysheet 컨테이너 - 항상 렌더링 */}
+
+
+ {/* 에러 상태 오버레이 */}
+ {error && (
+
+
+
+ 시트 로드 오류
+
+
{error}
+
+
+
+ )}
+
+ {/* 로딩 상태 오버레이 */}
+ {!error &&
+ (isConverting || !isInitialized) &&
+ currentFile?.xlsxBuffer && (
+
+
+
+
+ {isConverting ? "XLSX 변환 중..." : "시트 초기화 중..."}
+
+
+ {isConverting
+ ? "변환된 XLSX를 Luckysheet로 처리하고 있습니다."
+ : "잠시만 기다려주세요."}
+
+
+
+ )}
+
+ {/* 데이터 없음 상태 오버레이 */}
+ {!error && !currentFile?.xlsxBuffer && (
+
+
+
+ 표시할 시트가 없습니다
+
+
+ Excel 파일을 업로드해주세요.
+
+
+
+ )}
+
+ {/* 시트 정보 표시 (개발용) */}
+ {process.env.NODE_ENV === "development" && (
+
+
파일: {currentFile?.name}
+
+ XLSX 버퍼:{" "}
+ {currentFile?.xlsxBuffer
+ ? `${currentFile.xlsxBuffer.byteLength} bytes`
+ : "없음"}
+
+
변환 중: {isConverting ? "예" : "아니오"}
+
초기화: {isInitialized ? "완료" : "대기"}
+
컨테이너 준비: {isContainerReady ? "완료" : "대기"}
+
+ )}
+
+ );
+}
diff --git a/src/components/sheet/__tests__/SheetViewer.test.tsx b/src/components/sheet/__tests__/SheetViewer.test.tsx
new file mode 100644
index 0000000..e58719c
--- /dev/null
+++ b/src/components/sheet/__tests__/SheetViewer.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText("시트 개수: 1")).toBeInTheDocument();
+ expect(screen.getByText("활성 시트: sheet_0")).toBeInTheDocument();
+
+ process.env.NODE_ENV = originalEnv;
+ });
+ });
+});
diff --git a/src/stores/useAppStore.ts b/src/stores/useAppStore.ts
index 9f25057..61a571e 100644
--- a/src/stores/useAppStore.ts
+++ b/src/stores/useAppStore.ts
@@ -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()(
name: result.fileName || "Unknown",
size: result.fileSize || 0,
uploadedAt: new Date(),
+ xlsxBuffer: result.xlsxBuffer,
},
sheets: result.data,
activeSheetId: result.data[0]?.id || null,
diff --git a/src/types/sheet.ts b/src/types/sheet.ts
index ef6a2d1..41d3067 100644
--- a/src/types/sheet.ts
+++ b/src/types/sheet.ts
@@ -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 {
diff --git a/src/utils/fileProcessor.ts b/src/utils/fileProcessor.ts
index d77150a..f4aaff5 100644
--- a/src/utils/fileProcessor.ts
+++ b/src/utils/fileProcessor.ts
@@ -419,7 +419,9 @@ function convertSheetJSToLuckyExcel(workbook: any): SheetData[] {
/**
* SheetJS로 파일을 읽고 XLSX로 변환한 뒤 LuckyExcel로 처리
*/
-async function processFileWithSheetJSToXLSX(file: File): Promise {
+async function processFileWithSheetJSToXLSX(
+ file: File,
+): Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }> {
console.log("📊 SheetJS → XLSX → LuckyExcel 파이프라인 시작...");
const arrayBuffer = await file.arrayBuffer();
@@ -632,194 +634,206 @@ async function processFileWithSheetJSToXLSX(file: File): Promise {
console.log("🚀 LuckyExcel 호출 시작...");
// Promise를 사용한 LuckyExcel 처리
- return new Promise((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);
+ return new Promise<{ sheets: SheetData[]; xlsxBuffer: ArrayBuffer }>(
+ (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) {
+ 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),
+ 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({
+ sheets: fallbackSheets,
+ xlsxBuffer: xlsxArrayBuffer,
});
+ 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,
+ xlsxBuffer: xlsxArrayBuffer, // 변환된 XLSX ArrayBuffer 포함
+ 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, xlsxBuffer: xlsxArrayBuffer });
+ } catch (processError) {
+ console.error("❌ LuckyExcel 후처리 중 오류:", processError);
+
+ // LuckyExcel 후처리 실패 시 SheetJS 방식으로 대체
+ try {
+ console.log("🔄 SheetJS 방식으로 대체 처리...");
+ const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
+ resolve({
+ sheets: fallbackSheets,
+ xlsxBuffer: xlsxArrayBuffer,
+ });
+ } catch (fallbackError) {
+ console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
+ reject(fallbackError);
}
}
+ },
+ // 오류 콜백 함수 (세 번째 매개변수)
+ (error: any) => {
+ console.error("❌ LuckyExcel 변환 오류:", error);
- 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 방식으로 대체
+ // LuckyExcel 오류 시 SheetJS 방식으로 대체
try {
console.log("🔄 SheetJS 방식으로 대체 처리...");
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
- resolve(fallbackSheets);
+ resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer });
} catch (fallbackError) {
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
reject(fallbackError);
}
- }
- },
- // 오류 콜백 함수 (세 번째 매개변수)
- (error: any) => {
- console.error("❌ LuckyExcel 변환 오류:", error);
+ },
+ );
+ } 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);
- }
- },
- );
- } 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);
+ // LuckyExcel 호출 실패 시 SheetJS 방식으로 대체
+ try {
+ console.log("🔄 SheetJS 방식으로 대체 처리...");
+ const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
+ resolve({ sheets: fallbackSheets, xlsxBuffer: xlsxArrayBuffer });
+ } catch (fallbackError) {
+ console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
+ reject(fallbackError);
+ }
}
- }
- });
+ },
+ );
}
/**
@@ -837,6 +851,7 @@ export async function processExcelFile(file: File): Promise {
error: errorMessage,
fileName: file.name,
fileSize: file.size,
+ file: file,
};
}
@@ -853,6 +868,7 @@ export async function processExcelFile(file: File): Promise {
"지원되지 않는 파일 형식입니다. .csv, .xls, .xlsx 파일을 사용해주세요.",
fileName: file.name,
fileSize: file.size,
+ file: file,
};
}
@@ -861,7 +877,7 @@ export async function processExcelFile(file: File): Promise {
);
// 통합된 처리 방식: SheetJS → XLSX → LuckyExcel
- const sheets = await processFileWithSheetJSToXLSX(file);
+ const { sheets, xlsxBuffer } = await processFileWithSheetJSToXLSX(file);
if (!sheets || sheets.length === 0) {
return {
@@ -869,6 +885,7 @@ export async function processExcelFile(file: File): Promise {
error: "파일에서 유효한 시트를 찾을 수 없습니다.",
fileName: file.name,
fileSize: file.size,
+ file: file,
};
}
@@ -879,6 +896,8 @@ export async function processExcelFile(file: File): Promise {
data: sheets,
fileName: file.name,
fileSize: file.size,
+ file: file,
+ xlsxBuffer,
};
} catch (error) {
console.error("❌ 파일 처리 중 오류 발생:", error);
@@ -912,6 +931,7 @@ export async function processExcelFile(file: File): Promise {
error: errorMessage,
fileName: file.name,
fileSize: file.size,
+ file: file,
};
}
}
diff --git a/src/utils/luckysheetApi.ts b/src/utils/luckysheetApi.ts
new file mode 100644
index 0000000..0519ecb
--- /dev/null
+++ b/src/utils/luckysheetApi.ts
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index d831097..93b140e 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -61,3 +61,123 @@ 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: any;
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4b39e24..8dafce2 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,10 @@
-///
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ // @ts-ignore - vitest config
test: {
globals: true,
environment: "jsdom",