Korean-focused WYSIWYG Editor for Next.js with enhanced Korean support and image upload
npm install k-editor-koreanNext.js에서 쓸 에디터를 찾다가 하나 만듦
바이브코딩으로 만든거라 버그가 많을 수 있음
- ✅ 한글 입력 완벽 지원: IME 조합 문자 처리 및 한글 특화 기능
- ✅ 이미지 업로드: 드래그 앤 드롭, 붙여넣기, 파일 선택 지원
- ✅ 다양한 서식: 텍스트 스타일, 색상, 정렬, 목록 등
- ✅ 실행취소/다시실행: 완전한 히스토리 관리
- ✅ 반응형 디자인: 모바일 및 데스크톱 지원
- ✅ TypeScript: 완전한 타입 지원
- ✅ 범용성: React 프로젝트에서도 사용 가능
``bash`
npm install k-editoror
yarn add k-editoror
pnpm add k-editor
`tsx
import KEditor, { EditorContent, ImageMetadata } from 'k-editor';
function MyComponent() {
const [editorContent, setEditorContent] = useState
const handleImageUpload = async (file: File): Promise
// 이미지를 서버에 업로드하고 메타데이터 반환
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
return {
id: data.id || img_${Date.now()},`
fileName: file.name,
url: data.url,
filePath: data.filePath,
fileKey: data.fileKey, // S3 키 등
size: file.size,
mimeType: file.type,
width: data.width,
height: data.height,
uploadedAt: new Date(),
lastUsed: new Date()
};
};
const handleContentChange = (content: EditorContent) => {
setEditorContent(content);
console.log('HTML:', content.html);
console.log('Images:', content.images);
console.log('Text:', content.textContent);
console.log('Words:', content.wordCount);
};
return (
onChange={handleContentChange}
onImageUpload={handleImageUpload}
config={{
placeholder: '내용을 입력하세요...',
locale: 'ko',
theme: 'light',
}}
/>
);
}
`tsx
import KEditor, { KEditorConfig, EditorContent, ImageMetadata } from 'k-editor';
const config: KEditorConfig = {
placeholder: '여기에 텍스트를 입력하세요...',
maxLength: 50000,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFileSize: 5 1024 1024, // 5MB
enableImageTracking: true, // 이미지 추적 활성화
autoCleanup: false, // 자동 정리 비활성화 (운영환경에서 주의)
autoSave: true,
autoSaveInterval: 30000, // 30초
locale: 'ko', // 'ko' | 'en' | 'auto'
theme: 'light', // 'light' | 'dark' | 'auto'
};
function AdvancedEditor() {
// 새로운 형식 (권장)
const handleNewFormatChange = (content: EditorContent) => {
console.log('HTML:', content.html);
console.log('Images:', content.images.map(img => ({
id: img.id,
fileName: img.fileName,
url: img.url,
filePath: img.filePath,
fileKey: img.fileKey,
size: img.size
})));
// 데이터베이스 저장
saveToDatabase({
html: content.html,
images: content.images,
wordCount: content.wordCount
});
};
// 기존 호환성 지원
const handleLegacyChange = (htmlContent: string) => {
console.log('Legacy format:', htmlContent);
};
const handleImageUpload = async (file: File): Promise
// 서버 업로드 및 메타데이터 반환
const uploadResult = await uploadImageToServer(file);
return {
id: uploadResult.id,
fileName: file.name,
url: uploadResult.url,
filePath: uploadResult.path,
fileKey: uploadResult.s3Key,
size: file.size,
mimeType: file.type,
width: uploadResult.dimensions.width,
height: uploadResult.dimensions.height,
uploadedAt: new Date(),
lastUsed: new Date()
};
};
const handleImageRemove = async (imageId: string): Promise
try {
await fetch(/api/delete-image/${imageId}, { method: 'DELETE' });
return true;
} catch (error) {
console.error('이미지 삭제 실패:', error);
return false;
}
};
return (
onChange={handleNewFormatChange} // 새 형식
onContentChange={handleLegacyChange} // 기존 호환성
onSelectionChange={(selection) => console.log('Selection:', selection)}
onImageUpload={handleImageUpload}
onImageRemove={handleImageRemove}
className="my-editor"
style={{ minHeight: '400px' }}
/>
);
}
`
`typescript
// pages/api/upload.ts 또는 app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import sharp from 'sharp'; // 이미지 크기 추출용
import path from 'path';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file) {
return NextResponse.json({ error: 'No file received' }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 파일 저장 경로
const timestamp = Date.now();
const fileExtension = path.extname(file.name);
const baseName = path.basename(file.name, fileExtension);
const filename = ${timestamp}-${baseName}${fileExtension};img_${timestamp}_${baseName}
const filepath = path.join(process.cwd(), 'public/uploads', filename);
await writeFile(filepath, buffer);
// 이미지 크기 정보 추출
let dimensions = { width: 0, height: 0 };
try {
const metadata = await sharp(buffer).metadata();
dimensions = {
width: metadata.width || 0,
height: metadata.height || 0
};
} catch (error) {
console.warn('이미지 메타데이터 추출 실패:', error);
}
// ImageMetadata 형식으로 응답
return NextResponse.json({
id: ,/uploads/${filename}
fileName: file.name,
url: ,/uploads/${filename}
filePath: ,`
fileKey: filename, // 삭제 시 사용
size: file.size,
mimeType: file.type,
width: dimensions.width,
height: dimensions.height,
uploadedAt: new Date().toISOString(),
lastUsed: new Date().toISOString(),
message: 'File uploaded successfully'
});
} catch (error) {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
`tsx
interface KEditorProps {
config?: KEditorConfig;
initialValue?: string;
onChange?: (content: EditorContent) => void; // 새 형식
onContentChange?: (content: string) => void; // 기존 호환성
onSelectionChange?: (selection: { start: number; end: number }) => void;
onImageUpload?: (file: File) => Promise
onImageRemove?: (imageId: string) => Promise
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
readOnly?: boolean;
}
// EditorContent 구조
interface EditorContent {
html: string; // HTML 콘텐츠
images: ImageMetadata[]; // 사용된 이미지들
textContent?: string; // 순수 텍스트 (검색용)
wordCount?: number; // 단어 수
imageCount?: number; // 이미지 개수
}
// ImageMetadata 구조
interface ImageMetadata {
id: string; // 고유 식별자
fileName: string; // 원본 파일명
url: string; // 접근 가능한 URL
filePath: string; // 서버상 파일 경로
fileKey?: string; // S3 키 또는 삭제용 키
size?: number; // 파일 크기 (bytes)
mimeType?: string; // MIME 타입
width?: number; // 이미지 너비
height?: number; // 이미지 높이
uploadedAt: Date; // 업로드 시간
lastUsed: Date; // 마지막 사용 시간
}
`
`tsx
import { useEditorState } from 'k-editor';
function CustomEditor() {
const {
state,
updateContent,
updateSelection,
undo,
redo,
canUndo,
canRedo,
} = useEditorState('초기 내용');
// 커스텀 로직...
}
`
`tsx
import { useEditorCommands } from 'k-editor';
function CustomToolbar({ state, setState }) {
const { executeCommand } = useEditorCommands(state, setState);
return (
);
}
`
`typescript`
interface KEditorConfig {
placeholder?: string; // 플레이스홀더 텍스트
maxLength?: number; // 최대 글자 수
allowedFileTypes?: string[]; // 허용되는 이미지 타입
maxFileSize?: number; // 최대 파일 크기 (bytes)
imageUploadUrl?: string; // 이미지 업로드 URL (선택사항)
onImageUpload?: (file: File) => Promise
autoSave?: boolean; // 자동 저장 사용 여부
autoSaveInterval?: number; // 자동 저장 간격 (ms)
locale?: 'ko' | 'en' | 'auto'; // 언어 설정
theme?: 'light' | 'dark' | 'auto'; // 테마
}
K-Editor는 이미지를 DB에 직접 저장하지 않습니다. 대신 다음 방법들을 권장합니다:
#### 1. 클라우드 스토리지 + CDN (권장)
`tsx
import { compressImage } from 'k-editor/utils/imageOptimization';
const handleImageUpload = async (file: File): Promise
// 1. 이미지 압축 (DB 부담 줄이기)
const compressed = await compressImage(file, {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.8,
format: 'jpeg'
});
// 2. 클라우드 스토리지에 업로드
const formData = new FormData();
formData.append('image', compressed, file.name);
const response = await fetch('/api/upload-to-s3', {
method: 'POST',
body: formData,
});
const { cdnUrl } = await response.json();
return cdnUrl; // https://cdn.example.com/images/abc123.jpg
};
`
#### 2. 서버 스토리지 방식
`tsx`
const handleImageUpload = async (file: File): Promise
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData,
});
const { url } = await response.json();
return url; // /uploads/image-123.jpg
};
`tsx
import { compressImage, getImageDimensions, formatFileSize } from 'k-editor/utils/imageOptimization';
// 이미지 압축
const compressed = await compressImage(file, {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.8,
format: 'jpeg' // 또는 'webp', 'png'
});
// 이미지 크기 확인
const { width, height } = await getImageDimensions(file);
console.log(원본 크기: ${width}x${height});
// 파일 크기 포맷팅
console.log(압축 전: ${formatFileSize(file.size)});압축 후: ${formatFileSize(compressed.size)}
console.log();`
`typescript
// app/api/upload-to-s3/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { NextRequest, NextResponse } from 'next/server';
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('image') as File;
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const key = images/${Date.now()}-${file.name};https://${process.env.CLOUDFRONT_DOMAIN}/${key}
await s3Client.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: file.type,
}));
const cdnUrl = ;`
return NextResponse.json({
cdnUrl,
message: '업로드 성공'
});
} catch (error) {
return NextResponse.json({ error: '업로드 실패' }, { status: 500 });
}
}
| 방식 | DB 용량 | 장점 | 단점 |
|------|---------|------|------|
| Base64 (비권장) | 33% 증가 | 단순함 | DB 부담, 느린 로딩 |
| 서버 스토리지 | URL만 저장 | 빠른 구현 | 서버 디스크 관리 필요 |
| 클라우드 + CDN | URL만 저장 | 확장성, CDN 캐싱 | 초기 설정 복잡 |
`tsx
import {
isKorean,
KoreanIMEHandler,
disassembleHangul,
assembleHangul
} from 'k-editor';
// 한글 문자 확인
console.log(isKorean('한')); // true
console.log(isKorean('A')); // false
// 한글 분해/조합
const decomposed = disassembleHangul('한');
console.log(decomposed); // { cho: 'ㅎ', jung: 'ㅏ', jong: 'ㄴ' }
const composed = assembleHangul('ㅎ', 'ㅏ', 'ㄴ');
console.log(composed); // '한'
`
`css
.k-editor {
/ 에디터 컨테이너 /
}
.k-editor .toolbar {
/ 툴바 스타일 /
}
.k-editor .editor-content {
/ 편집 영역 스타일 /
}
.k-editor.dark {
/ 다크 테마 스타일 /
}
`
`tsx`
style={{
border: '2px solid #007bff',
borderRadius: '12px',
minHeight: '300px',
}}
/>
`bash개발 서버 실행
npm run dev
브라우저 지원
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- 모바일 브라우저 (iOS Safari, Chrome Mobile)
라이센스
MIT
기여하기
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
문제 신고
GitHub Issues를 통해 버그 리포트나 기능 요청을 해주세요.
파일 관리 및 정리
$3
문제: 포스트 삭제나 이미지 변경 시 고아 파일(orphaned files)이 누적되어 스토리지 낭비
해결책: 이미지 참조 추적 및 자동 정리 시스템
#### 기본 설정
`tsx
config={{
enableImageTracking: true, // 이미지 참조 추적 (기본값: true)
autoCleanup: true, // 자동 정리 활성화 (기본값: false)
autoCleanupInterval: 24, // 24시간마다 정리 (기본값: 24)
onImageDelete: async (url) => {
// 커스텀 삭제 로직
const response = await fetch('/api/delete-image', {
method: 'DELETE',
body: JSON.stringify({ url }),
});
return response.ok;
}
}}
/>
`#### 수동 파일 정리
`tsx
import { ImageCleanupPanel } from 'k-editor';function AdminPage() {
return (
파일 관리
onCleanupComplete={(result) => {
console.log(${result.deletedFiles.length}개 파일 삭제 완료);
}}
/>
);
}
`#### 프로그래밍 방식 정리
`tsx
import { globalImageManager, deleteFiles } from 'k-editor';// 포스트 삭제 시
const deletePost = async (postId: string) => {
// 1. 포스트의 이미지 참조 제거 및 고아 파일 목록 가져오기
const orphanedUrls = globalImageManager.removePostImages(postId);
// 2. 실제 파일 삭제
const result = await deleteFiles(orphanedUrls);
// 3. 포스트 데이터 삭제
await deletePostFromDB(postId);
console.log(
포스트 삭제 완료: ${result.deletedFiles.length}개 이미지 정리);
};// 이미지 참조 통계 확인
const stats = globalImageManager.getStatistics();
console.log(
총 ${stats.totalImages}개 이미지, ${stats.totalSize} 사용 중);// 정리 대상 파일 확인
const candidates = globalImageManager.getCleanupCandidates({ maxAge: 30 });
console.log(
${candidates.length}개 파일 정리 가능);
`$3
#### Next.js API Route
`typescript
// pages/api/delete-image.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { unlink, access } from 'fs/promises';
import path from 'path';export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'DELETE') {
return res.status(405).json({ success: false, message: 'Method not allowed' });
}
const { url } = req.body;
const filePath = path.join(process.cwd(), 'public', url);
try {
await access(filePath); // 파일 존재 확인
await unlink(filePath); // 파일 삭제
res.json({ success: true, message: '파일 삭제 완료' });
} catch (error) {
res.status(500).json({ success: false, message: '파일 삭제 실패' });
}
}
`#### AWS S3 삭제 예시
`typescript
// pages/api/delete-s3-image.ts
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';const s3Client = new S3Client({ region: process.env.AWS_REGION });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { url } = req.body;
// URL에서 S3 키 추출
const key = url.replace(
https://${process.env.CLOUDFRONT_DOMAIN}/, '');
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
}));
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
}
`$3
| 전략 | 설명 | 장점 | 단점 |
|------|------|------|------|
| 실시간 추적 | 이미지 사용/삭제를 실시간으로 추적 | 정확한 참조 관리 | 약간의 성능 오버헤드 |
| 배치 정리 | 정기적으로 사용되지 않는 파일 정리 | 안전하고 효율적 | 일시적 스토리지 사용량 증가 |
| 수명 관리 | 파일 업로드 후 일정 기간 후 자동 삭제 | 간단한 구현 | 필요한 파일도 삭제될 위험 |
$3
- Dry Run 모드: 실제 삭제 전 테스트 가능
- 백업 확인: 삭제 전 파일 백업 여부 확인
- 복구 기능: 실수로 삭제된 파일 복구 가능
- 권한 검증: 삭제 권한이 있는 파일만 처리
변경사항
$3
- 🔄 개선된 반환 형식: HTML + 이미지 메타데이터 배열로 변경
- 📊 이미지 메타데이터: 파일명, 경로, 키, 크기, 삭제 정보 포함
- 🔧 향상된 콜백:
onChange는 EditorContent 객체 반환
- ⚡ 호환성 지원: onContentChange`로 기존 코드 호환- 이미지 참조 추적 시스템 추가
- 자동 파일 정리 기능 구현
- 파일 삭제 API 및 관리 패널 추가
- 스토리지 최적화 및 고아 파일 방지
- 초기 릴리스
- 한글 입력 지원
- 이미지 업로드 기능
- 기본 WYSIWYG 기능