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",